From 73396490ba74d94e938863287bdcd6a92791a512 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 27 Aug 2022 19:44:31 +0100 Subject: [PATCH 01/71] Set walletsPath and listsPath to AppData folder for new Windows installs. --- WindowsInstaller/Qortal.aip | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 74acc012..c90dda3d 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -1173,7 +1173,7 @@ - + From 64ef8ab8633a584c4a57108274091961a391a7fe Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 19 Sep 2022 16:36:39 +0100 Subject: [PATCH 02/71] OnlineAccountsV3Message.MIN_PEER_VERSION set to 3.6.0 --- .../org/qortal/network/message/OnlineAccountsV3Message.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java b/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java index 0c5f6730..d554d96c 100644 --- a/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java +++ b/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java @@ -20,7 +20,7 @@ import java.util.Map; */ public class OnlineAccountsV3Message extends Message { - public static final long MIN_PEER_VERSION = 0x300050001L; // 3.5.1 + public static final long MIN_PEER_VERSION = 0x300060000L; // 3.6.0 private List onlineAccounts; From 952c51ab2526e21ef15bfd97c9029b94afe76408 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 19 Sep 2022 17:27:07 +0100 Subject: [PATCH 03/71] QORA / block reward adjustments set to activate at height 1010000 --- 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 8d1600ed..fad81ab5 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -56,7 +56,7 @@ ], "qoraHoldersShareByHeight": [ { "height": 1, "share": 0.20 }, - { "height": 9999999, "share": 0.01 } + { "height": 1010000, "share": 0.01 } ], "qoraPerQortReward": 250, "minAccountsToActivateShareBin": 30, @@ -75,7 +75,7 @@ "atFindNextTransactionFix": 275000, "newBlockSigHeight": 320000, "shareBinFix": 399000, - "sharesByLevelV2Height": 9999999, + "sharesByLevelV2Height": 1010000, "rewardShareLimitTimestamp": 1657382400000, "calcChainWeightTimestamp": 1620579600000, "transactionV5Timestamp": 1642176000000, From b99b1f5d5766b25f995590d40643980b2006e05e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 19 Sep 2022 17:29:26 +0100 Subject: [PATCH 04/71] Bump version to 3.6.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 22017136..e045e0f4 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.5.0 + 3.6.0 jar true From 84d42b93e15ae478672e7fe81602f7ba542bd08e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 20 Sep 2022 08:50:37 +0100 Subject: [PATCH 05/71] Reordered code in Block.mint() to fix potential issue after mempow activates. --- src/main/java/org/qortal/block/Block.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index e0581e7d..bdae83c2 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -368,16 +368,17 @@ public class Block { // Fetch our list of online accounts List onlineAccounts = OnlineAccountsManager.getInstance().getOnlineAccounts(onlineAccountsTimestamp); - if (onlineAccounts.isEmpty()) { - LOGGER.error("No online accounts - not even our own?"); - return null; - } // If mempow is active, remove any legacy accounts that are missing a nonce if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); } + if (onlineAccounts.isEmpty()) { + LOGGER.error("No online accounts - not even our own?"); + return null; + } + // Load sorted list of reward share public keys into memory, so that the indexes can be obtained. // This is up to 100x faster than querying each index separately. For 4150 reward share keys, it // was taking around 5000ms to query individually, vs 50ms using this approach. From 951c85faf1b743cf5de9da21bb819c71b17449b4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 20 Sep 2022 22:26:30 +0100 Subject: [PATCH 06/71] Fixed bug causing error 500 in some cases. --- .../org/qortal/data/arbitrary/ArbitraryResourceMetadata.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java index 75b5a4d8..e2bcaf56 100644 --- a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java @@ -24,7 +24,10 @@ public class ArbitraryResourceMetadata { this.description = description; this.tags = tags; this.category = category; - this.categoryName = category.getName(); + + if (category != null) { + this.categoryName = category.getName(); + } } public static ArbitraryResourceMetadata fromTransactionMetadata(ArbitraryDataTransactionMetadata transactionMetadata) { From 49d83650f4c202a64b1883a0240cba6884002a00 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 23 Sep 2022 15:25:44 +0100 Subject: [PATCH 07/71] Removed online accounts V2 and V1 messaging, as the V3 format will soon be required due to the nonce values. --- .../org/qortal/controller/Controller.java | 11 +- .../controller/OnlineAccountsManager.java | 157 +----------------- 2 files changed, 6 insertions(+), 162 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 4ff08e15..f6711991 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1232,19 +1232,10 @@ public class Controller extends Thread { break; case GET_ONLINE_ACCOUNTS: - OnlineAccountsManager.getInstance().onNetworkGetOnlineAccountsMessage(peer, message); - break; - case ONLINE_ACCOUNTS: - OnlineAccountsManager.getInstance().onNetworkOnlineAccountsMessage(peer, message); - break; - case GET_ONLINE_ACCOUNTS_V2: - OnlineAccountsManager.getInstance().onNetworkGetOnlineAccountsV2Message(peer, message); - break; - case ONLINE_ACCOUNTS_V2: - OnlineAccountsManager.getInstance().onNetworkOnlineAccountsV2Message(peer, message); + // No longer supported - to be eventually removed break; case GET_ONLINE_ACCOUNTS_V3: diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 254d6168..b4bfab12 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -55,12 +55,8 @@ public class OnlineAccountsManager { private static final long ONLINE_ACCOUNTS_QUEUE_INTERVAL = 100L; //ms private static final long ONLINE_ACCOUNTS_TASKS_INTERVAL = 10 * 1000L; // ms - private static final long ONLINE_ACCOUNTS_LEGACY_BROADCAST_INTERVAL = 60 * 1000L; // ms private static final long ONLINE_ACCOUNTS_BROADCAST_INTERVAL = 5 * 1000L; // ms - private static final long ONLINE_ACCOUNTS_V2_PEER_VERSION = 0x0300020000L; // v3.2.0 - private static final long ONLINE_ACCOUNTS_V3_PEER_VERSION = 0x0300040000L; // v3.4.0 - // MemoryPoW public final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes public int POW_DIFFICULTY = 18; // leading zero bits @@ -125,9 +121,7 @@ public class OnlineAccountsManager { // Send our online accounts executor.scheduleAtFixedRate(this::sendOurOnlineAccountsInfo, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, TimeUnit.MILLISECONDS); - // Request online accounts from peers (legacy) - executor.scheduleAtFixedRate(this::requestLegacyRemoteOnlineAccounts, ONLINE_ACCOUNTS_LEGACY_BROADCAST_INTERVAL, ONLINE_ACCOUNTS_LEGACY_BROADCAST_INTERVAL, TimeUnit.MILLISECONDS); - // Request online accounts from peers (V3+) + // Request online accounts from peers executor.scheduleAtFixedRate(this::requestRemoteOnlineAccounts, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, TimeUnit.MILLISECONDS); // Process import queue @@ -399,30 +393,7 @@ public class OnlineAccountsManager { } /** - * Request data from other peers. (Pre-V3) - */ - private void requestLegacyRemoteOnlineAccounts() { - final Long now = NTP.getTime(); - if (now == null) - return; - - // Don't bother if we're not up to date - if (!Controller.getInstance().isUpToDate()) - return; - - List mergedOnlineAccounts = Set.copyOf(this.currentOnlineAccounts.values()).stream().flatMap(Set::stream).collect(Collectors.toList()); - - Message messageV2 = new GetOnlineAccountsV2Message(mergedOnlineAccounts); - - Network.getInstance().broadcast(peer -> - peer.getPeersVersion() < ONLINE_ACCOUNTS_V3_PEER_VERSION - ? messageV2 - : null - ); - } - - /** - * Request data from other peers. V3+ + * Request data from other peers */ private void requestRemoteOnlineAccounts() { final Long now = NTP.getTime(); @@ -435,11 +406,7 @@ public class OnlineAccountsManager { Message messageV3 = new GetOnlineAccountsV3Message(currentOnlineAccountsHashes); - Network.getInstance().broadcast(peer -> - peer.getPeersVersion() >= ONLINE_ACCOUNTS_V3_PEER_VERSION - ? messageV3 - : null - ); + Network.getInstance().broadcast(peer -> messageV3); } /** @@ -579,17 +546,7 @@ public class OnlineAccountsManager { if (!hasInfoChanged) return false; - Message messageV1 = new OnlineAccountsMessage(ourOnlineAccounts); - Message messageV2 = new OnlineAccountsV2Message(ourOnlineAccounts); - Message messageV3 = new OnlineAccountsV3Message(ourOnlineAccounts); - - Network.getInstance().broadcast(peer -> - peer.getPeersVersion() >= OnlineAccountsV3Message.MIN_PEER_VERSION - ? messageV3 - : peer.getPeersVersion() >= ONLINE_ACCOUNTS_V2_PEER_VERSION - ? messageV2 - : messageV1 - ); + Network.getInstance().broadcast(peer -> new OnlineAccountsV3Message(ourOnlineAccounts)); LOGGER.debug("Broadcasted {} online account{} with timestamp {}", ourOnlineAccounts.size(), (ourOnlineAccounts.size() != 1 ? "s" : ""), onlineAccountsTimestamp); @@ -767,106 +724,6 @@ public class OnlineAccountsManager { // Network handlers - public void onNetworkGetOnlineAccountsMessage(Peer peer, Message message) { - GetOnlineAccountsMessage getOnlineAccountsMessage = (GetOnlineAccountsMessage) message; - - List excludeAccounts = getOnlineAccountsMessage.getOnlineAccounts(); - - // Send online accounts info, excluding entries with matching timestamp & public key from excludeAccounts - List accountsToSend = Set.copyOf(this.currentOnlineAccounts.values()).stream().flatMap(Set::stream).collect(Collectors.toList()); - int prefilterSize = accountsToSend.size(); - - Iterator iterator = accountsToSend.iterator(); - while (iterator.hasNext()) { - OnlineAccountData onlineAccountData = iterator.next(); - - for (OnlineAccountData excludeAccountData : excludeAccounts) { - if (onlineAccountData.getTimestamp() == excludeAccountData.getTimestamp() && Arrays.equals(onlineAccountData.getPublicKey(), excludeAccountData.getPublicKey())) { - iterator.remove(); - break; - } - } - } - - if (accountsToSend.isEmpty()) - return; - - Message onlineAccountsMessage = new OnlineAccountsMessage(accountsToSend); - peer.sendMessage(onlineAccountsMessage); - - LOGGER.debug("Sent {} of our {} online accounts to {}", accountsToSend.size(), prefilterSize, peer); - } - - public void onNetworkOnlineAccountsMessage(Peer peer, Message message) { - OnlineAccountsMessage onlineAccountsMessage = (OnlineAccountsMessage) message; - - List peersOnlineAccounts = onlineAccountsMessage.getOnlineAccounts(); - LOGGER.debug("Received {} online accounts from {}", peersOnlineAccounts.size(), peer); - - int importCount = 0; - - // Add any online accounts to the queue that aren't already present - for (OnlineAccountData onlineAccountData : peersOnlineAccounts) { - boolean isNewEntry = onlineAccountsImportQueue.add(onlineAccountData); - - if (isNewEntry) - importCount++; - } - - if (importCount > 0) - LOGGER.debug("Added {} online accounts to queue", importCount); - } - - public void onNetworkGetOnlineAccountsV2Message(Peer peer, Message message) { - GetOnlineAccountsV2Message getOnlineAccountsMessage = (GetOnlineAccountsV2Message) message; - - List excludeAccounts = getOnlineAccountsMessage.getOnlineAccounts(); - - // Send online accounts info, excluding entries with matching timestamp & public key from excludeAccounts - List accountsToSend = Set.copyOf(this.currentOnlineAccounts.values()).stream().flatMap(Set::stream).collect(Collectors.toList()); - int prefilterSize = accountsToSend.size(); - - Iterator iterator = accountsToSend.iterator(); - while (iterator.hasNext()) { - OnlineAccountData onlineAccountData = iterator.next(); - - for (OnlineAccountData excludeAccountData : excludeAccounts) { - if (onlineAccountData.getTimestamp() == excludeAccountData.getTimestamp() && Arrays.equals(onlineAccountData.getPublicKey(), excludeAccountData.getPublicKey())) { - iterator.remove(); - break; - } - } - } - - if (accountsToSend.isEmpty()) - return; - - Message onlineAccountsMessage = new OnlineAccountsV2Message(accountsToSend); - peer.sendMessage(onlineAccountsMessage); - - LOGGER.debug("Sent {} of our {} online accounts to {}", accountsToSend.size(), prefilterSize, peer); - } - - public void onNetworkOnlineAccountsV2Message(Peer peer, Message message) { - OnlineAccountsV2Message onlineAccountsMessage = (OnlineAccountsV2Message) message; - - List peersOnlineAccounts = onlineAccountsMessage.getOnlineAccounts(); - LOGGER.debug("Received {} online accounts from {}", peersOnlineAccounts.size(), peer); - - int importCount = 0; - - // Add any online accounts to the queue that aren't already present - for (OnlineAccountData onlineAccountData : peersOnlineAccounts) { - boolean isNewEntry = onlineAccountsImportQueue.add(onlineAccountData); - - if (isNewEntry) - importCount++; - } - - if (importCount > 0) - LOGGER.debug("Added {} online accounts to queue", importCount); - } - public void onNetworkGetOnlineAccountsV3Message(Peer peer, Message message) { GetOnlineAccountsV3Message getOnlineAccountsMessage = (GetOnlineAccountsV3Message) message; @@ -920,11 +777,7 @@ public class OnlineAccountsManager { } } - peer.sendMessage( - peer.getPeersVersion() >= OnlineAccountsV3Message.MIN_PEER_VERSION ? - new OnlineAccountsV3Message(outgoingOnlineAccounts) : - new OnlineAccountsV2Message(outgoingOnlineAccounts) - ); + peer.sendMessage(new OnlineAccountsV3Message(outgoingOnlineAccounts)); LOGGER.debug("Sent {} online accounts to {}", outgoingOnlineAccounts.size(), peer); } From 84a16157d1e7407718b4439d9e1e1afdc629f108 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 23 Sep 2022 18:02:46 +0100 Subject: [PATCH 08/71] Don't add online accounts to the import queue if they are already validated --- .../java/org/qortal/controller/OnlineAccountsManager.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index b4bfab12..eaf12db3 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -792,6 +792,12 @@ public class OnlineAccountsManager { // Add any online accounts to the queue that aren't already present for (OnlineAccountData onlineAccountData : peersOnlineAccounts) { + + Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountData.getTimestamp(), k -> ConcurrentHashMap.newKeySet()); + if (onlineAccounts.contains(onlineAccountData)) + // We have already validated this online account + continue; + boolean isNewEntry = onlineAccountsImportQueue.add(onlineAccountData); if (isNewEntry) From 99858f378100ee8dea6103f1aed6d15219f730d7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 23 Sep 2022 18:28:41 +0100 Subject: [PATCH 09/71] Wait 30 seconds after the node starts before computing our online accounts. This allows some time for initial online account lists to be retrieved, and reduces the chances of the same nonce being computed twice. --- .../controller/OnlineAccountsManager.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index eaf12db3..39ce8a85 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -57,6 +57,8 @@ public class OnlineAccountsManager { private static final long ONLINE_ACCOUNTS_TASKS_INTERVAL = 10 * 1000L; // ms private static final long ONLINE_ACCOUNTS_BROADCAST_INTERVAL = 5 * 1000L; // ms + private static final long INITIAL_SLEEP_INTERVAL = 30 * 1000L; + // MemoryPoW public final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes public int POW_DIFFICULTY = 18; // leading zero bits @@ -118,14 +120,23 @@ public class OnlineAccountsManager { // Expire old online accounts signatures executor.scheduleAtFixedRate(this::expireOldOnlineAccounts, ONLINE_ACCOUNTS_TASKS_INTERVAL, ONLINE_ACCOUNTS_TASKS_INTERVAL, TimeUnit.MILLISECONDS); - // Send our online accounts - executor.scheduleAtFixedRate(this::sendOurOnlineAccountsInfo, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, TimeUnit.MILLISECONDS); - // Request online accounts from peers executor.scheduleAtFixedRate(this::requestRemoteOnlineAccounts, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, TimeUnit.MILLISECONDS); // Process import queue executor.scheduleWithFixedDelay(this::processOnlineAccountsImportQueue, ONLINE_ACCOUNTS_QUEUE_INTERVAL, ONLINE_ACCOUNTS_QUEUE_INTERVAL, TimeUnit.MILLISECONDS); + + // Sleep for some time before scheduling sendOurOnlineAccountsInfo() + // This allows some time for initial online account lists to be retrieved, and + // reduces the chances of the same nonce being computed twice + try { + Thread.sleep(INITIAL_SLEEP_INTERVAL); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + // Send our online accounts + executor.scheduleAtFixedRate(this::sendOurOnlineAccountsInfo, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, TimeUnit.MILLISECONDS); } public void shutdown() { From 6d9e6e8d4c89582ffad23c1094b6a8e3aee91116 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 23 Sep 2022 18:46:01 +0100 Subject: [PATCH 10/71] Allow duplicate variations of each OnlineAccountData in the import queue, but don't allow two entries that match exactly. --- .../controller/OnlineAccountsManager.java | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 39ce8a85..f770bc3a 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -66,7 +66,7 @@ public class OnlineAccountsManager { private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, new NamedThreadFactory("OnlineAccounts")); private volatile boolean isStopping = false; - private final Set onlineAccountsImportQueue = ConcurrentHashMap.newKeySet(); + private final List onlineAccountsImportQueue = Collections.synchronizedList(new ArrayList<>()); /** * Cache of 'current' online accounts, keyed by timestamp @@ -184,9 +184,12 @@ public class OnlineAccountsManager { LOGGER.debug("Processing online accounts import queue (size: {})", this.onlineAccountsImportQueue.size()); + // Take a copy of onlineAccountsImportQueue so we can safely remove whilst iterating + List onlineAccountsImportQueueCopy = new ArrayList<>(this.onlineAccountsImportQueue); + Set onlineAccountsToAdd = new HashSet<>(); try (final Repository repository = RepositoryManager.getRepository()) { - for (OnlineAccountData onlineAccountData : this.onlineAccountsImportQueue) { + for (OnlineAccountData onlineAccountData : onlineAccountsImportQueueCopy) { if (isStopping) return; @@ -207,6 +210,19 @@ public class OnlineAccountsManager { } } + private boolean importQueueContainsExactMatch(OnlineAccountData acc) { + // Check if an item exists where all properties match exactly + // This is needed because signature and nonce are not compared in OnlineAccountData.equals() + synchronized (onlineAccountsImportQueue) { + return onlineAccountsImportQueue.stream().anyMatch(otherAcc -> + acc.getTimestamp() == otherAcc.getTimestamp() && + Arrays.equals(acc.getPublicKey(), otherAcc.getPublicKey()) && + acc.getNonce() == otherAcc.getNonce() && + Arrays.equals(acc.getSignature(), otherAcc.getSignature()) + ); + } + } + /** * Check if supplied onlineAccountData is superior (i.e. has a nonce value) than existing record. * Two entries are considered equal even if the nonce differs, to prevent multiple variations @@ -809,6 +825,10 @@ public class OnlineAccountsManager { // We have already validated this online account continue; + if (this.importQueueContainsExactMatch(onlineAccountData)) + // Identical online account data already present in queue + continue; + boolean isNewEntry = onlineAccountsImportQueue.add(onlineAccountData); if (isNewEntry) From ea4f4d949bdefac88736207b84b16a4d2229412d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 23 Sep 2022 19:45:59 +0100 Subject: [PATCH 11/71] When validating online accounts, enforce mempow if the online account's timestamp is after the feature trigger. --- .../java/org/qortal/controller/OnlineAccountsManager.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index f770bc3a..4d1ab561 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -325,8 +325,9 @@ public class OnlineAccountsManager { return false; } - // Validate mempow if feature trigger is active - if (now >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { + // Validate mempow if feature trigger is active (or if online account's timestamp is past the trigger timestamp) + long memoryPoWStartTimestamp = BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); + if (now >= memoryPoWStartTimestamp || onlineAccountTimestamp >= memoryPoWStartTimestamp) { if (!getInstance().verifyMemoryPoW(onlineAccountData, now)) { LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); return false; @@ -628,7 +629,8 @@ public class OnlineAccountsManager { } public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData, Long timestamp) { - if (!isMemoryPoWActive(timestamp)) { + long memoryPoWStartTimestamp = BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); + if (timestamp < memoryPoWStartTimestamp && onlineAccountData.getTimestamp() < memoryPoWStartTimestamp) { // Not active yet, so treat it as valid return true; } From c7cf33ef7838bf96777af0e3fab77e67c4e6cf1c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 10:23:55 +0100 Subject: [PATCH 12/71] Set hasOurOnlineAccounts to true if one of our accounts is found before signing. --- src/main/java/org/qortal/controller/OnlineAccountsManager.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 4d1ab561..32d0a47a 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -517,6 +517,8 @@ public class OnlineAccountsManager { Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountsTimestamp, k -> ConcurrentHashMap.newKeySet()); boolean alreadyExists = onlineAccounts.stream().anyMatch(a -> Arrays.equals(a.getPublicKey(), publicKey)); if (alreadyExists) { + this.hasOurOnlineAccounts = true; + if (remaining > 0) { // Move on to next account continue; From 174a779e4cd9bc692ef1ef8ecc03edb20206cc04 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 10:56:52 +0100 Subject: [PATCH 13/71] Add accounts from the import queue individually, and then skip future duplicates before unnecessarily validating them again. This closes a gap where accounts would be moved from onlineAccountsImportQueue to onlineAccountsToAdd, but not yet imported. During this time, there was nothing to stop them from being added to the import queue again, causing duplicate validations. --- .../controller/OnlineAccountsManager.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 32d0a47a..de8cfb12 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -193,9 +193,17 @@ public class OnlineAccountsManager { if (isStopping) return; + // Skip this account if it's already validated + Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountData.getTimestamp(), k -> ConcurrentHashMap.newKeySet()); + if (onlineAccounts.contains(onlineAccountData)) { + // We have already validated this online account + onlineAccountsImportQueue.remove(onlineAccountData); + continue; + } + boolean isValid = this.isValidCurrentAccount(repository, onlineAccountData); if (isValid) - onlineAccountsToAdd.add(onlineAccountData); + addAccounts(Arrays.asList(onlineAccountData)); // Remove from queue onlineAccountsImportQueue.remove(onlineAccountData); @@ -203,11 +211,6 @@ public class OnlineAccountsManager { } catch (DataException e) { LOGGER.error("Repository issue while verifying online accounts", e); } - - if (!onlineAccountsToAdd.isEmpty()) { - LOGGER.debug("Merging {} validated online accounts from import queue", onlineAccountsToAdd.size()); - addAccounts(onlineAccountsToAdd); - } } private boolean importQueueContainsExactMatch(OnlineAccountData acc) { @@ -381,7 +384,7 @@ public class OnlineAccountsManager { } } - LOGGER.debug(String.format("we have online accounts for timestamps: %s", String.join(", ", this.currentOnlineAccounts.keySet().stream().map(l -> Long.toString(l)).collect(Collectors.joining(", "))))); + LOGGER.trace(String.format("we have online accounts for timestamps: %s", String.join(", ", this.currentOnlineAccounts.keySet().stream().map(l -> Long.toString(l)).collect(Collectors.joining(", "))))); return true; } From 5b81b30974b4fba01bbf796e45d2bb4bbdd7b185 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 13:02:27 +0100 Subject: [PATCH 14/71] Modified online accounts request interval, and introduced bursting. It will now request online accounts every 1 minute instead of every 5 seconds, except for the first 5 minutes following a new online accounts timestamp, in which it will request every 5 seconds (referred to as the "burst" interval). It will also use the burst interval for the first 5 minutes after the node starts. This is based on the idea that most online accounts arrive soon after a new timestamp begins, and so there is no need to request accounts so frequently after that. This should reduce data usage by a significant amount. Once mempow is fully rolled out, the "burst" feature can be reduced or removed, since online accounts will be sent ahead of time, generally 15-30 mins prior to the new online accounts timestamp becoming active. --- .../org/qortal/controller/Controller.java | 4 +++ .../controller/OnlineAccountsManager.java | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index f6711991..8e1dfd8a 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -317,6 +317,10 @@ public class Controller extends Thread { } } + public static long uptime() { + return System.currentTimeMillis() - Controller.startTime; + } + /** Returns highest block, or null if it's not available. */ public BlockData getChainTip() { synchronized (this.latestBlocks) { diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index de8cfb12..a0f4db68 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -55,7 +55,12 @@ public class OnlineAccountsManager { private static final long ONLINE_ACCOUNTS_QUEUE_INTERVAL = 100L; //ms private static final long ONLINE_ACCOUNTS_TASKS_INTERVAL = 10 * 1000L; // ms - private static final long ONLINE_ACCOUNTS_BROADCAST_INTERVAL = 5 * 1000L; // ms + private static final long ONLINE_ACCOUNTS_COMPUTE_INTERVAL = 5 * 1000L; // ms + private static final long ONLINE_ACCOUNTS_BROADCAST_INTERVAL = 60 * 1000L; // ms + // After switching to a new online timestamp, we "burst" the online accounts requests + // at an increased interval for a specified amount of time + private static final long ONLINE_ACCOUNTS_BROADCAST_BURST_INTERVAL = 5 * 1000L; // ms + private static final long ONLINE_ACCOUNTS_BROADCAST_BURST_LENGTH = 5 * 60 * 1000L; // ms private static final long INITIAL_SLEEP_INTERVAL = 30 * 1000L; @@ -83,6 +88,8 @@ public class OnlineAccountsManager { */ private final SortedMap> latestBlocksOnlineAccounts = new ConcurrentSkipListMap<>(); + private long lastOnlineAccountsRequest = 0; + private boolean hasOurOnlineAccounts = false; public static long getOnlineTimestampModulus() { @@ -121,7 +128,7 @@ public class OnlineAccountsManager { executor.scheduleAtFixedRate(this::expireOldOnlineAccounts, ONLINE_ACCOUNTS_TASKS_INTERVAL, ONLINE_ACCOUNTS_TASKS_INTERVAL, TimeUnit.MILLISECONDS); // Request online accounts from peers - executor.scheduleAtFixedRate(this::requestRemoteOnlineAccounts, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, TimeUnit.MILLISECONDS); + executor.scheduleAtFixedRate(this::requestRemoteOnlineAccounts, ONLINE_ACCOUNTS_BROADCAST_BURST_INTERVAL, ONLINE_ACCOUNTS_BROADCAST_BURST_INTERVAL, TimeUnit.MILLISECONDS); // Process import queue executor.scheduleWithFixedDelay(this::processOnlineAccountsImportQueue, ONLINE_ACCOUNTS_QUEUE_INTERVAL, ONLINE_ACCOUNTS_QUEUE_INTERVAL, TimeUnit.MILLISECONDS); @@ -136,7 +143,7 @@ public class OnlineAccountsManager { } // Send our online accounts - executor.scheduleAtFixedRate(this::sendOurOnlineAccountsInfo, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, ONLINE_ACCOUNTS_BROADCAST_INTERVAL, TimeUnit.MILLISECONDS); + executor.scheduleAtFixedRate(this::sendOurOnlineAccountsInfo, ONLINE_ACCOUNTS_COMPUTE_INTERVAL, ONLINE_ACCOUNTS_COMPUTE_INTERVAL, TimeUnit.MILLISECONDS); } public void shutdown() { @@ -435,8 +442,24 @@ public class OnlineAccountsManager { if (!Controller.getInstance().isUpToDate()) return; - Message messageV3 = new GetOnlineAccountsV3Message(currentOnlineAccountsHashes); + long onlineAccountsTimestamp = getCurrentOnlineAccountTimestamp(); + if (now - onlineAccountsTimestamp >= ONLINE_ACCOUNTS_BROADCAST_BURST_LENGTH) { + // New online timestamp started more than 5 mins ago - we probably don't need to request so frequently + if (Controller.uptime() < ONLINE_ACCOUNTS_BROADCAST_BURST_LENGTH) { + // The node recently started up, so we should request at the burst interval + // This could allow accounts to move around the network more easily when an auto update is occurring + } + else if (now - lastOnlineAccountsRequest < ONLINE_ACCOUNTS_BROADCAST_INTERVAL) { + // We already requested online accounts in the last minute, so no need to request again + return; + } + } + + LOGGER.info("Requesting online accounts via broadcast..."); + + lastOnlineAccountsRequest = now; + Message messageV3 = new GetOnlineAccountsV3Message(currentOnlineAccountsHashes); Network.getInstance().broadcast(peer -> messageV3); } From 863a5eff9735671f24f72a03fafc9fb41886bd88 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 13:11:28 +0100 Subject: [PATCH 15/71] Moved various online accounts logs to TRACE level, to make it easier to monitor the queue processing when in DEBUG. --- .../org/qortal/controller/OnlineAccountsManager.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index a0f4db68..40192876 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -365,7 +365,7 @@ public class OnlineAccountsManager { for (var entry : hashesToRebuild.entrySet()) { Long timestamp = entry.getKey(); - LOGGER.debug(() -> String.format("Rehashing for timestamp %d and leading bytes %s", + LOGGER.trace(() -> String.format("Rehashing for timestamp %d and leading bytes %s", timestamp, entry.getValue().stream().sorted(Byte::compareUnsigned).map(leadingByte -> String.format("%02x", leadingByte)).collect(Collectors.joining(", ")) ) @@ -456,7 +456,7 @@ public class OnlineAccountsManager { } } - LOGGER.info("Requesting online accounts via broadcast..."); + LOGGER.debug("Requesting online accounts via broadcast..."); lastOnlineAccountsRequest = now; Message messageV3 = new GetOnlineAccountsV3Message(currentOnlineAccountsHashes); @@ -801,7 +801,7 @@ public class OnlineAccountsManager { Set timestampsOnlineAccounts = this.currentOnlineAccounts.getOrDefault(timestamp, Collections.emptySet()); outgoingOnlineAccounts.addAll(timestampsOnlineAccounts); - LOGGER.debug(() -> String.format("Going to send all %d online accounts for timestamp %d", timestampsOnlineAccounts.size(), timestamp)); + LOGGER.trace(() -> String.format("Going to send all %d online accounts for timestamp %d", timestampsOnlineAccounts.size(), timestamp)); } else { // Quick cache of which leading bytes to send so we only have to filter once Set outgoingLeadingBytes = new HashSet<>(); @@ -825,7 +825,7 @@ public class OnlineAccountsManager { .forEach(outgoingOnlineAccounts::add); if (outgoingOnlineAccounts.size() > beforeAddSize) - LOGGER.debug(String.format("Going to send %d online accounts for timestamp %d and leading bytes %s", + LOGGER.trace(String.format("Going to send %d online accounts for timestamp %d and leading bytes %s", outgoingOnlineAccounts.size() - beforeAddSize, timestamp, outgoingLeadingBytes.stream().sorted(Byte::compareUnsigned).map(leadingByte -> String.format("%02x", leadingByte)).collect(Collectors.joining(", ")) @@ -836,14 +836,14 @@ public class OnlineAccountsManager { peer.sendMessage(new OnlineAccountsV3Message(outgoingOnlineAccounts)); - LOGGER.debug("Sent {} online accounts to {}", outgoingOnlineAccounts.size(), peer); + LOGGER.trace("Sent {} online accounts to {}", outgoingOnlineAccounts.size(), peer); } public void onNetworkOnlineAccountsV3Message(Peer peer, Message message) { OnlineAccountsV3Message onlineAccountsMessage = (OnlineAccountsV3Message) message; List peersOnlineAccounts = onlineAccountsMessage.getOnlineAccounts(); - LOGGER.debug("Received {} online accounts from {}", peersOnlineAccounts.size(), peer); + LOGGER.trace("Received {} online accounts from {}", peersOnlineAccounts.size(), peer); int importCount = 0; From 94cdc10151669c92b7daedbce43e47c5212a01d8 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 31 May 2022 21:06:34 +0100 Subject: [PATCH 16/71] Initial work on BLOCK_SUMMARIES_V2, part of a bigger arc to improve synchronization. Touches quite a few files because: * Deprecate HEIGHT_V2 because it doesn't contain enough info to be fully useful during sync. Newer peers will re-use BLOCK_SUMMARIES_V2. * For newer peers, instead of sending / broadcasting HEIGHT_V2, send top N block summaries instead, to avoid requests for minor reorgs. * When responding to GET_BLOCK, and we don't actually have the requested block, we currently send an empty BLOCK_SUMMARIES message instead of not responding, which would cause a slow timeout in Synchronizer. This pattern has spread to other network message response code, so now we introduce a generic 'unknown' message type for all these cases. * Remove PeerChainTipData class entirely and re-use BlockSummaryData instead. * Each Peer instance used to hold PeerChainTipData - essentially single latest block summary - but now holds a List of latest block summaries. * PeerChainTipData getter/setter methods modified for compatibility at this point in time. * Repository methods that return BlockSummaryData (or lists of) now try to fully populate them, including newly added block reference field. * Re-worked Peer.canUseCommonBlockData() to be more readable * Cherry-picked patch to Message.fromByteBuffer() to pass an empty, read-only ByteBuffer to subclass fromByteBuffer() methods, instead of null. This allows natural use of BufferUnderflowException if a subclass tries to use read(), or hasRemaining(), etc. from an empty data-payload message. Previously this could have caused an NPE. --- .../org/qortal/api/model/ConnectedPeer.java | 10 +- .../org/qortal/controller/BlockMinter.java | 16 +-- .../org/qortal/controller/Controller.java | 102 +++++++++++------ .../org/qortal/controller/Synchronizer.java | 44 ++++---- .../arbitrary/ArbitraryDataFileManager.java | 7 +- .../qortal/data/block/BlockSummaryData.java | 24 +++- .../qortal/data/block/CommonBlockData.java | 8 +- .../qortal/data/network/PeerChainTipData.java | 37 ------- src/main/java/org/qortal/network/Network.java | 62 +++++++++-- src/main/java/org/qortal/network/Peer.java | 66 ++++++----- .../message/BlockSummariesV2Message.java | 104 ++++++++++++++++++ .../message/GenericUnknownMessage.java | 23 ++++ .../qortal/network/message/MessageType.java | 2 + .../hsqldb/HSQLDBBlockArchiveRepository.java | 8 +- .../hsqldb/HSQLDBBlockRepository.java | 13 ++- 15 files changed, 367 insertions(+), 159 deletions(-) delete mode 100644 src/main/java/org/qortal/data/network/PeerChainTipData.java create mode 100644 src/main/java/org/qortal/network/message/BlockSummariesV2Message.java create mode 100644 src/main/java/org/qortal/network/message/GenericUnknownMessage.java diff --git a/src/main/java/org/qortal/api/model/ConnectedPeer.java b/src/main/java/org/qortal/api/model/ConnectedPeer.java index 21bfc1f9..3d383321 100644 --- a/src/main/java/org/qortal/api/model/ConnectedPeer.java +++ b/src/main/java/org/qortal/api/model/ConnectedPeer.java @@ -1,7 +1,7 @@ package org.qortal.api.model; import io.swagger.v3.oas.annotations.media.Schema; -import org.qortal.data.network.PeerChainTipData; +import org.qortal.data.block.BlockSummaryData; import org.qortal.data.network.PeerData; import org.qortal.network.Handshake; import org.qortal.network.Peer; @@ -63,11 +63,11 @@ public class ConnectedPeer { this.age = "connecting..."; } - PeerChainTipData peerChainTipData = peer.getChainTipData(); + BlockSummaryData peerChainTipData = peer.getChainTipData(); if (peerChainTipData != null) { - this.lastHeight = peerChainTipData.getLastHeight(); - this.lastBlockSignature = peerChainTipData.getLastBlockSignature(); - this.lastBlockTimestamp = peerChainTipData.getLastBlockTimestamp(); + this.lastHeight = peerChainTipData.getHeight(); + this.lastBlockSignature = peerChainTipData.getSignature(); + this.lastBlockTimestamp = peerChainTipData.getTimestamp(); } } diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 343ab4af..a07d37fe 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -26,6 +26,9 @@ import org.qortal.data.block.CommonBlockData; import org.qortal.data.transaction.TransactionData; import org.qortal.network.Network; import org.qortal.network.Peer; +import org.qortal.network.message.BlockSummariesV2Message; +import org.qortal.network.message.HeightV2Message; +import org.qortal.network.message.Message; import org.qortal.repository.BlockRepository; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -431,16 +434,9 @@ public class BlockMinter extends Thread { blockchainLock.unlock(); } - if (newBlockMinted) { - // Broadcast our new chain to network - BlockData newBlockData = newBlock.getBlockData(); - - Network network = Network.getInstance(); - network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newBlockData)); - } - } catch (InterruptedException e) { - // We've been interrupted - time to exit - return; + if (newBlockMinted) { + // Broadcast our new chain to network + Network.getInstance().broadcastOurChain(); } } } catch (DataException e) { diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 8e1dfd8a..ce994757 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -45,7 +45,6 @@ import org.qortal.data.account.AccountData; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.naming.NameData; -import org.qortal.data.network.PeerChainTipData; import org.qortal.data.network.PeerData; import org.qortal.data.transaction.ChatTransactionData; import org.qortal.data.transaction.TransactionData; @@ -731,25 +730,25 @@ public class Controller extends Thread { public static final Predicate hasNoRecentBlock = peer -> { final Long minLatestBlockTimestamp = getMinimumLatestBlockTimestamp(); - final PeerChainTipData peerChainTipData = peer.getChainTipData(); - return peerChainTipData == null || peerChainTipData.getLastBlockTimestamp() == null || peerChainTipData.getLastBlockTimestamp() < minLatestBlockTimestamp; + final BlockSummaryData peerChainTipData = peer.getChainTipData(); + return peerChainTipData == null || peerChainTipData.getTimestamp() == null || peerChainTipData.getTimestamp() < minLatestBlockTimestamp; }; public static final Predicate hasNoOrSameBlock = peer -> { final BlockData latestBlockData = getInstance().getChainTip(); - final PeerChainTipData peerChainTipData = peer.getChainTipData(); - return peerChainTipData == null || peerChainTipData.getLastBlockSignature() == null || Arrays.equals(latestBlockData.getSignature(), peerChainTipData.getLastBlockSignature()); + final BlockSummaryData peerChainTipData = peer.getChainTipData(); + return peerChainTipData == null || peerChainTipData.getSignature() == null || Arrays.equals(latestBlockData.getSignature(), peerChainTipData.getSignature()); }; public static final Predicate hasOnlyGenesisBlock = peer -> { - final PeerChainTipData peerChainTipData = peer.getChainTipData(); - return peerChainTipData == null || peerChainTipData.getLastHeight() == null || peerChainTipData.getLastHeight() == 1; + final BlockSummaryData peerChainTipData = peer.getChainTipData(); + return peerChainTipData == null || peerChainTipData.getHeight() == 1; }; public static final Predicate hasInferiorChainTip = peer -> { - final PeerChainTipData peerChainTipData = peer.getChainTipData(); + final BlockSummaryData peerChainTipData = peer.getChainTipData(); final List inferiorChainTips = Synchronizer.getInstance().inferiorChainSignatures; - return peerChainTipData == null || peerChainTipData.getLastBlockSignature() == null || inferiorChainTips.contains(ByteArray.wrap(peerChainTipData.getLastBlockSignature())); + return peerChainTipData == null || peerChainTipData.getSignature() == null || inferiorChainTips.contains(ByteArray.wrap(peerChainTipData.getSignature())); }; public static final Predicate hasOldVersion = peer -> { @@ -1011,8 +1010,7 @@ public class Controller extends Thread { network.broadcast(peer -> peer.isOutbound() ? network.buildPeersMessage(peer) : new GetPeersMessage()); // Send our current height - BlockData latestBlockData = getChainTip(); - network.broadcast(peer -> network.buildHeightMessage(peer, latestBlockData)); + network.broadcastOurChain(); // Request unconfirmed transaction signatures, but only if we're up-to-date. // If we're NOT up-to-date then priority is synchronizing first @@ -1219,6 +1217,10 @@ public class Controller extends Thread { onNetworkHeightV2Message(peer, message); break; + case BLOCK_SUMMARIES_V2: + onNetworkBlockSummariesV2Message(peer, message); + break; + case GET_TRANSACTION: TransactionImporter.getInstance().onNetworkGetTransactionMessage(peer, message); break; @@ -1373,8 +1375,10 @@ public class Controller extends Thread { // Send valid, yet unexpected message type in response, so peer's synchronizer doesn't have to wait for timeout LOGGER.debug(() -> String.format("Sending 'block unknown' response to peer %s for GET_BLOCK request for unknown block %s", peer, Base58.encode(signature))); - // We'll send empty block summaries message as it's very short - Message blockUnknownMessage = new BlockSummariesMessage(Collections.emptyList()); + // Send generic 'unknown' message as it's very short + Message blockUnknownMessage = peer.getPeersVersion() >= GenericUnknownMessage.MINIMUM_PEER_VERSION + ? new GenericUnknownMessage() + : new BlockSummariesMessage(Collections.emptyList()); blockUnknownMessage.setId(message.getId()); if (!peer.sendMessage(blockUnknownMessage)) peer.disconnect("failed to send block-unknown response"); @@ -1423,11 +1427,15 @@ public class Controller extends Thread { this.stats.getBlockSummariesStats.requests.incrementAndGet(); // If peer's parent signature matches our latest block signature - // then we can short-circuit with an empty response + // then we have no blocks after that and can short-circuit with an empty response BlockData chainTip = getChainTip(); if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { - Message blockSummariesMessage = new BlockSummariesMessage(Collections.emptyList()); + Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION + ? new BlockSummariesV2Message(Collections.emptyList()) + : new BlockSummariesMessage(Collections.emptyList()); + blockSummariesMessage.setId(message.getId()); + if (!peer.sendMessage(blockSummariesMessage)) peer.disconnect("failed to send block summaries"); @@ -1483,7 +1491,9 @@ public class Controller extends Thread { this.stats.getBlockSummariesStats.fullyFromCache.incrementAndGet(); } - Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); + Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION + ? new BlockSummariesV2Message(blockSummaries) + : new BlockSummariesMessage(blockSummaries); blockSummariesMessage.setId(message.getId()); if (!peer.sendMessage(blockSummariesMessage)) peer.disconnect("failed to send block summaries"); @@ -1558,18 +1568,48 @@ public class Controller extends Thread { // If peer is inbound and we've not updated their height // then this is probably their initial HEIGHT_V2 message // so they need a corresponding HEIGHT_V2 message from us - if (!peer.isOutbound() && (peer.getChainTipData() == null || peer.getChainTipData().getLastHeight() == null)) - peer.sendMessage(Network.getInstance().buildHeightMessage(peer, getChainTip())); + if (!peer.isOutbound() && peer.getChainTipData() == null) { + Message responseMessage = Network.getInstance().buildHeightOrChainTipInfo(peer); + + if (responseMessage == null || !peer.sendMessage(responseMessage)) { + peer.disconnect("failed to send our chain tip info"); + return; + } + } } // Update peer chain tip data - PeerChainTipData newChainTipData = new PeerChainTipData(heightV2Message.getHeight(), heightV2Message.getSignature(), heightV2Message.getTimestamp(), heightV2Message.getMinterPublicKey()); + BlockSummaryData newChainTipData = new BlockSummaryData(heightV2Message.getHeight(), heightV2Message.getSignature(), heightV2Message.getMinterPublicKey(), heightV2Message.getTimestamp()); peer.setChainTipData(newChainTipData); // Potentially synchronize Synchronizer.getInstance().requestSync(); } + private void onNetworkBlockSummariesV2Message(Peer peer, Message message) { + BlockSummariesV2Message blockSummariesV2Message = (BlockSummariesV2Message) message; + + if (!Settings.getInstance().isLite()) { + // If peer is inbound and we've not updated their height + // then this is probably their initial BLOCK_SUMMARIES_V2 message + // so they need a corresponding BLOCK_SUMMARIES_V2 message from us + if (!peer.isOutbound() && peer.getChainTipData() == null) { + Message responseMessage = Network.getInstance().buildHeightOrChainTipInfo(peer); + + if (responseMessage == null || !peer.sendMessage(responseMessage)) { + peer.disconnect("failed to send our chain tip info"); + return; + } + } + } + + // Update peer chain tip data + peer.setChainTipSummaries(blockSummariesV2Message.getBlockSummaries()); + + // Potentially synchronize + Synchronizer.getInstance().requestSync(); + } + private void onNetworkGetAccountMessage(Peer peer, Message message) { GetAccountMessage getAccountMessage = (GetAccountMessage) message; String address = getAccountMessage.getAddress(); @@ -1585,8 +1625,8 @@ public class Controller extends Thread { // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT request for unknown account %s", peer, address)); - // We'll send empty block summaries message as it's very short - Message accountUnknownMessage = new BlockSummariesMessage(Collections.emptyList()); + // Send generic 'unknown' message as it's very short + Message accountUnknownMessage = new GenericUnknownMessage(); accountUnknownMessage.setId(message.getId()); if (!peer.sendMessage(accountUnknownMessage)) peer.disconnect("failed to send account-unknown response"); @@ -1621,8 +1661,8 @@ public class Controller extends Thread { // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT_BALANCE request for unknown account %s and asset ID %d", peer, address, assetId)); - // We'll send empty block summaries message as it's very short - Message accountUnknownMessage = new BlockSummariesMessage(Collections.emptyList()); + // Send generic 'unknown' message as it's very short + Message accountUnknownMessage = new GenericUnknownMessage(); accountUnknownMessage.setId(message.getId()); if (!peer.sendMessage(accountUnknownMessage)) peer.disconnect("failed to send account-unknown response"); @@ -1665,8 +1705,8 @@ public class Controller extends Thread { // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT_TRANSACTIONS request for unknown account %s", peer, address)); - // We'll send empty block summaries message as it's very short - Message accountUnknownMessage = new BlockSummariesMessage(Collections.emptyList()); + // Send generic 'unknown' message as it's very short + Message accountUnknownMessage = new GenericUnknownMessage(); accountUnknownMessage.setId(message.getId()); if (!peer.sendMessage(accountUnknownMessage)) peer.disconnect("failed to send account-unknown response"); @@ -1702,8 +1742,8 @@ public class Controller extends Thread { // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT_NAMES request for unknown account %s", peer, address)); - // We'll send empty block summaries message as it's very short - Message accountUnknownMessage = new BlockSummariesMessage(Collections.emptyList()); + // Send generic 'unknown' message as it's very short + Message accountUnknownMessage = new GenericUnknownMessage(); accountUnknownMessage.setId(message.getId()); if (!peer.sendMessage(accountUnknownMessage)) peer.disconnect("failed to send account-unknown response"); @@ -1737,8 +1777,8 @@ public class Controller extends Thread { // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout LOGGER.debug(() -> String.format("Sending 'name unknown' response to peer %s for GET_NAME request for unknown name %s", peer, name)); - // We'll send empty block summaries message as it's very short - Message nameUnknownMessage = new BlockSummariesMessage(Collections.emptyList()); + // Send generic 'unknown' message as it's very short + Message nameUnknownMessage = new GenericUnknownMessage(); nameUnknownMessage.setId(message.getId()); if (!peer.sendMessage(nameUnknownMessage)) peer.disconnect("failed to send name-unknown response"); @@ -1786,14 +1826,14 @@ public class Controller extends Thread { continue; } - final PeerChainTipData peerChainTipData = peer.getChainTipData(); + BlockSummaryData peerChainTipData = peer.getChainTipData(); if (peerChainTipData == null) { iterator.remove(); continue; } // Disregard peers that don't have a recent block - if (peerChainTipData.getLastBlockTimestamp() == null || peerChainTipData.getLastBlockTimestamp() < minLatestBlockTimestamp) { + if (peerChainTipData.getTimestamp() == null || peerChainTipData.getTimestamp() < minLatestBlockTimestamp) { iterator.remove(); continue; } diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 74a4a785..a6fbfe71 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -19,7 +19,6 @@ import org.qortal.block.BlockChain; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.block.CommonBlockData; -import org.qortal.data.network.PeerChainTipData; import org.qortal.data.transaction.RewardShareTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.event.Event; @@ -282,7 +281,7 @@ public class Synchronizer extends Thread { BlockData priorChainTip = Controller.getInstance().getChainTip(); synchronized (this.syncLock) { - this.syncPercent = (priorChainTip.getHeight() * 100) / peer.getChainTipData().getLastHeight(); + this.syncPercent = (priorChainTip.getHeight() * 100) / peer.getChainTipData().getHeight(); // Only update SysTray if we're potentially changing height if (this.syncPercent < 100) { @@ -312,7 +311,7 @@ public class Synchronizer extends Thread { case INFERIOR_CHAIN: { // Update our list of inferior chain tips - ByteArray inferiorChainSignature = ByteArray.wrap(peer.getChainTipData().getLastBlockSignature()); + ByteArray inferiorChainSignature = ByteArray.wrap(peer.getChainTipData().getSignature()); if (!inferiorChainSignatures.contains(inferiorChainSignature)) inferiorChainSignatures.add(inferiorChainSignature); @@ -320,7 +319,8 @@ public class Synchronizer extends Thread { LOGGER.debug(() -> String.format("Refused to synchronize with peer %s (%s)", peer, syncResult.name())); // Notify peer of our superior chain - if (!peer.sendMessage(Network.getInstance().buildHeightMessage(peer, priorChainTip))) + Message message = Network.getInstance().buildHeightOrChainTipInfo(peer); + if (message == null || !peer.sendMessage(message)) peer.disconnect("failed to notify peer of our superior chain"); break; } @@ -341,7 +341,7 @@ public class Synchronizer extends Thread { // fall-through... case NOTHING_TO_DO: { // Update our list of inferior chain tips - ByteArray inferiorChainSignature = ByteArray.wrap(peer.getChainTipData().getLastBlockSignature()); + ByteArray inferiorChainSignature = ByteArray.wrap(peer.getChainTipData().getSignature()); if (!inferiorChainSignatures.contains(inferiorChainSignature)) inferiorChainSignatures.add(inferiorChainSignature); @@ -369,8 +369,7 @@ public class Synchronizer extends Thread { // Reset our cache of inferior chains inferiorChainSignatures.clear(); - Network network = Network.getInstance(); - network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newChainTip)); + Network.getInstance().broadcastOurChain(); EventBus.INSTANCE.notify(new NewChainTipEvent(priorChainTip, newChainTip)); } @@ -513,13 +512,13 @@ public class Synchronizer extends Thread { final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock(); final int ourInitialHeight = ourLatestBlockData.getHeight(); - PeerChainTipData peerChainTipData = peer.getChainTipData(); - int peerHeight = peerChainTipData.getLastHeight(); - byte[] peersLastBlockSignature = peerChainTipData.getLastBlockSignature(); + BlockSummaryData peerChainTipData = peer.getChainTipData(); + int peerHeight = peerChainTipData.getHeight(); + byte[] peersLastBlockSignature = peerChainTipData.getSignature(); byte[] ourLastBlockSignature = ourLatestBlockData.getSignature(); LOGGER.debug(String.format("Fetching summaries from peer %s at height %d, sig %.8s, ts %d; our height %d, sig %.8s, ts %d", peer, - peerHeight, Base58.encode(peersLastBlockSignature), peer.getChainTipData().getLastBlockTimestamp(), + peerHeight, Base58.encode(peersLastBlockSignature), peerChainTipData.getTimestamp(), ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp())); List peerBlockSummaries = new ArrayList<>(); @@ -637,9 +636,9 @@ public class Synchronizer extends Thread { return peers; // Count the number of blocks this peer has beyond our common block - final PeerChainTipData peerChainTipData = peer.getChainTipData(); - final int peerHeight = peerChainTipData.getLastHeight(); - final byte[] peerLastBlockSignature = peerChainTipData.getLastBlockSignature(); + final BlockSummaryData peerChainTipData = peer.getChainTipData(); + final int peerHeight = peerChainTipData.getHeight(); + final byte[] peerLastBlockSignature = peerChainTipData.getSignature(); final int peerAdditionalBlocksAfterCommonBlock = peerHeight - commonBlockSummary.getHeight(); // Limit the number of blocks we are comparing. FUTURE: we could request more in batches, but there may not be a case when this is needed int summariesRequired = Math.min(peerAdditionalBlocksAfterCommonBlock, MAXIMUM_REQUEST_SIZE); @@ -727,8 +726,9 @@ public class Synchronizer extends Thread { LOGGER.debug(String.format("Listing peers with common block %.8s...", Base58.encode(commonBlockSummary.getSignature()))); for (Peer peer : peersSharingCommonBlock) { - final int peerHeight = peer.getChainTipData().getLastHeight(); - final Long peerLastBlockTimestamp = peer.getChainTipData().getLastBlockTimestamp(); + BlockSummaryData peerChainTipData = peer.getChainTipData(); + final int peerHeight = peerChainTipData.getHeight(); + final Long peerLastBlockTimestamp = peerChainTipData.getTimestamp(); final int peerAdditionalBlocksAfterCommonBlock = peerHeight - commonBlockSummary.getHeight(); final CommonBlockData peerCommonBlockData = peer.getCommonBlockData(); @@ -825,7 +825,7 @@ public class Synchronizer extends Thread { // Calculate the length of the shortest peer chain sharing this common block int minChainLength = 0; for (Peer peer : peersSharingCommonBlock) { - final int peerHeight = peer.getChainTipData().getLastHeight(); + final int peerHeight = peer.getChainTipData().getHeight(); final int peerAdditionalBlocksAfterCommonBlock = peerHeight - commonBlockSummary.getHeight(); if (peerAdditionalBlocksAfterCommonBlock < minChainLength || minChainLength == 0) @@ -933,13 +933,13 @@ public class Synchronizer extends Thread { final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock(); final int ourInitialHeight = ourLatestBlockData.getHeight(); - PeerChainTipData peerChainTipData = peer.getChainTipData(); - int peerHeight = peerChainTipData.getLastHeight(); - byte[] peersLastBlockSignature = peerChainTipData.getLastBlockSignature(); + BlockSummaryData peerChainTipData = peer.getChainTipData(); + int peerHeight = peerChainTipData.getHeight(); + byte[] peersLastBlockSignature = peerChainTipData.getSignature(); byte[] ourLastBlockSignature = ourLatestBlockData.getSignature(); String syncString = String.format("Synchronizing with peer %s at height %d, sig %.8s, ts %d; our height %d, sig %.8s, ts %d", peer, - peerHeight, Base58.encode(peersLastBlockSignature), peer.getChainTipData().getLastBlockTimestamp(), + peerHeight, Base58.encode(peersLastBlockSignature), peerChainTipData.getTimestamp(), ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp()); LOGGER.info(syncString); @@ -1313,7 +1313,7 @@ public class Synchronizer extends Thread { // Final check to make sure the peer isn't out of date (except for when we're in recovery mode) if (!recoveryMode && peer.getChainTipData() != null) { final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); - final Long peerLastBlockTimestamp = peer.getChainTipData().getLastBlockTimestamp(); + final Long peerLastBlockTimestamp = peer.getChainTipData().getTimestamp(); if (peerLastBlockTimestamp == null || peerLastBlockTimestamp < minLatestBlockTimestamp) { LOGGER.info(String.format("Peer %s is out of date, so abandoning sync attempt", peer)); return SynchronizationResult.CHAIN_TIP_TOO_OLD; diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java index 22cf4144..30b0fcca 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java @@ -595,9 +595,10 @@ public class ArbitraryDataFileManager extends Thread { // Send valid, yet unexpected message type in response, so peer's synchronizer doesn't have to wait for timeout LOGGER.debug(String.format("Sending 'file unknown' response to peer %s for GET_FILE request for unknown file %s", peer, arbitraryDataFile)); - // We'll send empty block summaries message as it's very short - // TODO: use a different message type here - Message fileUnknownMessage = new BlockSummariesMessage(Collections.emptyList()); + // Send generic 'unknown' message as it's very short + Message fileUnknownMessage = peer.getPeersVersion() >= GenericUnknownMessage.MINIMUM_PEER_VERSION + ? new GenericUnknownMessage() + : new BlockSummariesMessage(Collections.emptyList()); fileUnknownMessage.setId(message.getId()); if (!peer.sendMessage(fileUnknownMessage)) { LOGGER.debug("Couldn't sent file-unknown response"); diff --git a/src/main/java/org/qortal/data/block/BlockSummaryData.java b/src/main/java/org/qortal/data/block/BlockSummaryData.java index 2167f0f0..57e29d0d 100644 --- a/src/main/java/org/qortal/data/block/BlockSummaryData.java +++ b/src/main/java/org/qortal/data/block/BlockSummaryData.java @@ -11,11 +11,12 @@ public class BlockSummaryData { private int height; private byte[] signature; private byte[] minterPublicKey; - private int onlineAccountsCount; // Optional, set during construction + private Integer onlineAccountsCount; private Long timestamp; private Integer transactionCount; + private byte[] reference; // Optional, set after construction private Integer minterLevel; @@ -25,6 +26,15 @@ public class BlockSummaryData { protected BlockSummaryData() { } + /** Constructor typically populated with fields from HeightV2Message */ + public BlockSummaryData(int height, byte[] signature, byte[] minterPublicKey, long timestamp) { + this.height = height; + this.signature = signature; + this.minterPublicKey = minterPublicKey; + this.timestamp = timestamp; + } + + /** Constructor typically populated with fields from BlockSummariesMessage */ public BlockSummaryData(int height, byte[] signature, byte[] minterPublicKey, int onlineAccountsCount) { this.height = height; this.signature = signature; @@ -32,13 +42,16 @@ public class BlockSummaryData { this.onlineAccountsCount = onlineAccountsCount; } - public BlockSummaryData(int height, byte[] signature, byte[] minterPublicKey, int onlineAccountsCount, long timestamp, int transactionCount) { + /** Constructor typically populated with fields from BlockSummariesV2Message */ + public BlockSummaryData(int height, byte[] signature, byte[] minterPublicKey, Integer onlineAccountsCount, + Long timestamp, Integer transactionCount, byte[] reference) { this.height = height; this.signature = signature; this.minterPublicKey = minterPublicKey; this.onlineAccountsCount = onlineAccountsCount; this.timestamp = timestamp; this.transactionCount = transactionCount; + this.reference = reference; } public BlockSummaryData(BlockData blockData) { @@ -49,6 +62,7 @@ public class BlockSummaryData { this.timestamp = blockData.getTimestamp(); this.transactionCount = blockData.getTransactionCount(); + this.reference = blockData.getReference(); } // Getters / setters @@ -65,7 +79,7 @@ public class BlockSummaryData { return this.minterPublicKey; } - public int getOnlineAccountsCount() { + public Integer getOnlineAccountsCount() { return this.onlineAccountsCount; } @@ -77,6 +91,10 @@ public class BlockSummaryData { return this.transactionCount; } + public byte[] getReference() { + return this.reference; + } + public Integer getMinterLevel() { return this.minterLevel; } diff --git a/src/main/java/org/qortal/data/block/CommonBlockData.java b/src/main/java/org/qortal/data/block/CommonBlockData.java index dd502df7..37e9649b 100644 --- a/src/main/java/org/qortal/data/block/CommonBlockData.java +++ b/src/main/java/org/qortal/data/block/CommonBlockData.java @@ -1,7 +1,5 @@ package org.qortal.data.block; -import org.qortal.data.network.PeerChainTipData; - import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import java.math.BigInteger; @@ -14,14 +12,14 @@ public class CommonBlockData { private BlockSummaryData commonBlockSummary = null; private List blockSummariesAfterCommonBlock = null; private BigInteger chainWeight = null; - private PeerChainTipData chainTipData = null; + private BlockSummaryData chainTipData = null; // Constructors protected CommonBlockData() { } - public CommonBlockData(BlockSummaryData commonBlockSummary, PeerChainTipData chainTipData) { + public CommonBlockData(BlockSummaryData commonBlockSummary, BlockSummaryData chainTipData) { this.commonBlockSummary = commonBlockSummary; this.chainTipData = chainTipData; } @@ -49,7 +47,7 @@ public class CommonBlockData { this.chainWeight = chainWeight; } - public PeerChainTipData getChainTipData() { + public BlockSummaryData getChainTipData() { return this.chainTipData; } diff --git a/src/main/java/org/qortal/data/network/PeerChainTipData.java b/src/main/java/org/qortal/data/network/PeerChainTipData.java deleted file mode 100644 index d8dbbad4..00000000 --- a/src/main/java/org/qortal/data/network/PeerChainTipData.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.qortal.data.network; - -public class PeerChainTipData { - - /** Latest block height as reported by peer. */ - private Integer lastHeight; - /** Latest block signature as reported by peer. */ - private byte[] lastBlockSignature; - /** Latest block timestamp as reported by peer. */ - private Long lastBlockTimestamp; - /** Latest block minter public key as reported by peer. */ - private byte[] lastBlockMinter; - - public PeerChainTipData(Integer lastHeight, byte[] lastBlockSignature, Long lastBlockTimestamp, byte[] lastBlockMinter) { - this.lastHeight = lastHeight; - this.lastBlockSignature = lastBlockSignature; - this.lastBlockTimestamp = lastBlockTimestamp; - this.lastBlockMinter = lastBlockMinter; - } - - public Integer getLastHeight() { - return this.lastHeight; - } - - public byte[] getLastBlockSignature() { - return this.lastBlockSignature; - } - - public Long getLastBlockTimestamp() { - return this.lastBlockTimestamp; - } - - public byte[] getLastBlockMinter() { - return this.lastBlockMinter; - } - -} diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index 57073e99..8aac68f0 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -11,6 +11,7 @@ import org.qortal.controller.arbitrary.ArbitraryDataFileListManager; import org.qortal.controller.arbitrary.ArbitraryDataManager; import org.qortal.crypto.Crypto; import org.qortal.data.block.BlockData; +import org.qortal.data.block.BlockSummaryData; import org.qortal.data.network.PeerData; import org.qortal.data.transaction.TransactionData; import org.qortal.network.message.*; @@ -90,6 +91,8 @@ public class Network { private static final long DISCONNECTION_CHECK_INTERVAL = 10 * 1000L; // milliseconds + private static final int BROADCAST_CHAIN_TIP_DEPTH = 7; // Just enough to fill a SINGLE TCP packet (~1440 bytes) + // Generate our node keys / ID private final Ed25519PrivateKeyParameters edPrivateKeyParams = new Ed25519PrivateKeyParameters(new SecureRandom()); private final Ed25519PublicKeyParameters edPublicKeyParams = edPrivateKeyParams.generatePublicKey(); @@ -1087,10 +1090,16 @@ public class Network { if (peer.isOutbound()) { if (!Settings.getInstance().isLite()) { - // Send our height - Message heightMessage = buildHeightMessage(peer, Controller.getInstance().getChainTip()); - if (!peer.sendMessage(heightMessage)) { - peer.disconnect("failed to send height/info"); + // Send our height / chain tip info + Message message = this.buildHeightOrChainTipInfo(peer); + + if (message == null) { + peer.disconnect("Couldn't build our chain tip info"); + return; + } + + if (!peer.sendMessage(message)) { + peer.disconnect("failed to send height / chain tip info"); return; } } @@ -1164,10 +1173,47 @@ public class Network { return new PeersV2Message(peerAddresses); } - public Message buildHeightMessage(Peer peer, BlockData blockData) { - // HEIGHT_V2 contains way more useful info - return new HeightV2Message(blockData.getHeight(), blockData.getSignature(), - blockData.getTimestamp(), blockData.getMinterPublicKey()); + /** Builds either (legacy) HeightV2Message or (newer) BlockSummariesV2Message, depending on peer version. + * + * @return Message, or null if DataException was thrown. + */ + public Message buildHeightOrChainTipInfo(Peer peer) { + if (peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION) { + int latestHeight = Controller.getInstance().getChainHeight(); + + try (final Repository repository = RepositoryManager.getRepository()) { + List latestBlockSummaries = repository.getBlockRepository().getBlockSummaries(latestHeight - BROADCAST_CHAIN_TIP_DEPTH, latestHeight); + return new BlockSummariesV2Message(latestBlockSummaries); + } catch (DataException e) { + return null; + } + } else { + // For older peers + BlockData latestBlockData = Controller.getInstance().getChainTip(); + return new HeightV2Message(latestBlockData.getHeight(), latestBlockData.getSignature(), + latestBlockData.getTimestamp(), latestBlockData.getMinterPublicKey()); + } + } + + public void broadcastOurChain() { + BlockData latestBlockData = Controller.getInstance().getChainTip(); + int latestHeight = latestBlockData.getHeight(); + + try (final Repository repository = RepositoryManager.getRepository()) { + List latestBlockSummaries = repository.getBlockRepository().getBlockSummaries(latestHeight - BROADCAST_CHAIN_TIP_DEPTH, latestHeight); + Message latestBlockSummariesMessage = new BlockSummariesV2Message(latestBlockSummaries); + + // For older peers + Message heightMessage = new HeightV2Message(latestBlockData.getHeight(), latestBlockData.getSignature(), + latestBlockData.getTimestamp(), latestBlockData.getMinterPublicKey()); + + Network.getInstance().broadcast(broadcastPeer -> broadcastPeer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION + ? latestBlockSummariesMessage + : heightMessage + ); + } catch (DataException e) { + LOGGER.warn("Couldn't broadcast our chain tip info", e); + } } public Message buildNewTransactionMessage(Peer peer, TransactionData transactionData) { diff --git a/src/main/java/org/qortal/network/Peer.java b/src/main/java/org/qortal/network/Peer.java index cac0ccc9..a187d29b 100644 --- a/src/main/java/org/qortal/network/Peer.java +++ b/src/main/java/org/qortal/network/Peer.java @@ -6,8 +6,8 @@ import com.google.common.net.InetAddresses; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.controller.Controller; +import org.qortal.data.block.BlockSummaryData; import org.qortal.data.block.CommonBlockData; -import org.qortal.data.network.PeerChainTipData; import org.qortal.data.network.PeerData; import org.qortal.network.message.ChallengeMessage; import org.qortal.network.message.Message; @@ -148,7 +148,7 @@ public class Peer { /** * Latest block info as reported by peer. */ - private PeerChainTipData peersChainTipData; + private List peersChainTipData = Collections.emptyList(); /** * Our common block with this peer @@ -353,28 +353,34 @@ public class Peer { } } - public PeerChainTipData getChainTipData() { - synchronized (this.peerInfoLock) { - return this.peersChainTipData; - } + public BlockSummaryData getChainTipData() { + List chainTipSummaries = this.peersChainTipData; + + if (chainTipSummaries.isEmpty()) + return null; + + // Return last entry, which should have greatest height + return chainTipSummaries.get(chainTipSummaries.size() - 1); } - public void setChainTipData(PeerChainTipData chainTipData) { - synchronized (this.peerInfoLock) { - this.peersChainTipData = chainTipData; - } + public void setChainTipData(BlockSummaryData chainTipData) { + this.peersChainTipData = Collections.singletonList(chainTipData); + } + + public List getChainTipSummaries() { + return this.peersChainTipData; + } + + public void setChainTipSummaries(List chainTipSummaries) { + this.peersChainTipData = List.copyOf(chainTipSummaries); } public CommonBlockData getCommonBlockData() { - synchronized (this.peerInfoLock) { - return this.commonBlockData; - } + return this.commonBlockData; } public void setCommonBlockData(CommonBlockData commonBlockData) { - synchronized (this.peerInfoLock) { - this.commonBlockData = commonBlockData; - } + this.commonBlockData = commonBlockData; } public boolean isSyncInProgress() { @@ -904,20 +910,22 @@ public class Peer { // Common block data public boolean canUseCachedCommonBlockData() { - PeerChainTipData peerChainTipData = this.getChainTipData(); - CommonBlockData commonBlockData = this.getCommonBlockData(); + BlockSummaryData peerChainTipData = this.getChainTipData(); + if (peerChainTipData == null || peerChainTipData.getSignature() == null) + return false; - if (peerChainTipData != null && commonBlockData != null) { - PeerChainTipData commonBlockChainTipData = commonBlockData.getChainTipData(); - if (peerChainTipData.getLastBlockSignature() != null && commonBlockChainTipData != null - && commonBlockChainTipData.getLastBlockSignature() != null) { - if (Arrays.equals(peerChainTipData.getLastBlockSignature(), - commonBlockChainTipData.getLastBlockSignature())) { - return true; - } - } - } - return false; + CommonBlockData commonBlockData = this.getCommonBlockData(); + if (commonBlockData == null) + return false; + + BlockSummaryData commonBlockChainTipData = commonBlockData.getChainTipData(); + if (commonBlockChainTipData == null || commonBlockChainTipData.getSignature() == null) + return false; + + if (!Arrays.equals(peerChainTipData.getSignature(), commonBlockChainTipData.getSignature())) + return false; + + return true; } diff --git a/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java b/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java new file mode 100644 index 00000000..96c661a4 --- /dev/null +++ b/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java @@ -0,0 +1,104 @@ +package org.qortal.network.message; + +import com.google.common.primitives.Ints; +import com.google.common.primitives.Longs; +import org.qortal.data.block.BlockSummaryData; +import org.qortal.transform.Transformer; +import org.qortal.transform.block.BlockTransformer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +public class BlockSummariesV2Message extends Message { + + public static final long MINIMUM_PEER_VERSION = 0x03000400cbL; + + private static final int BLOCK_SUMMARY_V2_LENGTH = BlockTransformer.BLOCK_SIGNATURE_LENGTH /* block signature */ + + Transformer.PUBLIC_KEY_LENGTH /* minter public key */ + + Transformer.INT_LENGTH /* online accounts count */ + + Transformer.LONG_LENGTH /* block timestamp */ + + Transformer.INT_LENGTH /* transactions count */ + + BlockTransformer.BLOCK_SIGNATURE_LENGTH; /* block reference */ + + private List blockSummaries; + + public BlockSummariesV2Message(List blockSummaries) { + super(MessageType.BLOCK_SUMMARIES_V2); + + // Shortcut for when there are no summaries + if (blockSummaries.isEmpty()) { + this.dataBytes = Message.EMPTY_DATA_BYTES; + return; + } + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + // First summary's height + bytes.write(Ints.toByteArray(blockSummaries.get(0).getHeight())); + + for (BlockSummaryData blockSummary : blockSummaries) { + bytes.write(blockSummary.getSignature()); + bytes.write(blockSummary.getMinterPublicKey()); + bytes.write(Ints.toByteArray(blockSummary.getOnlineAccountsCount())); + bytes.write(Longs.toByteArray(blockSummary.getTimestamp())); + bytes.write(Ints.toByteArray(blockSummary.getTransactionCount())); + bytes.write(blockSummary.getReference()); + } + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + } + + private BlockSummariesV2Message(int id, List blockSummaries) { + super(id, MessageType.BLOCK_SUMMARIES_V2); + + this.blockSummaries = blockSummaries; + } + + public List getBlockSummaries() { + return this.blockSummaries; + } + + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + int height = bytes.getInt(); + + // Expecting bytes remaining to be exact multiples of BLOCK_SUMMARY_V2_LENGTH + if (bytes.remaining() % BLOCK_SUMMARY_V2_LENGTH != 0) + throw new BufferUnderflowException(); + + List blockSummaries = new ArrayList<>(); + while (bytes.hasRemaining()) { + byte[] signature = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; + bytes.get(signature); + + byte[] minterPublicKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; + bytes.get(minterPublicKey); + + int onlineAccountsCount = bytes.getInt(); + + long timestamp = bytes.getLong(); + + int transactionsCount = bytes.getInt(); + + byte[] reference = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; + bytes.get(reference); + + BlockSummaryData blockSummary = new BlockSummaryData(height, signature, minterPublicKey, + onlineAccountsCount, timestamp, transactionsCount, reference); + blockSummaries.add(blockSummary); + + height++; + } + + return new BlockSummariesV2Message(id, blockSummaries); + } + +} diff --git a/src/main/java/org/qortal/network/message/GenericUnknownMessage.java b/src/main/java/org/qortal/network/message/GenericUnknownMessage.java new file mode 100644 index 00000000..15faaa1b --- /dev/null +++ b/src/main/java/org/qortal/network/message/GenericUnknownMessage.java @@ -0,0 +1,23 @@ +package org.qortal.network.message; + +import java.nio.ByteBuffer; + +public class GenericUnknownMessage extends Message { + + public static final long MINIMUM_PEER_VERSION = 0x03000400cbL; + + public GenericUnknownMessage() { + super(MessageType.GENERIC_UNKNOWN); + + this.dataBytes = EMPTY_DATA_BYTES; + } + + private GenericUnknownMessage(int id) { + super(id, MessageType.GENERIC_UNKNOWN); + } + + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + return new GenericUnknownMessage(id); + } + +} diff --git a/src/main/java/org/qortal/network/message/MessageType.java b/src/main/java/org/qortal/network/message/MessageType.java index 087e7fbf..4dd4a3c8 100644 --- a/src/main/java/org/qortal/network/message/MessageType.java +++ b/src/main/java/org/qortal/network/message/MessageType.java @@ -21,6 +21,7 @@ public enum MessageType { HEIGHT_V2(10, HeightV2Message::fromByteBuffer), PING(11, PingMessage::fromByteBuffer), PONG(12, PongMessage::fromByteBuffer), + GENERIC_UNKNOWN(13, GenericUnknownMessage::fromByteBuffer), // Requesting data PEERS_V2(20, PeersV2Message::fromByteBuffer), @@ -41,6 +42,7 @@ public enum MessageType { BLOCK_SUMMARIES(70, BlockSummariesMessage::fromByteBuffer), GET_BLOCK_SUMMARIES(71, GetBlockSummariesMessage::fromByteBuffer), + BLOCK_SUMMARIES_V2(72, BlockSummariesV2Message::fromByteBuffer), ONLINE_ACCOUNTS(80, OnlineAccountsMessage::fromByteBuffer), GET_ONLINE_ACCOUNTS(81, GetOnlineAccountsMessage::fromByteBuffer), diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java index cc7e1611..c3c5638a 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java @@ -143,13 +143,17 @@ public class HSQLDBBlockArchiveRepository implements BlockArchiveRepository { byte[] blockMinterPublicKey = resultSet.getBytes(3); // Fetch additional info from the archive itself - int onlineAccountsCount = 0; + Integer onlineAccountsCount = null; + Long timestamp = null; + Integer transactionCount = null; + byte[] reference = null; + BlockData blockData = this.fromSignature(signature); if (blockData != null) { onlineAccountsCount = blockData.getOnlineAccountsCount(); } - BlockSummaryData blockSummary = new BlockSummaryData(height, signature, blockMinterPublicKey, onlineAccountsCount); + BlockSummaryData blockSummary = new BlockSummaryData(height, signature, blockMinterPublicKey, onlineAccountsCount, timestamp, transactionCount, reference); blockSummaries.add(blockSummary); } while (resultSet.next()); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index b8238085..f38d549c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -297,7 +297,7 @@ public class HSQLDBBlockRepository implements BlockRepository { @Override public List getBlockSummariesBySigner(byte[] signerPublicKey, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); - sql.append("SELECT signature, height, Blocks.minter, online_accounts_count FROM "); + sql.append("SELECT signature, height, Blocks.minter, online_accounts_count, minted_when, transaction_count, Blocks.reference 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) "); @@ -322,8 +322,12 @@ public class HSQLDBBlockRepository implements BlockRepository { int height = resultSet.getInt(2); byte[] blockMinterPublicKey = resultSet.getBytes(3); int onlineAccountsCount = resultSet.getInt(4); + long timestamp = resultSet.getLong(5); + int transactionCount = resultSet.getInt(6); + byte[] reference = resultSet.getBytes(7); - BlockSummaryData blockSummary = new BlockSummaryData(height, signature, blockMinterPublicKey, onlineAccountsCount); + BlockSummaryData blockSummary = new BlockSummaryData(height, signature, blockMinterPublicKey, onlineAccountsCount, + timestamp, transactionCount, reference); blockSummaries.add(blockSummary); } while (resultSet.next()); @@ -355,7 +359,7 @@ public class HSQLDBBlockRepository implements BlockRepository { @Override public List getBlockSummaries(int firstBlockHeight, int lastBlockHeight) throws DataException { - String sql = "SELECT signature, height, minter, online_accounts_count, minted_when, transaction_count " + String sql = "SELECT signature, height, minter, online_accounts_count, minted_when, transaction_count, reference " + "FROM Blocks WHERE height BETWEEN ? AND ?"; List blockSummaries = new ArrayList<>(); @@ -371,9 +375,10 @@ public class HSQLDBBlockRepository implements BlockRepository { int onlineAccountsCount = resultSet.getInt(4); long timestamp = resultSet.getLong(5); int transactionCount = resultSet.getInt(6); + byte[] reference = resultSet.getBytes(7); BlockSummaryData blockSummary = new BlockSummaryData(height, signature, minterPublicKey, onlineAccountsCount, - timestamp, transactionCount); + timestamp, transactionCount, reference); blockSummaries.add(blockSummary); } while (resultSet.next()); From e80dd31fb4a9ccfb960218561fcc242234801bb2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 13:53:27 +0100 Subject: [PATCH 17/71] BlockSummariesV2Message.MINIMUM_PEER_VERSION set to 3.6.1 --- .../org/qortal/network/message/BlockSummariesV2Message.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java b/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java index 96c661a4..6ed6c8aa 100644 --- a/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java +++ b/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java @@ -15,7 +15,7 @@ import java.util.List; public class BlockSummariesV2Message extends Message { - public static final long MINIMUM_PEER_VERSION = 0x03000400cbL; + public static final long MINIMUM_PEER_VERSION = 0x0300060001L; private static final int BLOCK_SUMMARY_V2_LENGTH = BlockTransformer.BLOCK_SIGNATURE_LENGTH /* block signature */ + Transformer.PUBLIC_KEY_LENGTH /* minter public key */ From 7a60f713ead6bded0062a026af4cc147cb1a952d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 14:35:02 +0100 Subject: [PATCH 18/71] Fixed error in rebase. --- src/main/java/org/qortal/controller/BlockMinter.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index a07d37fe..100e74db 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -434,9 +434,14 @@ public class BlockMinter extends Thread { blockchainLock.unlock(); } - if (newBlockMinted) { - // Broadcast our new chain to network - Network.getInstance().broadcastOurChain(); + if (newBlockMinted) { + // Broadcast our new chain to network + Network.getInstance().broadcastOurChain(); + } + + } catch (InterruptedException e) { + // We've been interrupted - time to exit + return; } } } catch (DataException e) { From d2ebb215e605709a498170d100d0bc57a4b01ed4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 14:36:49 +0100 Subject: [PATCH 19/71] Fixed Synchronizer.getBlockSummaries() which was expecting BLOCK_SUMMARIES, but updated peers send BLOCK_SUMMARIES_V2 --- .../java/org/qortal/controller/Synchronizer.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index a6fbfe71..a8d91f52 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -1553,12 +1553,19 @@ public class Synchronizer extends Thread { Message getBlockSummariesMessage = new GetBlockSummariesMessage(parentSignature, numberRequested); Message message = peer.getResponse(getBlockSummariesMessage); - if (message == null || message.getType() != MessageType.BLOCK_SUMMARIES) + if (message == null) return null; - BlockSummariesMessage blockSummariesMessage = (BlockSummariesMessage) message; + if (message.getType() == MessageType.BLOCK_SUMMARIES) { + BlockSummariesMessage blockSummariesMessage = (BlockSummariesMessage) message; + return blockSummariesMessage.getBlockSummaries(); + } + else if (message.getType() == MessageType.BLOCK_SUMMARIES_V2) { + BlockSummariesV2Message blockSummariesMessage = (BlockSummariesV2Message) message; + return blockSummariesMessage.getBlockSummaries(); + } - return blockSummariesMessage.getBlockSummaries(); + return null; } private List getBlockSignatures(Peer peer, byte[] parentSignature, int numberRequested) throws InterruptedException { From 309f27a6b83a6745583a887b5150d790a43bdcf3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 15:21:01 +0100 Subject: [PATCH 20/71] Moved error to debug, as we now get a burst of these soon after startup, due to commit 99858f3. This also shows that commit 99858f3 now prevents a block candidate with a very small number of online accounts being built immediately after startup. --- 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 bdae83c2..07c7db6f 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -375,7 +375,7 @@ public class Block { } if (onlineAccounts.isEmpty()) { - LOGGER.error("No online accounts - not even our own?"); + LOGGER.debug("No online accounts - not even our own?"); return null; } From 5c746f0bd90fc5aa8de6a5800608bd21e84164d9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 15:48:45 +0100 Subject: [PATCH 21/71] Fixed bug which required a node to hold local trade presences before it would request any. This caused large gaps with no presence data. They are removed when they expire, causing the local count to drop to zero, and the node would only start requesting them again once a peer had pushed one or more entries proactively. --- src/main/java/org/qortal/controller/tradebot/TradeBot.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index c7ae1db3..85e594fa 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -468,9 +468,6 @@ public class TradeBot implements Listener { List safeTradePresences = List.copyOf(this.safeAllTradePresencesByPubkey.values()); - if (safeTradePresences.isEmpty()) - return; - LOGGER.debug("Broadcasting all {} known trade presences. Next broadcast timestamp: {}", safeTradePresences.size(), nextTradePresenceBroadcastTimestamp ); From 4681218416c86e841dc2afa58436c9166ab46bae Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Sep 2022 15:49:29 +0100 Subject: [PATCH 22/71] Include total count in debug trade presence logging --- src/main/java/org/qortal/controller/tradebot/TradeBot.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 85e594fa..5880f561 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -634,7 +634,7 @@ public class TradeBot implements Listener { } if (newCount > 0) { - LOGGER.debug("New trade presences: {}", newCount); + LOGGER.debug("New trade presences: {}, all trade presences: {}", newCount, allTradePresencesByPubkey.size()); rebuildSafeAllTradePresences(); } } From aa9da45c01657e218a9f249497cc8c25e164d0f5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 25 Sep 2022 11:37:07 +0100 Subject: [PATCH 23/71] Added optional filtering by reference in GET /chat/messages --- src/main/java/org/qortal/api/resource/ChatResource.java | 6 ++++++ .../org/qortal/api/websocket/ChatMessagesWebSocket.java | 2 ++ src/main/java/org/qortal/repository/ChatRepository.java | 2 +- .../org/qortal/repository/hsqldb/HSQLDBChatRepository.java | 7 ++++++- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index 0bbd1951..ee2a8599 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -69,6 +69,7 @@ public class ChatResource { public List searchChat(@QueryParam("before") Long before, @QueryParam("after") Long after, @QueryParam("txGroupId") Integer txGroupId, @QueryParam("involving") List involvingAddresses, + @QueryParam("reference") String reference, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { @@ -87,11 +88,16 @@ public class ChatResource { if (after != null && after < 1500000000000L) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + byte[] referenceBytes = null; + if (reference != null) + referenceBytes = Base58.decode(reference); + try (final Repository repository = RepositoryManager.getRepository()) { return repository.getChatRepository().getMessagesMatchingCriteria( before, after, txGroupId, + referenceBytes, involvingAddresses, limit, offset, reverse); } catch (DataException e) { diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index 3dc2d494..9760b7f0 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -46,6 +46,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, txGroupId, null, + null, null, null, null); sendMessages(session, chatMessages); @@ -72,6 +73,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + null, involvingAddresses, null, null, null); diff --git a/src/main/java/org/qortal/repository/ChatRepository.java b/src/main/java/org/qortal/repository/ChatRepository.java index cd4b9a8f..2ecd8a34 100644 --- a/src/main/java/org/qortal/repository/ChatRepository.java +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -14,7 +14,7 @@ public interface ChatRepository { * Expects EITHER non-null txGroupID OR non-null sender and recipient addresses. */ public List getMessagesMatchingCriteria(Long before, Long after, - Integer txGroupId, List involving, + Integer txGroupId, byte[] reference, List involving, Integer limit, Integer offset, Boolean reverse) throws DataException; public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java index 2972e9f2..2f570686 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -23,7 +23,7 @@ public class HSQLDBChatRepository implements ChatRepository { } @Override - public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, + public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] referenceBytes, List involving, Integer limit, Integer offset, Boolean reverse) throws DataException { // Check args meet expectations @@ -57,6 +57,11 @@ public class HSQLDBChatRepository implements ChatRepository { bindParams.add(after); } + if (referenceBytes != null) { + whereClauses.add("reference = ?"); + bindParams.add(referenceBytes); + } + if (txGroupId != null) { whereClauses.add("tx_group_id = " + txGroupId); // int safe to use literally whereClauses.add("recipient IS NULL"); From 5989473c8a9bd2c09134e46a262878ef8b286ad3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 25 Sep 2022 12:06:14 +0100 Subject: [PATCH 24/71] Revert "Allow duplicate variations of each OnlineAccountData in the import queue, but don't allow two entries that match exactly." This reverts commit 6d9e6e8d4c89582ffad23c1094b6a8e3aee91116. --- .../controller/OnlineAccountsManager.java | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 40192876..47d8cf1b 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -71,7 +71,7 @@ public class OnlineAccountsManager { private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, new NamedThreadFactory("OnlineAccounts")); private volatile boolean isStopping = false; - private final List onlineAccountsImportQueue = Collections.synchronizedList(new ArrayList<>()); + private final Set onlineAccountsImportQueue = ConcurrentHashMap.newKeySet(); /** * Cache of 'current' online accounts, keyed by timestamp @@ -191,12 +191,9 @@ public class OnlineAccountsManager { LOGGER.debug("Processing online accounts import queue (size: {})", this.onlineAccountsImportQueue.size()); - // Take a copy of onlineAccountsImportQueue so we can safely remove whilst iterating - List onlineAccountsImportQueueCopy = new ArrayList<>(this.onlineAccountsImportQueue); - Set onlineAccountsToAdd = new HashSet<>(); try (final Repository repository = RepositoryManager.getRepository()) { - for (OnlineAccountData onlineAccountData : onlineAccountsImportQueueCopy) { + for (OnlineAccountData onlineAccountData : this.onlineAccountsImportQueue) { if (isStopping) return; @@ -220,19 +217,6 @@ public class OnlineAccountsManager { } } - private boolean importQueueContainsExactMatch(OnlineAccountData acc) { - // Check if an item exists where all properties match exactly - // This is needed because signature and nonce are not compared in OnlineAccountData.equals() - synchronized (onlineAccountsImportQueue) { - return onlineAccountsImportQueue.stream().anyMatch(otherAcc -> - acc.getTimestamp() == otherAcc.getTimestamp() && - Arrays.equals(acc.getPublicKey(), otherAcc.getPublicKey()) && - acc.getNonce() == otherAcc.getNonce() && - Arrays.equals(acc.getSignature(), otherAcc.getSignature()) - ); - } - } - /** * Check if supplied onlineAccountData is superior (i.e. has a nonce value) than existing record. * Two entries are considered equal even if the nonce differs, to prevent multiple variations @@ -855,10 +839,6 @@ public class OnlineAccountsManager { // We have already validated this online account continue; - if (this.importQueueContainsExactMatch(onlineAccountData)) - // Identical online account data already present in queue - continue; - boolean isNewEntry = onlineAccountsImportQueue.add(onlineAccountData); if (isNewEntry) From 765416db71a1b3dd986a108dbfd31713d3004016 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 25 Sep 2022 12:26:00 +0100 Subject: [PATCH 25/71] Yet another attempt to optimize the online accounts import queue processing. The main difference here is that we now remove items from the onlineAccountsImportQueue in a batch, _after_ they have been imported. This prevents duplicates from being added to the queue in the previous time gap between them being removed and imported. --- .../qortal/controller/OnlineAccountsManager.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 47d8cf1b..6fa69a89 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -207,13 +207,20 @@ public class OnlineAccountsManager { boolean isValid = this.isValidCurrentAccount(repository, onlineAccountData); if (isValid) - addAccounts(Arrays.asList(onlineAccountData)); + onlineAccountsToAdd.add(onlineAccountData); - // Remove from queue - onlineAccountsImportQueue.remove(onlineAccountData); + // Don't remove from the queue yet - we'll do this at the end of the process + // This prevents duplicates being added to the queue whilst it's being processed } } catch (DataException e) { LOGGER.error("Repository issue while verifying online accounts", e); + + } finally { + if (!onlineAccountsToAdd.isEmpty()) { + LOGGER.debug("Merging {} validated online accounts from import queue", onlineAccountsToAdd.size()); + addAccounts(onlineAccountsToAdd); + onlineAccountsImportQueue.removeAll(onlineAccountsToAdd); + } } } From 1bb8f1b6d2d68032e02eb05aba6b49e6258f09b9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 25 Sep 2022 12:36:00 +0100 Subject: [PATCH 26/71] Fixed bug in last commit. We need to track items to remove separately from items to add, otherwise invalid accounts remain in the queue. --- .../java/org/qortal/controller/OnlineAccountsManager.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 6fa69a89..686ef514 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -192,6 +192,7 @@ public class OnlineAccountsManager { LOGGER.debug("Processing online accounts import queue (size: {})", this.onlineAccountsImportQueue.size()); Set onlineAccountsToAdd = new HashSet<>(); + Set onlineAccountsToRemove = new HashSet<>(); try (final Repository repository = RepositoryManager.getRepository()) { for (OnlineAccountData onlineAccountData : this.onlineAccountsImportQueue) { if (isStopping) @@ -211,6 +212,7 @@ public class OnlineAccountsManager { // Don't remove from the queue yet - we'll do this at the end of the process // This prevents duplicates being added to the queue whilst it's being processed + onlineAccountsToRemove.add(onlineAccountData); } } catch (DataException e) { LOGGER.error("Repository issue while verifying online accounts", e); @@ -219,7 +221,7 @@ public class OnlineAccountsManager { if (!onlineAccountsToAdd.isEmpty()) { LOGGER.debug("Merging {} validated online accounts from import queue", onlineAccountsToAdd.size()); addAccounts(onlineAccountsToAdd); - onlineAccountsImportQueue.removeAll(onlineAccountsToAdd); + onlineAccountsImportQueue.removeAll(onlineAccountsToRemove); } } } From a9721bab3d735ad376d5296f29833002dcd0e742 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 25 Sep 2022 18:39:56 +0100 Subject: [PATCH 27/71] Fixed issue causing startup of various components to be delayed by 30 seconds. --- .../org/qortal/controller/OnlineAccountsManager.java | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 686ef514..2644fa66 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -133,17 +133,10 @@ public class OnlineAccountsManager { // Process import queue executor.scheduleWithFixedDelay(this::processOnlineAccountsImportQueue, ONLINE_ACCOUNTS_QUEUE_INTERVAL, ONLINE_ACCOUNTS_QUEUE_INTERVAL, TimeUnit.MILLISECONDS); - // Sleep for some time before scheduling sendOurOnlineAccountsInfo() + // Send our online accounts (using increased initial delay) // This allows some time for initial online account lists to be retrieved, and // reduces the chances of the same nonce being computed twice - try { - Thread.sleep(INITIAL_SLEEP_INTERVAL); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - - // Send our online accounts - executor.scheduleAtFixedRate(this::sendOurOnlineAccountsInfo, ONLINE_ACCOUNTS_COMPUTE_INTERVAL, ONLINE_ACCOUNTS_COMPUTE_INTERVAL, TimeUnit.MILLISECONDS); + executor.scheduleAtFixedRate(this::sendOurOnlineAccountsInfo, INITIAL_SLEEP_INTERVAL, ONLINE_ACCOUNTS_COMPUTE_INTERVAL, TimeUnit.MILLISECONDS); } public void shutdown() { From 3890fa849072a5c5d28f265d4235ed32936840d1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 25 Sep 2022 18:46:33 +0100 Subject: [PATCH 28/71] Renamed constant for consistency --- .../java/org/qortal/controller/OnlineAccountsManager.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 2644fa66..ff20a8d0 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -53,7 +53,7 @@ public class OnlineAccountsManager { */ private static final int MAX_BLOCKS_CACHED_ONLINE_ACCOUNTS = 3; - private static final long ONLINE_ACCOUNTS_QUEUE_INTERVAL = 100L; //ms + private static final long ONLINE_ACCOUNTS_QUEUE_INTERVAL = 100L; // ms private static final long ONLINE_ACCOUNTS_TASKS_INTERVAL = 10 * 1000L; // ms private static final long ONLINE_ACCOUNTS_COMPUTE_INTERVAL = 5 * 1000L; // ms private static final long ONLINE_ACCOUNTS_BROADCAST_INTERVAL = 60 * 1000L; // ms @@ -62,7 +62,7 @@ public class OnlineAccountsManager { private static final long ONLINE_ACCOUNTS_BROADCAST_BURST_INTERVAL = 5 * 1000L; // ms private static final long ONLINE_ACCOUNTS_BROADCAST_BURST_LENGTH = 5 * 60 * 1000L; // ms - private static final long INITIAL_SLEEP_INTERVAL = 30 * 1000L; + private static final long ONLINE_ACCOUNTS_COMPUTE_INITIAL_SLEEP_INTERVAL = 30 * 1000L; // ms // MemoryPoW public final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes @@ -136,7 +136,7 @@ public class OnlineAccountsManager { // Send our online accounts (using increased initial delay) // This allows some time for initial online account lists to be retrieved, and // reduces the chances of the same nonce being computed twice - executor.scheduleAtFixedRate(this::sendOurOnlineAccountsInfo, INITIAL_SLEEP_INTERVAL, ONLINE_ACCOUNTS_COMPUTE_INTERVAL, TimeUnit.MILLISECONDS); + executor.scheduleAtFixedRate(this::sendOurOnlineAccountsInfo, ONLINE_ACCOUNTS_COMPUTE_INITIAL_SLEEP_INTERVAL, ONLINE_ACCOUNTS_COMPUTE_INTERVAL, TimeUnit.MILLISECONDS); } public void shutdown() { From 7080b55aacd69138fed21ab8e3370e5088a20b59 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 25 Sep 2022 19:43:56 +0100 Subject: [PATCH 29/71] Reintroduced initial sleep period in block archiver. --- .../java/org/qortal/controller/repository/BlockArchiver.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index 8757bf32..63d61ef8 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -16,7 +16,7 @@ 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 + private static final long INITIAL_SLEEP_PERIOD = 5 * 60 * 1000L + 1234L; // ms public void run() { Thread.currentThread().setName("Block archiver"); From c35c7180d4c15438ca1db2129c03df563fdd24cc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 3 Oct 2022 10:58:47 +0100 Subject: [PATCH 30/71] Return empty levels in GET /addresses/online/levels --- src/main/java/org/qortal/api/resource/AddressesResource.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/AddressesResource.java b/src/main/java/org/qortal/api/resource/AddressesResource.java index 4de8d908..468b90a8 100644 --- a/src/main/java/org/qortal/api/resource/AddressesResource.java +++ b/src/main/java/org/qortal/api/resource/AddressesResource.java @@ -205,6 +205,10 @@ public class AddressesResource { try (final Repository repository = RepositoryManager.getRepository()) { List onlineAccountLevels = new ArrayList<>(); + // Prepopulate all levels + for (int i=0; i<=10; i++) + onlineAccountLevels.add(new OnlineAccountLevel(i, 0)); + for (OnlineAccountData onlineAccountData : onlineAccounts) { try { final int minterLevel = Account.getRewardShareEffectiveMintingLevelIncludingLevelZero(repository, onlineAccountData.getPublicKey()); From 1233ba670300c9e6b6b831a034613c5325346120 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 4 Oct 2022 20:08:30 +0100 Subject: [PATCH 31/71] Bump version to 3.6.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e045e0f4..3be7fff3 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.6.0 + 3.6.1 jar true From 10b0f0a0549094a82be158efe88d6a1b18de2fd7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 5 Oct 2022 15:29:29 +0100 Subject: [PATCH 32/71] Catch JSON exceptions in PirateChainWalletController. This could prevent additional wallets from being initialized if connection was lost while syncing an existing one. --- .../PirateChainWalletController.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/PirateChainWalletController.java b/src/main/java/org/qortal/controller/PirateChainWalletController.java index 1eac4b3a..333c2cda 100644 --- a/src/main/java/org/qortal/controller/PirateChainWalletController.java +++ b/src/main/java/org/qortal/controller/PirateChainWalletController.java @@ -4,6 +4,7 @@ import com.rust.litewalletjni.LiteWalletJni; import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.json.JSONException; import org.json.JSONObject; import org.qortal.arbitrary.ArbitraryDataFile; import org.qortal.arbitrary.ArbitraryDataReader; @@ -99,14 +100,19 @@ public class PirateChainWalletController extends Thread { LOGGER.debug("Syncing Pirate Chain wallet..."); String response = LiteWalletJni.execute("sync", ""); LOGGER.debug("sync response: {}", response); - JSONObject json = new JSONObject(response); - if (json.has("result")) { - String result = json.getString("result"); - // We may have to set wallet to ready if this is the first ever successful sync - if (Objects.equals(result, "success")) { - this.currentWallet.setReady(true); + try { + JSONObject json = new JSONObject(response); + if (json.has("result")) { + String result = json.getString("result"); + + // We may have to set wallet to ready if this is the first ever successful sync + if (Objects.equals(result, "success")) { + this.currentWallet.setReady(true); + } } + } catch (JSONException e) { + LOGGER.info("Unable to interpret JSON", e); } // Rate limit sync attempts From fdd95eac563beb860797bdb888a6abb98f0cf0c9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 7 Oct 2022 11:05:24 +0100 Subject: [PATCH 33/71] Limit to 240 blocks in syncToPeerChain(). Should fix OutOfMemoryException often seen when syncing from 1000+ blocks behind the chain tip. --- src/main/java/org/qortal/controller/Synchronizer.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index a8d91f52..0fe9a56b 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -1246,7 +1246,14 @@ public class Synchronizer extends Thread { int numberSignaturesRequired = additionalPeerBlocksAfterCommonBlock - peerBlockSignatures.size(); int retryCount = 0; - while (height < peerHeight) { + + // Keep fetching blocks from peer until we reach their tip, or reach a count of MAXIMUM_COMMON_DELTA blocks. + // We need to limit the total number, otherwise too much can be loaded into memory, causing an + // OutOfMemoryException. This is common when syncing from 1000+ blocks behind the chain tip, after starting + // from a small fork that didn't become part of the main chain. This causes the entire sync process to + // use syncToPeerChain(), resulting in potentially thousands of blocks being held in memory if the limit + // below isn't applied. + while (height < peerHeight && peerBlocks.size() <= MAXIMUM_COMMON_DELTA) { if (Controller.isStopping()) return SynchronizationResult.SHUTTING_DOWN; From 8cedf618f45a5d1ab24633e9b32972e663c3dcdd Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 7 Oct 2022 14:46:09 +0100 Subject: [PATCH 34/71] Skip GET_BLOCK_SUMMARIES requests if it can already be fulfilled entirely from the peer's chain tip block summaries cache. Loading from the cache should speed up sync decisions, particularly when choose which peer to sync from. The greater the number of connected peers, the more significant this optimization will be. It should also reduce wasted network requests and data usage. Adding this check prior to making a network request is a simple way to introduce the new cached summaries from BLOCK_SUMMARIES_V2 without having to rewrite a lot of the complex sync / peer comparison logic. Longer term we may want to rewrite that logic to read from the cache directly, but it doesn't make sense to introduce that level of risk at this point time, especially as the Synchronizer may be rewritten soon to prefer longer chains. Even so, this is still quite a high risk commit so lots of testing will be needed. --- .../org/qortal/controller/Synchronizer.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 0fe9a56b..dc70db2a 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -8,6 +8,7 @@ import java.util.*; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -1556,7 +1557,41 @@ public class Synchronizer extends Thread { return SynchronizationResult.OK; } + private List getBlockSummariesFromCache(Peer peer, byte[] parentSignature, int numberRequested) { + List peerSummaries = peer.getChainTipSummaries(); + if (peerSummaries == null) + return null; + + // Check if the requested parent block exists in peer's summaries cache + int parentIndex = IntStream.range(0, peerSummaries.size()).filter(i -> Arrays.equals(peerSummaries.get(i).getSignature(), parentSignature)).findFirst().orElse(-1); + if (parentIndex < 0) + return null; + + // Peer's summaries contains the requested parent, so return summaries after that + // Make sure we have at least one block after the parent block + int summariesAvailable = peerSummaries.size() - parentIndex - 1; + if (summariesAvailable <= 0) + return null; + + // Don't try and return more summaries than we have, or more than were requested + int summariesToReturn = Math.min(numberRequested, summariesAvailable); + int startIndex = parentIndex + 1; + int endIndex = startIndex + summariesToReturn - 1; + if (endIndex > peerSummaries.size() - 1) + return null; + + LOGGER.trace("Serving {} block summaries from cache", summariesToReturn); + return peerSummaries.subList(startIndex, endIndex); + } + private List getBlockSummaries(Peer peer, byte[] parentSignature, int numberRequested) throws InterruptedException { + // We might be able to shortcut the response if we already have the summaries in the peer's chain tip data + List cachedSummaries = this.getBlockSummariesFromCache(peer, parentSignature, numberRequested); + if (cachedSummaries != null && !cachedSummaries.isEmpty()) + return cachedSummaries; + + LOGGER.trace("Requesting {} block summaries from peer {}", numberRequested, peer); + Message getBlockSummariesMessage = new GetBlockSummariesMessage(parentSignature, numberRequested); Message message = peer.getResponse(getBlockSummariesMessage); From 0088ba8485a73c723a9ea4555e0435d42df20a3f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 7 Oct 2022 14:47:46 +0100 Subject: [PATCH 35/71] Reduce INITIAL_BLOCK_STEP from 8 to 7. This allows the first pass to always be served from the peer's cache of 8 summaries. This allows a maximum of 7 to be returned, because the 8th spot is needed for the parent block's signature. --- src/main/java/org/qortal/controller/Synchronizer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index dc70db2a..ccb3dfdd 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -44,7 +44,7 @@ public class Synchronizer extends Thread { private static final int SYNC_BATCH_SIZE = 1000; // XXX move to Settings? /** Initial jump back of block height when searching for common block with peer */ - private static final int INITIAL_BLOCK_STEP = 8; + private static final int INITIAL_BLOCK_STEP = 7; /** Maximum jump back of block height when searching for common block with peer */ private static final int MAXIMUM_BLOCK_STEP = 128; From 3a18599d8511833872cbd46d3c23e4b3ff81ddf4 Mon Sep 17 00:00:00 2001 From: Nuc1eoN <2538022+Nuc1eoN@users.noreply.github.com> Date: Fri, 7 Oct 2022 23:35:35 +0200 Subject: [PATCH 36/71] Mark start/stop scripts as executables The `start.sh` & `stop.sh` scripts have already been marked as executables in the source folder... But since we have only piped their contents, we need to set correct file permissions again. --- tools/build-zip.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/build-zip.sh b/tools/build-zip.sh index b52b5da7..f423bca1 100755 --- a/tools/build-zip.sh +++ b/tools/build-zip.sh @@ -58,6 +58,9 @@ git show HEAD:log4j2.properties > ${build_dir}/log4j2.properties git show HEAD:start.sh > ${build_dir}/start.sh git show HEAD:stop.sh > ${build_dir}/stop.sh +chmod +x ${build_dir}/start.sh +chmod +x ${build_dir}/stop.sh + printf "{\n}\n" > ${build_dir}/settings.json gtouch -d ${commit_ts%%+??:??} ${build_dir} ${build_dir}/* From 77d60fc33f8171363d58d037044a7bac4ae4152d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Oct 2022 14:11:28 +0100 Subject: [PATCH 37/71] Revert "Skip GET_BLOCK_SUMMARIES requests if it can already be fulfilled entirely from the peer's chain tip block summaries cache." This reverts commit 8cedf618f45a5d1ab24633e9b32972e663c3dcdd. --- .../org/qortal/controller/Synchronizer.java | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index ccb3dfdd..e4419249 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -8,7 +8,6 @@ import java.util.*; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; -import java.util.stream.IntStream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -1557,41 +1556,7 @@ public class Synchronizer extends Thread { return SynchronizationResult.OK; } - private List getBlockSummariesFromCache(Peer peer, byte[] parentSignature, int numberRequested) { - List peerSummaries = peer.getChainTipSummaries(); - if (peerSummaries == null) - return null; - - // Check if the requested parent block exists in peer's summaries cache - int parentIndex = IntStream.range(0, peerSummaries.size()).filter(i -> Arrays.equals(peerSummaries.get(i).getSignature(), parentSignature)).findFirst().orElse(-1); - if (parentIndex < 0) - return null; - - // Peer's summaries contains the requested parent, so return summaries after that - // Make sure we have at least one block after the parent block - int summariesAvailable = peerSummaries.size() - parentIndex - 1; - if (summariesAvailable <= 0) - return null; - - // Don't try and return more summaries than we have, or more than were requested - int summariesToReturn = Math.min(numberRequested, summariesAvailable); - int startIndex = parentIndex + 1; - int endIndex = startIndex + summariesToReturn - 1; - if (endIndex > peerSummaries.size() - 1) - return null; - - LOGGER.trace("Serving {} block summaries from cache", summariesToReturn); - return peerSummaries.subList(startIndex, endIndex); - } - private List getBlockSummaries(Peer peer, byte[] parentSignature, int numberRequested) throws InterruptedException { - // We might be able to shortcut the response if we already have the summaries in the peer's chain tip data - List cachedSummaries = this.getBlockSummariesFromCache(peer, parentSignature, numberRequested); - if (cachedSummaries != null && !cachedSummaries.isEmpty()) - return cachedSummaries; - - LOGGER.trace("Requesting {} block summaries from peer {}", numberRequested, peer); - Message getBlockSummariesMessage = new GetBlockSummariesMessage(parentSignature, numberRequested); Message message = peer.getResponse(getBlockSummariesMessage); From e6bb0b81cff21d2e713185c95b8962d4bb87e50e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Oct 2022 19:11:20 +0100 Subject: [PATCH 38/71] Revert "Reduce INITIAL_BLOCK_STEP from 8 to 7." This reverts commit 0088ba8485a73c723a9ea4555e0435d42df20a3f. --- src/main/java/org/qortal/controller/Synchronizer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index e4419249..0fe9a56b 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -43,7 +43,7 @@ public class Synchronizer extends Thread { private static final int SYNC_BATCH_SIZE = 1000; // XXX move to Settings? /** Initial jump back of block height when searching for common block with peer */ - private static final int INITIAL_BLOCK_STEP = 7; + private static final int INITIAL_BLOCK_STEP = 8; /** Maximum jump back of block height when searching for common block with peer */ private static final int MAXIMUM_BLOCK_STEP = 128; From 2d58118d7cfa717a4a6521b9d2fa2bd325c7e5ea Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Oct 2022 20:11:01 +0100 Subject: [PATCH 39/71] Always use BlockSummariesMessage V1 (instead of V2) when responding to GetBlockSummaries requests. This should hopefully fix a potential issue where peer's chain tip data becomes contaminated with other summary data, causing incorrect sync decisions. --- src/main/java/org/qortal/controller/Controller.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index ce994757..1e028ebc 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1430,9 +1430,7 @@ public class Controller extends Thread { // then we have no blocks after that and can short-circuit with an empty response BlockData chainTip = getChainTip(); if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { - Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION - ? new BlockSummariesV2Message(Collections.emptyList()) - : new BlockSummariesMessage(Collections.emptyList()); + Message blockSummariesMessage = new BlockSummariesMessage(Collections.emptyList()); blockSummariesMessage.setId(message.getId()); @@ -1491,9 +1489,7 @@ public class Controller extends Thread { this.stats.getBlockSummariesStats.fullyFromCache.incrementAndGet(); } - Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION - ? new BlockSummariesV2Message(blockSummaries) - : new BlockSummariesMessage(blockSummaries); + Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); blockSummariesMessage.setId(message.getId()); if (!peer.sendMessage(blockSummariesMessage)) peer.disconnect("failed to send block summaries"); From cb1eee8ff5f1f30e647cec69779cbf08dff91f94 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Oct 2022 20:37:39 +0100 Subject: [PATCH 40/71] GenericUnknownMessage.MINIMUM_PEER_VERSION set to 3.6.1. This should ideally have been set in the 3.6.1 release, but not setting it is unlikely to have caused any problems. --- .../java/org/qortal/network/message/GenericUnknownMessage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/message/GenericUnknownMessage.java b/src/main/java/org/qortal/network/message/GenericUnknownMessage.java index 15faaa1b..dea9f2b8 100644 --- a/src/main/java/org/qortal/network/message/GenericUnknownMessage.java +++ b/src/main/java/org/qortal/network/message/GenericUnknownMessage.java @@ -4,7 +4,7 @@ import java.nio.ByteBuffer; public class GenericUnknownMessage extends Message { - public static final long MINIMUM_PEER_VERSION = 0x03000400cbL; + public static final long MINIMUM_PEER_VERSION = 0x0300060001L; public GenericUnknownMessage() { super(MessageType.GENERIC_UNKNOWN); From 36fcd6792a55352b8d7753dd7d9b8cb16f42d9eb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 10 Oct 2022 10:28:36 +0100 Subject: [PATCH 41/71] Discard BLOCK_SUMMARIES_V2 messages with an ID (thanks to @catbref for the code) This is a better fix for the "contaminated chain tip summaries" issue. Need to reduce the logging level to debug before release. --- src/main/java/org/qortal/controller/Controller.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 1e028ebc..2146c86b 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1599,6 +1599,17 @@ public class Controller extends Thread { } } + if (message.hasId()) { + /* + * Experimental proof-of-concept: discard messages with ID + * These are 'late' reply messages received after timeout has expired, + * having been passed upwards from Peer to Network to Controller. + * Hence, these are NOT simple "here's my chain tip" broadcasts from other peers. + */ + LOGGER.info("Discarding late {} message with ID {} from {}", message.getType().name(), message.getId(), peer); + return; + } + // Update peer chain tip data peer.setChainTipSummaries(blockSummariesV2Message.getBlockSummaries()); From 10d3176e70694808be0476a95d15804e31fcb948 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 10 Oct 2022 10:28:44 +0100 Subject: [PATCH 42/71] Revert "Always use BlockSummariesMessage V1 (instead of V2) when responding to GetBlockSummaries requests." This reverts commit 2d58118d7cfa717a4a6521b9d2fa2bd325c7e5ea. --- src/main/java/org/qortal/controller/Controller.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 2146c86b..93cbae92 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1430,7 +1430,9 @@ public class Controller extends Thread { // then we have no blocks after that and can short-circuit with an empty response BlockData chainTip = getChainTip(); if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { - Message blockSummariesMessage = new BlockSummariesMessage(Collections.emptyList()); + Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION + ? new BlockSummariesV2Message(Collections.emptyList()) + : new BlockSummariesMessage(Collections.emptyList()); blockSummariesMessage.setId(message.getId()); @@ -1489,7 +1491,9 @@ public class Controller extends Thread { this.stats.getBlockSummariesStats.fullyFromCache.incrementAndGet(); } - Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); + Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION + ? new BlockSummariesV2Message(blockSummaries) + : new BlockSummariesMessage(blockSummaries); blockSummariesMessage.setId(message.getId()); if (!peer.sendMessage(blockSummariesMessage)) peer.disconnect("failed to send block summaries"); From d4aaba2293105e63deeb784b87a8dbe566d25724 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 10 Oct 2022 19:06:08 +0100 Subject: [PATCH 43/71] Bump version to 3.6.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 3be7fff3..591801e9 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.6.1 + 3.6.2 jar true From 7c15d88cbc23dd45d8c090d286a628c05974af01 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 12 Oct 2022 08:52:58 +0100 Subject: [PATCH 44/71] Fix for issue in BLOCK_SUMMARIES_V2 when sending an empty array of summaries. The BLOCK_SUMMARIES message type would differentiate between an empty response and a missing/invalid response. However, in V2, a response with empty summaries would throw a BufferUnderflowException and be treated by the caller as a null message. This caused problems when trying to find a common block with peers that have diverged by more than 8 blocks. With V1 the caller would know to search back further (e.g. 16 blocks) but in V2 it was treated as "no response" and so the caller would give up instead of increasing the look-back threshold. This fix will identify BLOCK_SUMMARIES_V2 messages with no content, and return an empty array of block summaries instead of a null message. Should be enough to recover any stuck nodes, as long as they haven't diverged more than 240 blocks from the main chain. --- .../qortal/network/message/BlockSummariesV2Message.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java b/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java index 6ed6c8aa..62428cc0 100644 --- a/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java +++ b/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java @@ -68,13 +68,18 @@ public class BlockSummariesV2Message extends Message { } public static Message fromByteBuffer(int id, ByteBuffer bytes) { + List blockSummaries = new ArrayList<>(); + + // If there are no bytes remaining then we can treat this as an empty array of summaries + if (bytes.remaining() == 0) + return new BlockSummariesV2Message(id, blockSummaries); + int height = bytes.getInt(); // Expecting bytes remaining to be exact multiples of BLOCK_SUMMARY_V2_LENGTH if (bytes.remaining() % BLOCK_SUMMARY_V2_LENGTH != 0) throw new BufferUnderflowException(); - List blockSummaries = new ArrayList<>(); while (bytes.hasRemaining()) { byte[] signature = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; bytes.get(signature); From 7c7f071eba29240e1b8045df978d1b6fc3f11f60 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 12 Oct 2022 08:54:27 +0100 Subject: [PATCH 45/71] Bump version to 3.6.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 591801e9..5f439cad 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.6.2 + 3.6.3 jar true From 5c223179edf11bdcc62bf269268f79093b0ea870 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 13 Oct 2022 23:37:21 +0100 Subject: [PATCH 46/71] Updated AdvancedInstaller project for v3.6.3 --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index c90dda3d..1f579a9c 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - + From b4125d2bf15a87195cf8cfc2a9501a76a4c71335 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Oct 2022 11:34:46 +0100 Subject: [PATCH 47/71] Fix for NPE in verifyMemoryPoW() --- .../java/org/qortal/controller/OnlineAccountsManager.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index ff20a8d0..7b60f0d9 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -649,6 +649,11 @@ public class OnlineAccountsManager { return true; } + // Require a valid nonce value + if (onlineAccountData.getNonce() == null || onlineAccountData.getNonce() < 0) { + return false; + } + int nonce = onlineAccountData.getNonce(); byte[] mempowBytes; From 38443583804ef992ffa17a515dd214169a3e19f1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Oct 2022 16:38:05 +0100 Subject: [PATCH 48/71] Mark a peer as misbehaved if it fails to respond with a usable block 3 times in a row. This should help to workaround deserialization and missing response issues. --- .../org/qortal/controller/Synchronizer.java | 17 ++++++++++++++++- .../java/org/qortal/data/network/PeerData.java | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 0fe9a56b..a7dd38ff 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -53,6 +53,9 @@ public class Synchronizer extends Thread { /** Maximum number of block signatures we ask from peer in one go */ private static final int MAXIMUM_REQUEST_SIZE = 200; // XXX move to Settings? + /** Maximum number of consecutive failed sync attempts before marking peer as misbehaved */ + private static final int MAX_CONSECUTIVE_FAILED_SYNC_ATTEMPTS = 3; + private static final long RECOVERY_MODE_TIMEOUT = 10 * 60 * 1000L; // ms @@ -1591,8 +1594,20 @@ public class Synchronizer extends Thread { Message getBlockMessage = new GetBlockMessage(signature); Message message = peer.getResponse(getBlockMessage); - if (message == null) + if (message == null) { + peer.getPeerData().incrementFailedSyncCount(); + if (peer.getPeerData().getFailedSyncCount() >= MAX_CONSECUTIVE_FAILED_SYNC_ATTEMPTS) { + // Several failed attempts, so mark peer as misbehaved + LOGGER.info("Marking peer {} as misbehaved due to {} failed sync attempts", peer, peer.getPeerData().getFailedSyncCount()); + Network.getInstance().peerMisbehaved(peer); + } return null; + } + + // Reset failed sync count now that we have a block response + // FUTURE: we could move this to the end of the sync process, but to reduce risk this can be done + // at a later stage. For now we are only defending against serialization errors or no responses. + peer.getPeerData().setFailedSyncCount(0); switch (message.getType()) { case BLOCK: { diff --git a/src/main/java/org/qortal/data/network/PeerData.java b/src/main/java/org/qortal/data/network/PeerData.java index 09982c00..471685dd 100644 --- a/src/main/java/org/qortal/data/network/PeerData.java +++ b/src/main/java/org/qortal/data/network/PeerData.java @@ -28,6 +28,9 @@ public class PeerData { private Long addedWhen; private String addedBy; + /** The number of consecutive times we failed to sync with this peer */ + private int failedSyncCount = 0; + // Constructors // necessary for JAXB serialization @@ -92,6 +95,18 @@ public class PeerData { return this.addedBy; } + public int getFailedSyncCount() { + return this.failedSyncCount; + } + + public void setFailedSyncCount(int failedSyncCount) { + this.failedSyncCount = failedSyncCount; + } + + public void incrementFailedSyncCount() { + this.failedSyncCount++; + } + // Pretty peerAddress getter for JAXB @XmlElement(name = "address") protected String getPrettyAddress() { From 0d9aafaf4e2f26077bfa2c751a1452db62439dbf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Oct 2022 17:03:10 +0100 Subject: [PATCH 49/71] Reduced log spam --- 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 93cbae92..12ad11a1 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1610,7 +1610,7 @@ public class Controller extends Thread { * having been passed upwards from Peer to Network to Controller. * Hence, these are NOT simple "here's my chain tip" broadcasts from other peers. */ - LOGGER.info("Discarding late {} message with ID {} from {}", message.getType().name(), message.getId(), peer); + LOGGER.debug("Discarding late {} message with ID {} from {}", message.getType().name(), message.getId(), peer); return; } From c2d02aead973b28ba61052c6acd056943f5c8f78 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Oct 2022 18:44:25 +0100 Subject: [PATCH 50/71] Default minPeerVersion set to 3.6.3 --- 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 5b8d609e..40b2a247 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -204,7 +204,7 @@ public class Settings { private int maxRetries = 2; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.3.7"; + private String minPeerVersion = "3.6.3"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From 3c565638c11eecdf35f662df55fbb5051c7f836c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Oct 2022 18:58:13 +0100 Subject: [PATCH 51/71] onlineAccountsMemoryPoWTimestamp set to Sat Oct 22 2022 16:00:00 UTC --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index fad81ab5..e9f1500d 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -24,7 +24,7 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, - "onlineAccountsMemoryPoWTimestamp": 9999999999999, + "onlineAccountsMemoryPoWTimestamp": 1666454400000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From 57125a91cf756a5e9971d9c54d55cca82f774497 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Oct 2022 18:59:42 +0100 Subject: [PATCH 52/71] Bump version to 3.6.4 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5f439cad..eb306420 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.6.3 + 3.6.4 jar true From 6f27d3798c6cc9f1071d981f969517bdd0cae356 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 19:18:41 +0100 Subject: [PATCH 53/71] Improved online accounts processing, to avoid creating keys in the map before validation. --- .../java/org/qortal/controller/OnlineAccountsManager.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 7b60f0d9..fe6e3078 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -192,8 +192,8 @@ public class OnlineAccountsManager { return; // Skip this account if it's already validated - Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountData.getTimestamp(), k -> ConcurrentHashMap.newKeySet()); - if (onlineAccounts.contains(onlineAccountData)) { + Set onlineAccounts = this.currentOnlineAccounts.get(onlineAccountData.getTimestamp()); + if (onlineAccounts != null && onlineAccounts.contains(onlineAccountData)) { // We have already validated this online account onlineAccountsImportQueue.remove(onlineAccountData); continue; From 72985b1fc6a11f2a15d36861af5eaa85cf78a9f6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 19:24:54 +0100 Subject: [PATCH 54/71] Reduce log spam, especially around the time of node startup before online accounts have been retrieved. We expect a "Couldn't build a to-be-minted block" log on every startup due to trying to mint before having any accounts. This one has moved from error to info level because error logs can be quite intrusive when using an IDE. --- src/main/java/org/qortal/controller/BlockMinter.java | 2 +- src/main/java/org/qortal/controller/OnlineAccountsManager.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 100e74db..0734d4e9 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -244,7 +244,7 @@ public class BlockMinter extends Thread { Block newBlock = Block.mint(repository, previousBlockData, mintingAccount); if (newBlock == null) { // For some reason we can't mint right now - moderatedLog(() -> LOGGER.error("Couldn't build a to-be-minted block")); + moderatedLog(() -> LOGGER.info("Couldn't build a to-be-minted block")); continue; } diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index fe6e3078..c4eadd44 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -702,7 +702,7 @@ public class OnlineAccountsManager { */ // Block::mint() - only wants online accounts with (online) timestamp that matches block's (online) timestamp so they can be added to new block public List getOnlineAccounts(long onlineTimestamp) { - LOGGER.info(String.format("caller's timestamp: %d, our timestamps: %s", onlineTimestamp, String.join(", ", this.currentOnlineAccounts.keySet().stream().map(l -> Long.toString(l)).collect(Collectors.joining(", "))))); + LOGGER.debug(String.format("caller's timestamp: %d, our timestamps: %s", onlineTimestamp, String.join(", ", this.currentOnlineAccounts.keySet().stream().map(l -> Long.toString(l)).collect(Collectors.joining(", "))))); return new ArrayList<>(Set.copyOf(this.currentOnlineAccounts.getOrDefault(onlineTimestamp, Collections.emptySet()))); } From e45ad37eb57c26445478937444833f2bd22b0b60 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 19:30:08 +0100 Subject: [PATCH 55/71] Fixed bug which could prevent invalid accounts being removed from the queue until the next valid one is added. --- src/main/java/org/qortal/controller/OnlineAccountsManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index c4eadd44..487a5253 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -214,8 +214,8 @@ public class OnlineAccountsManager { if (!onlineAccountsToAdd.isEmpty()) { LOGGER.debug("Merging {} validated online accounts from import queue", onlineAccountsToAdd.size()); addAccounts(onlineAccountsToAdd); - onlineAccountsImportQueue.removeAll(onlineAccountsToRemove); } + onlineAccountsImportQueue.removeAll(onlineAccountsToRemove); } } From b37aa749c6bc518e33d7a2917244c5ef2d4b3f99 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 19:34:24 +0100 Subject: [PATCH 56/71] Removed onlineAccountsMemPoWEnabled setting as it's no longer needed. --- .../java/org/qortal/controller/OnlineAccountsManager.java | 4 ++-- src/main/java/org/qortal/settings/Settings.java | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 487a5253..45b47f5d 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -600,7 +600,7 @@ public class OnlineAccountsManager { // MemoryPoW private boolean isMemoryPoWActive(Long timestamp) { - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp() || Settings.getInstance().isOnlineAccountsMemPoWEnabled()) { + if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { return true; } return false; @@ -617,7 +617,7 @@ public class OnlineAccountsManager { private Integer computeMemoryPoW(byte[] bytes, byte[] publicKey, long onlineAccountsTimestamp) throws TimeoutException { if (!isMemoryPoWActive(NTP.getTime())) { - LOGGER.info("Mempow start timestamp not yet reached, and onlineAccountsMemPoWEnabled not enabled in settings"); + LOGGER.info("Mempow start timestamp not yet reached"); return null; } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 40b2a247..2e57142e 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -290,10 +290,6 @@ public class Settings { /** Additional offset added to values returned by NTP.getTime() */ private Long testNtpOffset = null; - // Online accounts - - /** Whether to opt-in to mempow computations for online accounts, ahead of general release */ - private boolean onlineAccountsMemPoWEnabled = false; /* Foreign chains */ @@ -800,10 +796,6 @@ public class Settings { return this.testNtpOffset; } - public boolean isOnlineAccountsMemPoWEnabled() { - return this.onlineAccountsMemPoWEnabled; - } - public long getRepositoryBackupInterval() { return this.repositoryBackupInterval; } From 1d5497e484849cf63f7662d5cc129ac4b23f4469 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Oct 2022 14:13:38 +0100 Subject: [PATCH 57/71] Modifications to support a single node testnet: - Added "singleNodeTestnet" setting, allowing for fast and consecutive block minting, and no requirement for a minimum number of peers. - Added "recoveryModeTimeout" setting (previously hardcoded in Synchronizer). - Updated testnets documentation to include new settings and a quick start guide. - Added "generic" minting account that can be used in testnets (not functional on mainnet), to simplify the process for new devs. --- TestNets.md | 44 +++++++++++++++---- .../org/qortal/controller/BlockMinter.java | 10 +++-- .../org/qortal/controller/Controller.java | 4 ++ .../org/qortal/controller/Synchronizer.java | 7 ++- .../java/org/qortal/settings/Settings.java | 18 +++++++- 5 files changed, 67 insertions(+), 16 deletions(-) diff --git a/TestNets.md b/TestNets.md index e475e593..b4b9feed 100644 --- a/TestNets.md +++ b/TestNets.md @@ -52,14 +52,13 @@ ## Single-node testnet -A single-node testnet is possible with code modifications, for basic testing, or to more easily start a new testnet. -To do so, follow these steps: -- Comment out the `if (mintedLastBlock) { }` conditional in BlockMinter.java -- Comment out the `minBlockchainPeers` validation in Settings.validate() -- Set `minBlockchainPeers` to 0 in settings.json -- Set `Synchronizer.RECOVERY_MODE_TIMEOUT` to `0` -- All other steps should remain the same. Only a single reward share key is needed. -- Remember to put these values back after introducing other nodes +A single-node testnet is possible with an additional settings, or to more easily start a new testnet. +Just add this setting: +``` +"singleNodeTestnet": true +``` +This will automatically allow multiple consecutive blocks to be minted, as well as setting minBlockchainPeers to 0. +Remember to put these values back after introducing other nodes ## Fixed network @@ -93,3 +92,32 @@ Your options are: - `qort` tool, but prepend with one-time shell variable: `BASE_URL=some-node-hostname-or-ip:port qort ......` - `peer-heights`, but use `-t` option, or `BASE_URL` shell variable as above +## Example settings-test.json +``` +{ + "isTestNet": true, + "bitcoinNet": "TEST3", + "repositoryPath": "db-testnet", + "blockchainConfig": "testchain.json", + "minBlockchainPeers": 1, + "apiDocumentationEnabled": true, + "apiRestricted": false, + "bootstrap": false, + "maxPeerConnectionTime": 999999999, + "localAuthBypassEnabled": true, + "singleNodeTestnet": true, + "recoveryModeTimeout": 0 +} +``` + +## Quick start +Here are some steps to quickly get a single node testnet up and running with a generic minting account: +1. Start with template `settings-test.json`, and create a `testchain.json` based on mainnet's blockchain.json (or obtain one from Qortal developers). These should be in the same directory as the jar. +2. Make sure feature triggers and other timestamp/height activations are correctly set. Generally these would be `0` so that they are enabled from the start. +3. Set a recent genesis `timestamp` in testchain.json, and add this reward share entry: +`{ "type": "REWARD_SHARE", "minterPublicKey": "DwcUnhxjamqppgfXCLgbYRx8H9XFPUc2qYRy3CEvQWEw", "recipient": "QbTDMss7NtRxxQaSqBZtSLSNdSYgvGaqFf", "rewardSharePublicKey": "CRvQXxFfUMfr4q3o1PcUZPA4aPCiubBsXkk47GzRo754", "sharePercent": 0 },` +4. Start the node, passing in settings-test.json, e.g: `java -jar qortal.jar settings-test.json` +5. Once started, add the corresponding minting key to the node: +`curl -X POST "http://localhost:62391/admin/mintingaccounts" -d "F48mYJycFgRdqtc58kiovwbcJgVukjzRE4qRRtRsK9ix"` +6. Alternatively you can use your own minting account instead of the generic one above. +7. After a short while, blocks should be minted from the genesis timestamp until the current time. \ No newline at end of file diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 0734d4e9..7e3b4b9e 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -93,6 +93,8 @@ public class BlockMinter extends Thread { List newBlocks = new ArrayList<>(); + final boolean isSingleNodeTestnet = Settings.getInstance().isSingleNodeTestnet(); + try (final Repository repository = RepositoryManager.getRepository()) { // Going to need this a lot... BlockRepository blockRepository = repository.getBlockRepository(); @@ -111,8 +113,9 @@ public class BlockMinter extends Thread { // Free up any repository locks repository.discardChanges(); - // Sleep for a while - Thread.sleep(1000); + // Sleep for a while. + // It's faster on single node testnets, to allow lots of blocks to be minted quickly. + Thread.sleep(isSingleNodeTestnet ? 50 : 1000); isMintingPossible = false; @@ -223,9 +226,10 @@ public class BlockMinter extends Thread { List newBlocksMintingAccounts = mintingAccountsData.stream().map(accountData -> new PrivateKeyAccount(repository, accountData.getPrivateKey())).collect(Collectors.toList()); // We might need to sit the next block out, if one of our minting accounts signed the previous one + // Skip this check for single node testnets, since they definitely need to mint every block byte[] previousBlockMinter = previousBlockData.getMinterPublicKey(); boolean mintedLastBlock = mintingAccountsData.stream().anyMatch(mintingAccount -> Arrays.equals(mintingAccount.getPublicKey(), previousBlockMinter)); - if (mintedLastBlock) { + if (mintedLastBlock && !isSingleNodeTestnet) { LOGGER.trace(String.format("One of our keys signed the last block, so we won't sign the next one")); continue; } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 12ad11a1..6fe6a159 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1872,6 +1872,10 @@ public class Controller extends Thread { if (latestBlockData == null || latestBlockData.getTimestamp() < minLatestBlockTimestamp) return false; + if (Settings.getInstance().isSingleNodeTestnet()) + // Single node testnets won't have peers, so we can assume up to date from this point + return true; + // Needs a mutable copy of the unmodifiableList List peers = new ArrayList<>(Network.getInstance().getImmutableHandshakedPeers()); if (peers == null) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index a7dd38ff..6f2a0fe1 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -56,8 +56,6 @@ public class Synchronizer extends Thread { /** Maximum number of consecutive failed sync attempts before marking peer as misbehaved */ private static final int MAX_CONSECUTIVE_FAILED_SYNC_ATTEMPTS = 3; - private static final long RECOVERY_MODE_TIMEOUT = 10 * 60 * 1000L; // ms - private boolean running; @@ -399,9 +397,10 @@ public class Synchronizer extends Thread { timePeersLastAvailable = NTP.getTime(); // If enough time has passed, enter recovery mode, which lifts some restrictions on who we can sync with and when we can mint - if (NTP.getTime() - timePeersLastAvailable > RECOVERY_MODE_TIMEOUT) { + long recoveryModeTimeout = Settings.getInstance().getRecoveryModeTimeout(); + if (NTP.getTime() - timePeersLastAvailable > recoveryModeTimeout) { if (recoveryMode == false) { - LOGGER.info(String.format("Peers have been unavailable for %d minutes. Entering recovery mode...", RECOVERY_MODE_TIMEOUT/60/1000)); + LOGGER.info(String.format("Peers have been unavailable for %d minutes. Entering recovery mode...", recoveryModeTimeout/60/1000)); recoveryMode = true; } } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 2e57142e..acfd0e78 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -184,6 +184,8 @@ public class Settings { // Peer-to-peer related private boolean isTestNet = false; + /** Single node testnet mode */ + private boolean singleNodeTestnet = false; /** Port number for inbound peer-to-peer connections. */ private Integer listenPort; /** Whether to attempt to open the listen port via UPnP */ @@ -203,6 +205,9 @@ public class Settings { /** Maximum number of retry attempts if a peer fails to respond with the requested data */ private int maxRetries = 2; + /** The number of seconds of no activity before recovery mode begins */ + public long recoveryModeTimeout = 10 * 60 * 1000L; + /** Minimum peer version number required in order to sync with them */ private String minPeerVersion = "3.6.3"; /** Whether to allow connections with peers below minPeerVersion @@ -486,7 +491,7 @@ public class Settings { private void validate() { // Validation goes here - if (this.minBlockchainPeers < 1) + if (this.minBlockchainPeers < 1 && !singleNodeTestnet) throwValidationError("minBlockchainPeers must be at least 1"); if (this.apiKey != null && this.apiKey.trim().length() < 8) @@ -643,6 +648,10 @@ public class Settings { return this.isTestNet; } + public boolean isSingleNodeTestnet() { + return this.singleNodeTestnet; + } + public int getListenPort() { if (this.listenPort != null) return this.listenPort; @@ -663,6 +672,9 @@ public class Settings { } public int getMinBlockchainPeers() { + if (singleNodeTestnet) + return 0; + return this.minBlockchainPeers; } @@ -688,6 +700,10 @@ public class Settings { public int getMaxRetries() { return this.maxRetries; } + public long getRecoveryModeTimeout() { + return recoveryModeTimeout; + } + public String getMinPeerVersion() { return this.minPeerVersion; } public boolean getAllowConnectionsWithOlderPeerVersions() { return this.allowConnectionsWithOlderPeerVersions; } From b3273ff01a8652e536507781eee64eff100c6b8a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Oct 2022 16:47:42 +0100 Subject: [PATCH 58/71] Removed all mempow feature trigger conditionals. We no longer need all the code complexity, now that 24 hours have passed since activation. We don't validate online accounts beyond 12 hours, and the data is trimmed after 24 hours. --- src/main/java/org/qortal/block/Block.java | 102 ++++++++---------- .../java/org/qortal/block/BlockChain.java | 8 -- .../controller/OnlineAccountsManager.java | 62 +++-------- .../transform/block/BlockTransformer.java | 12 +-- src/main/resources/blockchain.json | 1 - .../org/qortal/test/common/AccountUtils.java | 4 +- 6 files changed, 62 insertions(+), 127 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 07c7db6f..55c13b36 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -366,14 +366,9 @@ public class Block { long timestamp = calcTimestamp(parentBlockData, minter.getPublicKey(), minterLevel); long onlineAccountsTimestamp = OnlineAccountsManager.getCurrentOnlineAccountTimestamp(); - // Fetch our list of online accounts + // Fetch our list of online accounts, removing any that are missing a nonce List onlineAccounts = OnlineAccountsManager.getInstance().getOnlineAccounts(onlineAccountsTimestamp); - - // If mempow is active, remove any legacy accounts that are missing a nonce - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); - } - + onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); if (onlineAccounts.isEmpty()) { LOGGER.debug("No online accounts - not even our own?"); return null; @@ -412,29 +407,27 @@ public class Block { // Aggregated, single signature byte[] onlineAccountsSignatures = Qortal25519Extras.aggregateSignatures(signaturesToAggregate); - // Add nonces to the end of the online accounts signatures if mempow is active - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - try { - // Create ordered list of nonce values - List nonces = new ArrayList<>(); - for (int i = 0; i < onlineAccountsCount; ++i) { - Integer accountIndex = accountIndexes.get(i); - OnlineAccountData onlineAccountData = indexedOnlineAccounts.get(accountIndex); - nonces.add(onlineAccountData.getNonce()); - } - - // Encode the nonces to a byte array - byte[] encodedNonces = BlockTransformer.encodeOnlineAccountNonces(nonces); - - // Append the encoded nonces to the encoded online account signatures - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - outputStream.write(onlineAccountsSignatures); - outputStream.write(encodedNonces); - onlineAccountsSignatures = outputStream.toByteArray(); - } - catch (TransformationException | IOException e) { - return null; + // Add nonces to the end of the online accounts signatures + try { + // Create ordered list of nonce values + List nonces = new ArrayList<>(); + for (int i = 0; i < onlineAccountsCount; ++i) { + Integer accountIndex = accountIndexes.get(i); + OnlineAccountData onlineAccountData = indexedOnlineAccounts.get(accountIndex); + nonces.add(onlineAccountData.getNonce()); } + + // Encode the nonces to a byte array + byte[] encodedNonces = BlockTransformer.encodeOnlineAccountNonces(nonces); + + // Append the encoded nonces to the encoded online account signatures + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + outputStream.write(onlineAccountsSignatures); + outputStream.write(encodedNonces); + onlineAccountsSignatures = outputStream.toByteArray(); + } + catch (TransformationException | IOException e) { + return null; } byte[] minterSignature = minter.sign(BlockTransformer.getBytesForMinterSignature(parentBlockData, @@ -1047,14 +1040,9 @@ public class Block { final int signaturesLength = Transformer.SIGNATURE_LENGTH; final int noncesLength = onlineRewardShares.size() * Transformer.INT_LENGTH; - if (this.blockData.getTimestamp() >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - // We expect nonces to be appended to the online accounts signatures - if (this.blockData.getOnlineAccountsSignatures().length != signaturesLength + noncesLength) - return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED; - } else { - if (this.blockData.getOnlineAccountsSignatures().length != signaturesLength) - return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED; - } + // We expect nonces to be appended to the online accounts signatures + if (this.blockData.getOnlineAccountsSignatures().length != signaturesLength + noncesLength) + return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED; // Check signatures long onlineTimestamp = this.blockData.getOnlineAccountsTimestamp(); @@ -1063,32 +1051,30 @@ public class Block { byte[] encodedOnlineAccountSignatures = this.blockData.getOnlineAccountsSignatures(); // Split online account signatures into signature(s) + nonces, then validate the nonces - if (this.blockData.getTimestamp() >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - byte[] extractedSignatures = BlockTransformer.extract(encodedOnlineAccountSignatures, 0, signaturesLength); - byte[] extractedNonces = BlockTransformer.extract(encodedOnlineAccountSignatures, signaturesLength, onlineRewardShares.size() * Transformer.INT_LENGTH); - encodedOnlineAccountSignatures = extractedSignatures; + byte[] extractedSignatures = BlockTransformer.extract(encodedOnlineAccountSignatures, 0, signaturesLength); + byte[] extractedNonces = BlockTransformer.extract(encodedOnlineAccountSignatures, signaturesLength, onlineRewardShares.size() * Transformer.INT_LENGTH); + encodedOnlineAccountSignatures = extractedSignatures; - List nonces = BlockTransformer.decodeOnlineAccountNonces(extractedNonces); + List nonces = BlockTransformer.decodeOnlineAccountNonces(extractedNonces); - // Build block's view of online accounts (without signatures, as we don't need them here) - Set onlineAccounts = new HashSet<>(); - for (int i = 0; i < onlineRewardShares.size(); ++i) { - Integer nonce = nonces.get(i); - byte[] publicKey = onlineRewardShares.get(i).getRewardSharePublicKey(); + // Build block's view of online accounts (without signatures, as we don't need them here) + Set onlineAccounts = new HashSet<>(); + for (int i = 0; i < onlineRewardShares.size(); ++i) { + Integer nonce = nonces.get(i); + byte[] publicKey = onlineRewardShares.get(i).getRewardSharePublicKey(); - OnlineAccountData onlineAccountData = new OnlineAccountData(onlineTimestamp, null, publicKey, nonce); - onlineAccounts.add(onlineAccountData); - } - - // Remove those already validated & cached by online accounts manager - no need to re-validate them - OnlineAccountsManager.getInstance().removeKnown(onlineAccounts, onlineTimestamp); - - // Validate the rest - for (OnlineAccountData onlineAccount : onlineAccounts) - if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, this.blockData.getTimestamp())) - return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; + OnlineAccountData onlineAccountData = new OnlineAccountData(onlineTimestamp, null, publicKey, nonce); + onlineAccounts.add(onlineAccountData); } + // Remove those already validated & cached by online accounts manager - no need to re-validate them + OnlineAccountsManager.getInstance().removeKnown(onlineAccounts, onlineTimestamp); + + // Validate the rest + for (OnlineAccountData onlineAccount : onlineAccounts) + if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, this.blockData.getTimestamp())) + return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; + // Extract online accounts' timestamp signatures from block data. Only one signature if aggregated. List onlineAccountsSignatures = BlockTransformer.decodeTimestampSignatures(encodedOnlineAccountSignatures); diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 42692a18..826fdd78 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -195,10 +195,6 @@ public class BlockChain { * featureTriggers because unit tests need to set this value via Reflection. */ private long onlineAccountsModulusV2Timestamp; - /** Feature trigger timestamp for online accounts mempow verification. Can't use featureTriggers - * because unit tests need to set this value via Reflection. */ - private long onlineAccountsMemoryPoWTimestamp; - /** Max reward shares by block height */ public static class MaxRewardSharesByTimestamp { public long timestamp; @@ -359,10 +355,6 @@ public class BlockChain { return this.onlineAccountsModulusV2Timestamp; } - public long getOnlineAccountsMemoryPoWTimestamp() { - return this.onlineAccountsMemoryPoWTimestamp; - } - /** Returns true if approval-needing transaction types require a txGroupId other than NO_GROUP. */ public boolean getRequireGroupForApproval() { return this.requireGroupForApproval; diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 45b47f5d..0e24bdfc 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -20,7 +20,6 @@ import org.qortal.network.message.*; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; -import org.qortal.settings.Settings; import org.qortal.utils.Base58; import org.qortal.utils.NTP; import org.qortal.utils.NamedThreadFactory; @@ -156,7 +155,6 @@ public class OnlineAccountsManager { return; byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); - final boolean mempowActive = onlineAccountsTimestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); Set replacementAccounts = new HashSet<>(); for (PrivateKeyAccount onlineAccount : onlineAccounts) { @@ -165,7 +163,7 @@ public class OnlineAccountsManager { byte[] signature = Qortal25519Extras.signForAggregation(onlineAccount.getPrivateKey(), timestampBytes); byte[] publicKey = onlineAccount.getPublicKey(); - Integer nonce = mempowActive ? new Random().nextInt(500000) : null; + Integer nonce = new Random().nextInt(500000); OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); replacementAccounts.add(ourOnlineAccountData); @@ -321,13 +319,10 @@ public class OnlineAccountsManager { return false; } - // Validate mempow if feature trigger is active (or if online account's timestamp is past the trigger timestamp) - long memoryPoWStartTimestamp = BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); - if (now >= memoryPoWStartTimestamp || onlineAccountTimestamp >= memoryPoWStartTimestamp) { - if (!getInstance().verifyMemoryPoW(onlineAccountData, now)) { - LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); - return false; - } + // Validate mempow + if (!getInstance().verifyMemoryPoW(onlineAccountData, now)) { + LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); + return false; } return true; @@ -471,12 +466,10 @@ public class OnlineAccountsManager { // 'next' timestamp (prioritize this as it's the most important, if mempow active) final long nextOnlineAccountsTimestamp = toOnlineAccountTimestamp(now) + getOnlineTimestampModulus(); - if (isMemoryPoWActive(now)) { - boolean success = computeOurAccountsForTimestamp(nextOnlineAccountsTimestamp); - if (!success) { - // We didn't compute the required nonce value(s), and so can't proceed until they have been retried - return; - } + boolean success = computeOurAccountsForTimestamp(nextOnlineAccountsTimestamp); + if (!success) { + // We didn't compute the required nonce value(s), and so can't proceed until they have been retried + return; } // 'current' timestamp @@ -553,21 +546,15 @@ public class OnlineAccountsManager { // Compute nonce Integer nonce; - if (isMemoryPoWActive(NTP.getTime())) { - try { - nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp); - if (nonce == null) { - // A nonce is required - return false; - } - } catch (TimeoutException e) { - LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey))); + try { + nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp); + if (nonce == null) { + // A nonce is required return false; } - } - else { - // Send -1 if we haven't computed a nonce due to feature trigger timestamp - nonce = -1; + } catch (TimeoutException e) { + LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey))); + return false; } byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes); @@ -599,12 +586,6 @@ public class OnlineAccountsManager { // MemoryPoW - private boolean isMemoryPoWActive(Long timestamp) { - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - return true; - } - return false; - } private byte[] getMemoryPoWBytes(byte[] publicKey, long onlineAccountsTimestamp) throws IOException { byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); @@ -616,11 +597,6 @@ public class OnlineAccountsManager { } private Integer computeMemoryPoW(byte[] bytes, byte[] publicKey, long onlineAccountsTimestamp) throws TimeoutException { - if (!isMemoryPoWActive(NTP.getTime())) { - LOGGER.info("Mempow start timestamp not yet reached"); - return null; - } - LOGGER.info(String.format("Computing nonce for account %.8s and timestamp %d...", Base58.encode(publicKey), onlineAccountsTimestamp)); // Calculate the time until the next online timestamp and use it as a timeout when computing the nonce @@ -643,12 +619,6 @@ public class OnlineAccountsManager { } public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData, Long timestamp) { - long memoryPoWStartTimestamp = BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); - if (timestamp < memoryPoWStartTimestamp && onlineAccountData.getTimestamp() < memoryPoWStartTimestamp) { - // Not active yet, so treat it as valid - return true; - } - // Require a valid nonce value if (onlineAccountData.getNonce() == null || onlineAccountData.getNonce() < 0) { return false; diff --git a/src/main/java/org/qortal/transform/block/BlockTransformer.java b/src/main/java/org/qortal/transform/block/BlockTransformer.java index 9e02a6f5..c97aa090 100644 --- a/src/main/java/org/qortal/transform/block/BlockTransformer.java +++ b/src/main/java/org/qortal/transform/block/BlockTransformer.java @@ -235,7 +235,7 @@ public class BlockTransformer extends Transformer { // Online accounts timestamp is only present if there are also signatures onlineAccountsTimestamp = byteBuffer.getLong(); - final int signaturesByteLength = getOnlineAccountSignaturesLength(onlineAccountsSignaturesCount, onlineAccountsCount, timestamp); + final int signaturesByteLength = (onlineAccountsSignaturesCount * Transformer.SIGNATURE_LENGTH) + (onlineAccountsCount * INT_LENGTH); if (signaturesByteLength > BlockChain.getInstance().getMaxBlockSize()) throw new TransformationException("Byte data too long for online accounts signatures"); @@ -511,16 +511,6 @@ public class BlockTransformer extends Transformer { return nonces; } - public static int getOnlineAccountSignaturesLength(int onlineAccountsSignaturesCount, int onlineAccountCount, long blockTimestamp) { - if (blockTimestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - // Once mempow is active, we expect the online account signatures to be appended with the nonce values - return (onlineAccountsSignaturesCount * Transformer.SIGNATURE_LENGTH) + (onlineAccountCount * INT_LENGTH); - } - else { - // Before mempow, only the online account signatures were included (which will likely be a single signature) - return onlineAccountsSignaturesCount * Transformer.SIGNATURE_LENGTH; - } - } public static byte[] extract(byte[] input, int pos, int length) { byte[] output = new byte[length]; diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index e9f1500d..893add5e 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -24,7 +24,6 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, - "onlineAccountsMemoryPoWTimestamp": 1666454400000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, diff --git a/src/test/java/org/qortal/test/common/AccountUtils.java b/src/test/java/org/qortal/test/common/AccountUtils.java index 0d0b6d6a..0d8baae2 100644 --- a/src/test/java/org/qortal/test/common/AccountUtils.java +++ b/src/test/java/org/qortal/test/common/AccountUtils.java @@ -124,8 +124,6 @@ public class AccountUtils { long timestamp = System.currentTimeMillis(); byte[] timestampBytes = Longs.toByteArray(timestamp); - final boolean mempowActive = timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); - for (int a = 0; a < numAccounts; ++a) { byte[] privateKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; SECURE_RANDOM.nextBytes(privateKey); @@ -135,7 +133,7 @@ public class AccountUtils { byte[] signature = signForAggregation(privateKey, timestampBytes); - Integer nonce = mempowActive ? new Random().nextInt(500000) : null; + Integer nonce = new Random().nextInt(500000); onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey, nonce)); } From f83d4bac7b054b73f5ec42a8fbc6e07c8561e74b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Oct 2022 17:01:58 +0100 Subject: [PATCH 59/71] Reduced online accounts mempow difficulty to 5 on testnets. This allows testnets to more easily coexist on the same machines that are running a mainnet instance, and still tests the mempow computation and verification in a non-resource-intensive way. --- .../controller/OnlineAccountsManager.java | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 0e24bdfc..5e0c2abe 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -20,6 +20,7 @@ import org.qortal.network.message.*; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; import org.qortal.utils.Base58; import org.qortal.utils.NTP; import org.qortal.utils.NamedThreadFactory; @@ -63,9 +64,13 @@ public class OnlineAccountsManager { private static final long ONLINE_ACCOUNTS_COMPUTE_INITIAL_SLEEP_INTERVAL = 30 * 1000L; // ms - // MemoryPoW - public final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes - public int POW_DIFFICULTY = 18; // leading zero bits + // MemoryPoW - mainnet + public static final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY = 18; // leading zero bits + + // MemoryPoW - testnet + public static final int POW_BUFFER_SIZE_TESTNET = 1 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY_TESTNET = 5; // leading zero bits private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, new NamedThreadFactory("OnlineAccounts")); private volatile boolean isStopping = false; @@ -111,6 +116,20 @@ public class OnlineAccountsManager { return (timestamp / getOnlineTimestampModulus()) * getOnlineTimestampModulus(); } + private static int getPoWBufferSize() { + if (Settings.getInstance().isTestNet()) + return POW_BUFFER_SIZE_TESTNET; + + return POW_BUFFER_SIZE; + } + + private static int getPoWDifficulty() { + if (Settings.getInstance().isTestNet()) + return POW_DIFFICULTY_TESTNET; + + return POW_DIFFICULTY; + } + private OnlineAccountsManager() { } @@ -604,7 +623,7 @@ public class OnlineAccountsManager { final long nextOnlineAccountsTimestamp = toOnlineAccountTimestamp(startTime) + getOnlineTimestampModulus(); long timeUntilNextTimestamp = nextOnlineAccountsTimestamp - startTime; - Integer nonce = MemoryPoW.compute2(bytes, POW_BUFFER_SIZE, POW_DIFFICULTY, timeUntilNextTimestamp); + Integer nonce = MemoryPoW.compute2(bytes, getPoWBufferSize(), getPoWDifficulty(), timeUntilNextTimestamp); double totalSeconds = (NTP.getTime() - startTime) / 1000.0f; int minutes = (int) ((totalSeconds % 3600) / 60); @@ -613,7 +632,7 @@ public class OnlineAccountsManager { LOGGER.info(String.format("Computed nonce for timestamp %d and account %.8s: %d. Buffer size: %d. Difficulty: %d. " + "Time taken: %02d:%02d. Hashrate: %f", onlineAccountsTimestamp, Base58.encode(publicKey), - nonce, POW_BUFFER_SIZE, POW_DIFFICULTY, minutes, seconds, hashRate)); + nonce, getPoWBufferSize(), getPoWDifficulty(), minutes, seconds, hashRate)); return nonce; } @@ -634,7 +653,7 @@ public class OnlineAccountsManager { } // Verify the nonce - return MemoryPoW.verify2(mempowBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce); + return MemoryPoW.verify2(mempowBytes, getPoWBufferSize(), getPoWDifficulty(), nonce); } From 510328db47dfe512ec47f9b91e3cf2d74ebd7c10 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 15:50:43 +0100 Subject: [PATCH 60/71] Removed unused timestamp value. --- src/main/java/org/qortal/block/Block.java | 2 +- .../java/org/qortal/controller/OnlineAccountsManager.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 55c13b36..c024308a 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1072,7 +1072,7 @@ public class Block { // Validate the rest for (OnlineAccountData onlineAccount : onlineAccounts) - if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, this.blockData.getTimestamp())) + if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount)) return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; // Extract online accounts' timestamp signatures from block data. Only one signature if aggregated. diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 5e0c2abe..aa35541d 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -339,7 +339,7 @@ public class OnlineAccountsManager { } // Validate mempow - if (!getInstance().verifyMemoryPoW(onlineAccountData, now)) { + if (!getInstance().verifyMemoryPoW(onlineAccountData)) { LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); return false; } @@ -582,7 +582,7 @@ public class OnlineAccountsManager { OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); // Make sure to verify before adding - if (verifyMemoryPoW(ourOnlineAccountData, NTP.getTime())) { + if (verifyMemoryPoW(ourOnlineAccountData)) { ourOnlineAccounts.add(ourOnlineAccountData); } } @@ -637,7 +637,7 @@ public class OnlineAccountsManager { return nonce; } - public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData, Long timestamp) { + public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData) { // Require a valid nonce value if (onlineAccountData.getNonce() == null || onlineAccountData.getNonce() < 0) { return false; From 30cd56165a69bbcbe75eb7c35eb33383cf3b653d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 16:02:25 +0100 Subject: [PATCH 61/71] Speed up syncing blocks in the range of 1-12 hours ago by caching the valid online accounts. --- src/main/java/org/qortal/block/Block.java | 3 +++ .../org/qortal/controller/OnlineAccountsManager.java | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index c024308a..99a82808 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1075,6 +1075,9 @@ public class Block { if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount)) return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; + // Cache the valid online accounts as they will likely be needed for the next block + OnlineAccountsManager.getInstance().addBlocksOnlineAccounts(onlineAccounts, onlineTimestamp); + // Extract online accounts' timestamp signatures from block data. Only one signature if aggregated. List onlineAccountsSignatures = BlockTransformer.decodeTimestampSignatures(encodedOnlineAccountSignatures); diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index aa35541d..53968cfd 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -737,11 +737,12 @@ public class OnlineAccountsManager { * Typically called by {@link Block#areOnlineAccountsValid()} */ public void addBlocksOnlineAccounts(Set blocksOnlineAccounts, Long timestamp) { - // We want to add to 'current' in preference if possible - if (this.currentOnlineAccounts.containsKey(timestamp)) { - addAccounts(blocksOnlineAccounts); + // If these are current accounts, then there is no need to cache them, and should instead rely + // on the more complete entries we already have in self.currentOnlineAccounts. + // Note: since sig-agg, we no longer have individual signatures included in blocks, so we + // mustn't add anything to currentOnlineAccounts from here. + if (this.currentOnlineAccounts.containsKey(timestamp)) return; - } // Add to block cache instead this.latestBlocksOnlineAccounts.computeIfAbsent(timestamp, k -> ConcurrentHashMap.newKeySet()) From b64c05353157b74ebba12c8c2856d6847cf0e390 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 16:54:53 +0100 Subject: [PATCH 62/71] Reuse the work buffer when verifying online accounts from the OnlineAccountsManager import queue. This is a hopeful fix for extra memory usage since mempow activated, due to adding a lot of load to the garbage collector. It only applies to accounts verified from the import queue; the optimization hasn't been applied to block processing. But verifying online accounts when processing blocks is rare and generally would only last a short amount of time. --- src/main/java/org/qortal/block/Block.java | 2 +- .../qortal/controller/OnlineAccountsManager.java | 13 +++++++++---- src/main/java/org/qortal/crypto/MemoryPoW.java | 9 ++++++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 99a82808..5e838458 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1072,7 +1072,7 @@ public class Block { // Validate the rest for (OnlineAccountData onlineAccount : onlineAccounts) - if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount)) + if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, null)) return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; // Cache the valid online accounts as they will likely be needed for the next block diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 53968cfd..1aea118b 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -72,6 +72,11 @@ public class OnlineAccountsManager { public static final int POW_BUFFER_SIZE_TESTNET = 1 * 1024 * 1024; // bytes public static final int POW_DIFFICULTY_TESTNET = 5; // leading zero bits + // IMPORTANT: if we ever need to dynamically modify the buffer size using a feature trigger, the + // pre-allocated buffer below will NOT work, and we should instead use a dynamically allocated + // one for the transition period. + private static long[] POW_VERIFY_WORK_BUFFER = new long[getPoWBufferSize() / 8]; + private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, new NamedThreadFactory("OnlineAccounts")); private volatile boolean isStopping = false; @@ -339,7 +344,7 @@ public class OnlineAccountsManager { } // Validate mempow - if (!getInstance().verifyMemoryPoW(onlineAccountData)) { + if (!getInstance().verifyMemoryPoW(onlineAccountData, POW_VERIFY_WORK_BUFFER)) { LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); return false; } @@ -582,7 +587,7 @@ public class OnlineAccountsManager { OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); // Make sure to verify before adding - if (verifyMemoryPoW(ourOnlineAccountData)) { + if (verifyMemoryPoW(ourOnlineAccountData, null)) { ourOnlineAccounts.add(ourOnlineAccountData); } } @@ -637,7 +642,7 @@ public class OnlineAccountsManager { return nonce; } - public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData) { + public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData, long[] workBuffer) { // Require a valid nonce value if (onlineAccountData.getNonce() == null || onlineAccountData.getNonce() < 0) { return false; @@ -653,7 +658,7 @@ public class OnlineAccountsManager { } // Verify the nonce - return MemoryPoW.verify2(mempowBytes, getPoWBufferSize(), getPoWDifficulty(), nonce); + return MemoryPoW.verify2(mempowBytes, workBuffer, getPoWBufferSize(), getPoWDifficulty(), nonce); } diff --git a/src/main/java/org/qortal/crypto/MemoryPoW.java b/src/main/java/org/qortal/crypto/MemoryPoW.java index f27c8f7a..634b8f9b 100644 --- a/src/main/java/org/qortal/crypto/MemoryPoW.java +++ b/src/main/java/org/qortal/crypto/MemoryPoW.java @@ -99,6 +99,10 @@ public class MemoryPoW { } public static boolean verify2(byte[] data, int workBufferLength, long difficulty, int nonce) { + return verify2(data, null, workBufferLength, difficulty, nonce); + } + + public static boolean verify2(byte[] data, long[] workBuffer, int workBufferLength, long difficulty, int nonce) { // Hash data with SHA256 byte[] hash = Crypto.digest(data); @@ -111,7 +115,10 @@ public class MemoryPoW { byteBuffer = null; int longBufferLength = workBufferLength / 8; - long[] workBuffer = new long[longBufferLength]; + + if (workBuffer == null) + workBuffer = new long[longBufferLength]; + long[] state = new long[4]; long seed = 8682522807148012L; From 59a804c560c22d2655f1f80b75293ed930861d9a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 16:57:52 +0100 Subject: [PATCH 63/71] Include "blocks remaining" in systray when syncing from more than 60 minutes away from a peer's chain tip. --- .../org/qortal/controller/Controller.java | 6 +++++ .../org/qortal/controller/Synchronizer.java | 26 +++++++++++++++++++ src/main/resources/i18n/SysTray_de.properties | 2 ++ src/main/resources/i18n/SysTray_en.properties | 2 ++ src/main/resources/i18n/SysTray_es.properties | 2 ++ src/main/resources/i18n/SysTray_fi.properties | 2 ++ src/main/resources/i18n/SysTray_fr.properties | 2 ++ src/main/resources/i18n/SysTray_hu.properties | 2 ++ src/main/resources/i18n/SysTray_it.properties | 2 ++ src/main/resources/i18n/SysTray_ko.properties | 2 ++ src/main/resources/i18n/SysTray_nl.properties | 2 ++ src/main/resources/i18n/SysTray_ro.properties | 2 ++ src/main/resources/i18n/SysTray_ru.properties | 2 ++ src/main/resources/i18n/SysTray_sv.properties | 2 ++ .../resources/i18n/SysTray_zh_CN.properties | 2 ++ .../resources/i18n/SysTray_zh_TW.properties | 2 ++ 16 files changed, 60 insertions(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 6fe6a159..bcd010e8 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -838,6 +838,12 @@ public class Controller extends Thread { String tooltip = String.format("%s - %d %s", actionText, numberOfPeers, connectionsText); if (!Settings.getInstance().isLite()) { tooltip = tooltip.concat(String.format(" - %s %d", heightText, height)); + + final Integer blocksRemaining = Synchronizer.getInstance().getBlocksRemaining(); + if (blocksRemaining != null && blocksRemaining > 0) { + String blocksRemainingText = Translator.INSTANCE.translate("SysTray", "BLOCKS_REMAINING"); + tooltip = tooltip.concat(String.format(" - %d %s", blocksRemaining, blocksRemainingText)); + } } tooltip = tooltip.concat(String.format("\n%s: %s", Translator.INSTANCE.translate("SysTray", "BUILD_VERSION"), this.buildVersion)); SysTray.getInstance().setToolTipText(tooltip); diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 6f2a0fe1..cd9483e9 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -76,6 +76,8 @@ public class Synchronizer extends Thread { private volatile boolean isSynchronizing = false; /** Temporary estimate of synchronization progress for SysTray use. */ private volatile int syncPercent = 0; + /** Temporary estimate of blocks remaining for SysTray use. */ + private volatile int blocksRemaining = 0; private static volatile boolean requestSync = false; private boolean syncRequestPending = false; @@ -181,6 +183,18 @@ public class Synchronizer extends Thread { } } + public Integer getBlocksRemaining() { + synchronized (this.syncLock) { + // Report as 0 blocks remaining if the latest block is within the last 60 mins + final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L); + if (Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) { + return 0; + } + + return this.isSynchronizing ? this.blocksRemaining : null; + } + } + public void requestSync() { requestSync = true; } @@ -1457,6 +1471,12 @@ public class Synchronizer extends Thread { repository.saveChanges(); + synchronized (this.syncLock) { + if (peer.getChainTipData() != null) { + this.blocksRemaining = peer.getChainTipData().getHeight() - newBlock.getBlockData().getHeight(); + } + } + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } @@ -1552,6 +1572,12 @@ public class Synchronizer extends Thread { repository.saveChanges(); + synchronized (this.syncLock) { + if (peer.getChainTipData() != null) { + this.blocksRemaining = peer.getChainTipData().getHeight() - newBlock.getBlockData().getHeight(); + } + } + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } diff --git a/src/main/resources/i18n/SysTray_de.properties b/src/main/resources/i18n/SysTray_de.properties index b949ca8c..4dc7edd2 100644 --- a/src/main/resources/i18n/SysTray_de.properties +++ b/src/main/resources/i18n/SysTray_de.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatisches Update BLOCK_HEIGHT = height +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Build-Version CHECK_TIME_ACCURACY = Prüfe Zeitgenauigkeit diff --git a/src/main/resources/i18n/SysTray_en.properties b/src/main/resources/i18n/SysTray_en.properties index 204f0df2..39940be0 100644 --- a/src/main/resources/i18n/SysTray_en.properties +++ b/src/main/resources/i18n/SysTray_en.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Auto Update BLOCK_HEIGHT = height +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Build version CHECK_TIME_ACCURACY = Check time accuracy diff --git a/src/main/resources/i18n/SysTray_es.properties b/src/main/resources/i18n/SysTray_es.properties index d4b931d4..36cbb22c 100644 --- a/src/main/resources/i18n/SysTray_es.properties +++ b/src/main/resources/i18n/SysTray_es.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Actualización automática BLOCK_HEIGHT = altura +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versión de compilación CHECK_TIME_ACCURACY = Comprobar la precisión del tiempo diff --git a/src/main/resources/i18n/SysTray_fi.properties b/src/main/resources/i18n/SysTray_fi.properties index bc787715..4038d615 100644 --- a/src/main/resources/i18n/SysTray_fi.properties +++ b/src/main/resources/i18n/SysTray_fi.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automaattinen päivitys BLOCK_HEIGHT = korkeus +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versio CHECK_TIME_ACCURACY = Tarkista ajan tarkkuus diff --git a/src/main/resources/i18n/SysTray_fr.properties b/src/main/resources/i18n/SysTray_fr.properties index 6e60713c..2e376842 100644 --- a/src/main/resources/i18n/SysTray_fr.properties +++ b/src/main/resources/i18n/SysTray_fr.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Mise à jour automatique BLOCK_HEIGHT = hauteur +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Numéro de version CHECK_TIME_ACCURACY = Vérifier l'heure diff --git a/src/main/resources/i18n/SysTray_hu.properties b/src/main/resources/i18n/SysTray_hu.properties index 9bc51ff5..74ab21ac 100644 --- a/src/main/resources/i18n/SysTray_hu.properties +++ b/src/main/resources/i18n/SysTray_hu.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatikus Frissítés BLOCK_HEIGHT = blokkmagasság +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Verzió CHECK_TIME_ACCURACY = Óra pontosságának ellenőrzése diff --git a/src/main/resources/i18n/SysTray_it.properties b/src/main/resources/i18n/SysTray_it.properties index bf61cc46..d966d825 100644 --- a/src/main/resources/i18n/SysTray_it.properties +++ b/src/main/resources/i18n/SysTray_it.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Aggiornamento automatico BLOCK_HEIGHT = altezza +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versione CHECK_TIME_ACCURACY = Controlla la precisione dell'ora diff --git a/src/main/resources/i18n/SysTray_ko.properties b/src/main/resources/i18n/SysTray_ko.properties index 9773a54f..dc6cb69b 100644 --- a/src/main/resources/i18n/SysTray_ko.properties +++ b/src/main/resources/i18n/SysTray_ko.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = 자동 업데이트 BLOCK_HEIGHT = 높이 +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = 빌드 버전 CHECK_TIME_ACCURACY = 시간 정확도 점검 diff --git a/src/main/resources/i18n/SysTray_nl.properties b/src/main/resources/i18n/SysTray_nl.properties index 8a4f112b..c2acb7ce 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 = Block hoogte +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versie nummer CHECK_TIME_ACCURACY = Controleer accuraatheid van de tijd diff --git a/src/main/resources/i18n/SysTray_ro.properties b/src/main/resources/i18n/SysTray_ro.properties index 0e1aa6c6..4130bbcb 100644 --- a/src/main/resources/i18n/SysTray_ro.properties +++ b/src/main/resources/i18n/SysTray_ro.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Actualizare automata BLOCK_HEIGHT = dimensiune +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = versiunea compilatiei CHECK_TIME_ACCURACY = verificare exactitate ora diff --git a/src/main/resources/i18n/SysTray_ru.properties b/src/main/resources/i18n/SysTray_ru.properties index fc3d8648..ff346304 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 = Высота блока +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Версия сборки CHECK_TIME_ACCURACY = Проверка точного времени diff --git a/src/main/resources/i18n/SysTray_sv.properties b/src/main/resources/i18n/SysTray_sv.properties index 0e74337b..96f291b5 100644 --- a/src/main/resources/i18n/SysTray_sv.properties +++ b/src/main/resources/i18n/SysTray_sv.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatisk uppdatering BLOCK_HEIGHT = höjd +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Byggversion CHECK_TIME_ACCURACY = Kontrollera tidens noggrannhet diff --git a/src/main/resources/i18n/SysTray_zh_CN.properties b/src/main/resources/i18n/SysTray_zh_CN.properties index c103d24b..d6848a7c 100644 --- a/src/main/resources/i18n/SysTray_zh_CN.properties +++ b/src/main/resources/i18n/SysTray_zh_CN.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = 自动更新 BLOCK_HEIGHT = 区块高度 +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = 版本 CHECK_TIME_ACCURACY = 检查时间准确性 diff --git a/src/main/resources/i18n/SysTray_zh_TW.properties b/src/main/resources/i18n/SysTray_zh_TW.properties index 5e6ccc3e..eabdbb63 100644 --- a/src/main/resources/i18n/SysTray_zh_TW.properties +++ b/src/main/resources/i18n/SysTray_zh_TW.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = 自動更新 BLOCK_HEIGHT = 區塊高度 +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = 版本 CHECK_TIME_ACCURACY = 檢查時間準確性 From 166425bee9e943ddf8c603da7e1c7d4184481276 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 17:20:39 +0100 Subject: [PATCH 64/71] Added feature trigger timestamp (TBC) to increase online accounts mempow difficulty (also TBC). --- src/main/java/org/qortal/block/BlockChain.java | 7 ++++++- .../controller/OnlineAccountsManager.java | 17 +++++++++++------ src/main/resources/blockchain.json | 3 ++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 826fdd78..5e1f44f3 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -73,7 +73,8 @@ public class BlockChain { calcChainWeightTimestamp, transactionV5Timestamp, transactionV6Timestamp, - disableReferenceTimestamp; + disableReferenceTimestamp, + increaseOnlineAccountsDifficultyTimestamp; } // Custom transaction fees @@ -478,6 +479,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.disableReferenceTimestamp.name()).longValue(); } + public long getIncreaseOnlineAccountsDifficultyTimestamp() { + return this.featureTriggers.get(FeatureTrigger.increaseOnlineAccountsDifficultyTimestamp.name()).longValue(); + } + // More complex getters for aspects that change by height or timestamp diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 1aea118b..fd2c38df 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -66,7 +66,8 @@ public class OnlineAccountsManager { // MemoryPoW - mainnet public static final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY = 18; // leading zero bits + public static final int POW_DIFFICULTY_V1 = 18; // leading zero bits + public static final int POW_DIFFICULTY_V2 = 19; // leading zero bits // MemoryPoW - testnet public static final int POW_BUFFER_SIZE_TESTNET = 1 * 1024 * 1024; // bytes @@ -128,11 +129,14 @@ public class OnlineAccountsManager { return POW_BUFFER_SIZE; } - private static int getPoWDifficulty() { + private static int getPoWDifficulty(long timestamp) { if (Settings.getInstance().isTestNet()) return POW_DIFFICULTY_TESTNET; - return POW_DIFFICULTY; + if (timestamp >= BlockChain.getInstance().getIncreaseOnlineAccountsDifficultyTimestamp()) + return POW_DIFFICULTY_V2; + + return POW_DIFFICULTY_V1; } private OnlineAccountsManager() { @@ -628,7 +632,8 @@ public class OnlineAccountsManager { final long nextOnlineAccountsTimestamp = toOnlineAccountTimestamp(startTime) + getOnlineTimestampModulus(); long timeUntilNextTimestamp = nextOnlineAccountsTimestamp - startTime; - Integer nonce = MemoryPoW.compute2(bytes, getPoWBufferSize(), getPoWDifficulty(), timeUntilNextTimestamp); + int difficulty = getPoWDifficulty(onlineAccountsTimestamp); + Integer nonce = MemoryPoW.compute2(bytes, getPoWBufferSize(), difficulty, timeUntilNextTimestamp); double totalSeconds = (NTP.getTime() - startTime) / 1000.0f; int minutes = (int) ((totalSeconds % 3600) / 60); @@ -637,7 +642,7 @@ public class OnlineAccountsManager { LOGGER.info(String.format("Computed nonce for timestamp %d and account %.8s: %d. Buffer size: %d. Difficulty: %d. " + "Time taken: %02d:%02d. Hashrate: %f", onlineAccountsTimestamp, Base58.encode(publicKey), - nonce, getPoWBufferSize(), getPoWDifficulty(), minutes, seconds, hashRate)); + nonce, getPoWBufferSize(), difficulty, minutes, seconds, hashRate)); return nonce; } @@ -658,7 +663,7 @@ public class OnlineAccountsManager { } // Verify the nonce - return MemoryPoW.verify2(mempowBytes, workBuffer, getPoWBufferSize(), getPoWDifficulty(), nonce); + return MemoryPoW.verify2(mempowBytes, workBuffer, getPoWBufferSize(), getPoWDifficulty(onlineAccountData.getTimestamp()), nonce); } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 893add5e..34671c76 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -79,7 +79,8 @@ "calcChainWeightTimestamp": 1620579600000, "transactionV5Timestamp": 1642176000000, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 1655222400000 + "disableReferenceTimestamp": 1655222400000, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, From f739d8f5c6a9d0edc4a9a138f5468efe6ee8eeb7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 18:06:34 +0100 Subject: [PATCH 65/71] Added increaseOnlineAccountsDifficultyTimestamp feature trigger to unit tests. --- src/test/resources/test-chain-v2-block-timestamps.json | 3 ++- src/test/resources/test-chain-v2-disable-reference.json | 3 ++- src/test/resources/test-chain-v2-founder-rewards.json | 3 ++- src/test/resources/test-chain-v2-leftover-reward.json | 3 ++- src/test/resources/test-chain-v2-minting.json | 3 ++- src/test/resources/test-chain-v2-qora-holder-extremes.json | 3 ++- src/test/resources/test-chain-v2-qora-holder-reduction.json | 3 ++- src/test/resources/test-chain-v2-qora-holder.json | 3 ++- src/test/resources/test-chain-v2-reward-levels.json | 3 ++- src/test/resources/test-chain-v2-reward-scaling.json | 3 ++- src/test/resources/test-chain-v2-reward-shares.json | 3 ++- src/test/resources/test-chain-v2.json | 3 ++- 12 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 37224684..4a883bd9 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -69,7 +69,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index 7ea0b86d..e8fee5e0 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -72,7 +72,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 0 + "disableReferenceTimestamp": 0, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 85a50f83..17a713a0 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index ebc3ccfa..b57c3195 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index cc91f993..60b3cd76 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 085d1dbf..2d044687 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 75858057..3cf8848e 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "aggregateSignatureTimestamp": 0 + "aggregateSignatureTimestamp": 0, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 0706c5bb..93965b76 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index b3644d6b..06422e71 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 1c68dda4..6adcd0ac 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 10d2aab3..95324b56 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -73,7 +73,8 @@ "newConsensusTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 5f439602..c0fb9861 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, From fa80c838645effa63936acbfd84c0fbca0269b56 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 30 Oct 2022 17:07:56 +0000 Subject: [PATCH 66/71] Remove QORTAL_METADATA service as this uses its own protocol instead. --- src/main/java/org/qortal/arbitrary/misc/Service.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 5d94d806..99e9de36 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -47,8 +47,7 @@ public enum Service { LIST(900, true, null, null), PLAYLIST(910, true, null, null), APP(1000, false, null, null), - METADATA(1100, false, null, null), - QORTAL_METADATA(1111, true, 10*1024L, Arrays.asList("title", "description", "tags")); + METADATA(1100, false, null, null); public final int value; private final boolean requiresValidation; From 4043ae19285faf56411ff1a5dc7a6033c1543c09 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 30 Oct 2022 17:23:46 +0000 Subject: [PATCH 67/71] Added QCHAT_IMAGE service (with 500KB file size limit). --- src/main/java/org/qortal/arbitrary/misc/Service.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 99e9de36..981aa119 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -38,6 +38,7 @@ public enum Service { GIT_REPOSITORY(300, false, null, null), IMAGE(400, true, 10*1024*1024L, null), THUMBNAIL(410, true, 500*1024L, null), + QCHAT_IMAGE(420, true, 500*1024L, null), VIDEO(500, false, null, null), AUDIO(600, false, null, null), BLOG(700, false, null, null), From 0628847d14db75aa30e784ef81c1c26794d7c56b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 30 Oct 2022 17:25:11 +0000 Subject: [PATCH 68/71] Removed QORTAL_METADATA service tests. --- .../test/arbitrary/ArbitraryServiceTests.java | 74 ------------------- 1 file changed, 74 deletions(-) diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index 4db8bdc7..d71910f7 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -101,78 +101,4 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.MISSING_INDEX_FILE, service.validate(path)); } - @Test - public void testValidQortalMetadata() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"title\":\"Test Title\", \"description\":\"Test description\", \"tags\":[\"test\"]}"; - - // Write to temp path - Path path = Files.createTempFile("testValidQortalMetadata", null); - path.toFile().deleteOnExit(); - Files.write(path, dataString.getBytes(), StandardOpenOption.CREATE); - - Service service = Service.QORTAL_METADATA; - assertTrue(service.isValidationRequired()); - assertEquals(ValidationResult.OK, service.validate(path)); - } - - @Test - public void testQortalMetadataMissingKeys() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"description\":\"Test description\", \"tags\":[\"test\"]}"; - - // Write to temp path - Path path = Files.createTempFile("testQortalMetadataMissingKeys", null); - path.toFile().deleteOnExit(); - Files.write(path, dataString.getBytes(), StandardOpenOption.CREATE); - - Service service = Service.QORTAL_METADATA; - assertTrue(service.isValidationRequired()); - assertEquals(ValidationResult.MISSING_KEYS, service.validate(path)); - } - - @Test - public void testQortalMetadataTooLarge() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"title\":\"Test Title\", \"description\":\"Test description\", \"tags\":[\"test\"]}"; - - // Generate some large data to go along with it - int largeDataSize = 11*1024; // Larger than allowed 10kiB - byte[] largeData = new byte[largeDataSize]; - new Random().nextBytes(largeData); - - // Write to temp path - Path path = Files.createTempDirectory("testQortalMetadataTooLarge"); - path.toFile().deleteOnExit(); - Files.write(Paths.get(path.toString(), "data"), dataString.getBytes(), StandardOpenOption.CREATE); - Files.write(Paths.get(path.toString(), "large_data"), largeData, StandardOpenOption.CREATE); - - Service service = Service.QORTAL_METADATA; - assertTrue(service.isValidationRequired()); - assertEquals(ValidationResult.EXCEEDS_SIZE_LIMIT, service.validate(path)); - } - - @Test - public void testMultipleFileMetadata() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"title\":\"Test Title\", \"description\":\"Test description\", \"tags\":[\"test\"]}"; - - // Generate some large data to go along with it - int otherDataSize = 1024; // Smaller than 10kiB limit - byte[] otherData = new byte[otherDataSize]; - new Random().nextBytes(otherData); - - // Write to temp path - Path path = Files.createTempDirectory("testMultipleFileMetadata"); - path.toFile().deleteOnExit(); - Files.write(Paths.get(path.toString(), "data"), dataString.getBytes(), StandardOpenOption.CREATE); - Files.write(Paths.get(path.toString(), "other_data"), otherData, StandardOpenOption.CREATE); - - Service service = Service.QORTAL_METADATA; - assertTrue(service.isValidationRequired()); - - // There are multiple files, so we don't know which one to parse as JSON - assertEquals(ValidationResult.MISSING_KEYS, service.validate(path)); - } - } From 985c195e9e1a3cbdf6791bae3ce73947d2925931 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 30 Oct 2022 17:33:21 +0000 Subject: [PATCH 69/71] Added GIF_REPOSITORY, with custom validation function and unit tests. --- .../org/qortal/arbitrary/misc/Service.java | 35 ++++++++- .../test/arbitrary/ArbitraryServiceTests.java | 74 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 981aa119..5dd8d94e 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -1,16 +1,18 @@ package org.qortal.arbitrary.misc; +import org.apache.commons.io.FilenameUtils; import org.json.JSONObject; import org.qortal.arbitrary.ArbitraryDataRenderer; import org.qortal.transaction.Transaction; import org.qortal.utils.FilesystemUtils; +import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Objects; import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; @@ -48,7 +50,31 @@ public enum Service { LIST(900, true, null, null), PLAYLIST(910, true, null, null), APP(1000, false, null, null), - METADATA(1100, false, null, null); + METADATA(1100, false, null, null), + GIF_REPOSITORY(1200, true, 25*1024*1024L, null) { + @Override + public ValidationResult validate(Path path) { + // Custom validation function to require .gif files only, and at least 1 + int gifCount = 0; + File[] files = path.toFile().listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + return ValidationResult.DIRECTORIES_NOT_ALLOWED; + } + String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(); + if (!Objects.equals(extension, "gif")) { + return ValidationResult.INVALID_FILE_EXTENSION; + } + gifCount++; + } + } + if (gifCount == 0) { + return ValidationResult.MISSING_DATA; + } + return ValidationResult.OK; + } + }; public final int value; private final boolean requiresValidation; @@ -114,7 +140,10 @@ public enum Service { OK(1), MISSING_KEYS(2), EXCEEDS_SIZE_LIMIT(3), - MISSING_INDEX_FILE(4); + MISSING_INDEX_FILE(4), + DIRECTORIES_NOT_ALLOWED(5), + INVALID_FILE_EXTENSION(6), + MISSING_DATA(7); public final int value; diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index d71910f7..e6a51776 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -101,4 +101,78 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.MISSING_INDEX_FILE, service.validate(path)); } + @Test + public void testValidateGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image3.gif"), data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.OK, service.validate(path)); + } + + @Test + public void testValidateMultiLayerGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateMultiLayerGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + + Path subdirectory = Paths.get(path.toString(), "subdirectory"); + Files.createDirectories(subdirectory); + Files.write(Paths.get(subdirectory.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(subdirectory.toString(), "image3.gif"), data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); + } + + @Test + public void testValidateEmptyGifRepository() throws IOException { + Path path = Files.createTempDirectory("testValidateEmptyGifRepository"); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.MISSING_DATA, service.validate(path)); + } + + @Test + public void testValidateInvalidGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateInvalidGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image3.jpg"), data, StandardOpenOption.CREATE); // Invalid extension + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); + } + } From aead9cfcbfe23747d60ce1a59d05be9354ac22fa Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 1 Nov 2022 08:55:57 +0000 Subject: [PATCH 70/71] Proof of concept: speed up QORT buying When users buy QORT ("Alice"-side), most of the API time is spent computing mempow for the MESSAGE sent to Bob's AT. This is the final stage startResponse() and after Alice's P2SH is already broadcast. To speed this up, the MESSAGE part is moved into its own thread allowing startResponse() to return sooner, improving the user experience. Caveats: If MESSAGE importAsUnconfirmed() somehow fails the the buy won't complete and Alice will have to wait for P2SH refund. If Alice shuts down her node while MESSAGE mempow is being computed then it's possible the shutdown will be blocked until mempow is complete. Currently only implemented in LitecoinACCTv3TradeBot as this is only proof-of-concept. Tested with multiple buys in the same block. --- .../tradebot/LitecoinACCTv3TradeBot.java | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java index a31a1a28..a4ae921e 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,27 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { 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); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + messageTransaction.computeNonce(); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.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; - } + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); From 9c68f1038ab899e69831345f10916e9ea6732115 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 5 Nov 2022 14:02:04 +0000 Subject: [PATCH 71/71] Bump AT version to 1.4.0 --- lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar | Bin 0 -> 161850 bytes lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom | 9 +++++++++ lib/org/ciyam/AT/maven-metadata-local.xml | 5 +++-- pom.xml | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar create mode 100644 lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom diff --git a/lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..c2c3d3556575dd7793cbd08ba3013003e8eaf621 GIT binary patch literal 161850 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 GNcMjLnE!kL literal 0 HcmV?d00001 diff --git a/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom new file mode 100644 index 00000000..0dc1aedc --- /dev/null +++ b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.ciyam + AT + 1.4.0 + POM was created from install:install-file + diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml index 8f8b1f6e..063c735d 100644 --- a/lib/org/ciyam/AT/maven-metadata-local.xml +++ b/lib/org/ciyam/AT/maven-metadata-local.xml @@ -3,14 +3,15 @@ org.ciyam AT - 1.3.8 + 1.4.0 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 + 1.4.0 - 20200925114415 + 20221105114346 diff --git a/pom.xml b/pom.xml index eb306420..860cdce5 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ 0.15.10 1.69 ${maven.build.timestamp} - 1.3.8 + 1.4.0 3.6 1.8 2.6