diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 2a63f092..d2300a0b 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -19,10 +19,10 @@ - + - + @@ -174,7 +174,7 @@ - + diff --git a/lib/org/ciyam/AT/1.3.8/AT-1.3.8.jar b/lib/org/ciyam/AT/1.3.8/AT-1.3.8.jar new file mode 100644 index 00000000..5e7c3677 Binary files /dev/null and b/lib/org/ciyam/AT/1.3.8/AT-1.3.8.jar differ diff --git a/lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom b/lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom new file mode 100644 index 00000000..106adc38 --- /dev/null +++ b/lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.ciyam + AT + 1.3.8 + 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 ff99d403..8f8b1f6e 100644 --- a/lib/org/ciyam/AT/maven-metadata-local.xml +++ b/lib/org/ciyam/AT/maven-metadata-local.xml @@ -3,13 +3,14 @@ org.ciyam AT - 1.3.7 + 1.3.8 1.3.4 1.3.5 1.3.6 1.3.7 + 1.3.8 - 20200812131412 + 20200925114415 diff --git a/pom.xml b/pom.xml index 3986e8e9..03ee9fdd 100644 --- a/pom.xml +++ b/pom.xml @@ -3,20 +3,19 @@ 4.0.0 org.qortal qortal - 1.3.5 + 1.3.7 jar bf9fb80 0.15.6 1.64 ${maven.build.timestamp} - 1.3.7 + 1.3.8 3.6 1.8 1.2.2 28.1-jre - 2.5.0-fixed - 2.5.0 + 2.5.1 2.29.1 9.4.29.v20200521 2.12.1 @@ -407,12 +406,6 @@ hsqldb ${hsqldb.version} - - org.hsqldb - sqltool - ${hsqldb-sqltool.version} - test - org.ciyam diff --git a/src/main/java/org/qortal/api/Security.java b/src/main/java/org/qortal/api/Security.java index 2449f781..448f951a 100644 --- a/src/main/java/org/qortal/api/Security.java +++ b/src/main/java/org/qortal/api/Security.java @@ -5,10 +5,20 @@ import java.net.UnknownHostException; import javax.servlet.http.HttpServletRequest; -public class Security { +import org.qortal.settings.Settings; + +public abstract class Security { + + public static final String API_KEY_HEADER = "X-API-KEY"; - // TODO: replace with proper authentication public static void checkApiCallAllowed(HttpServletRequest request) { + String expectedApiKey = Settings.getInstance().getApiKey(); + String passedApiKey = request.getHeader(API_KEY_HEADER); + + if ((expectedApiKey != null && !expectedApiKey.equals(passedApiKey)) || + (passedApiKey != null && !passedApiKey.equals(expectedApiKey))) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); + InetAddress remoteAddr; try { remoteAddr = InetAddress.getByName(request.getRemoteAddr()); @@ -19,4 +29,5 @@ public class Security { if (!remoteAddr.isLoopbackAddress()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); } + } diff --git a/src/main/java/org/qortal/api/model/ActivitySummary.java b/src/main/java/org/qortal/api/model/ActivitySummary.java index 27b5ed8d..e8e9a3aa 100644 --- a/src/main/java/org/qortal/api/model/ActivitySummary.java +++ b/src/main/java/org/qortal/api/model/ActivitySummary.java @@ -1,5 +1,6 @@ package org.qortal.api.model; +import java.util.Collections; import java.util.EnumMap; import java.util.Map; @@ -13,17 +14,61 @@ import org.qortal.transaction.Transaction.TransactionType; @XmlAccessorType(XmlAccessType.FIELD) public class ActivitySummary { - public int blockCount; - public int transactionCount; - public int assetsIssued; - public int namesRegistered; + private int blockCount; + private int assetsIssued; + private int namesRegistered; // Assuming TransactionType values are contiguous so 'length' equals count @XmlJavaTypeAdapter(TransactionCountMapXmlAdapter.class) - public Map transactionCountByType = new EnumMap<>(TransactionType.class); + private Map transactionCountByType = new EnumMap<>(TransactionType.class); + private int totalTransactionCount = 0; public ActivitySummary() { // Needed for JAXB } + public int getBlockCount() { + return this.blockCount; + } + + public void setBlockCount(int blockCount) { + this.blockCount = blockCount; + } + + public int getTotalTransactionCount() { + return this.totalTransactionCount; + } + + public int getAssetsIssued() { + return this.assetsIssued; + } + + public void setAssetsIssued(int assetsIssued) { + this.assetsIssued = assetsIssued; + } + + public int getNamesRegistered() { + return this.namesRegistered; + } + + public void setNamesRegistered(int namesRegistered) { + this.namesRegistered = namesRegistered; + } + + public Map getTransactionCountByType() { + return Collections.unmodifiableMap(this.transactionCountByType); + } + + public void setTransactionCountByType(TransactionType transactionType, int transactionCount) { + this.transactionCountByType.put(transactionType, transactionCount); + + this.totalTransactionCount = this.transactionCountByType.values().stream().mapToInt(Integer::intValue).sum(); + } + + public void setTransactionCountByType(Map transactionCountByType) { + this.transactionCountByType = new EnumMap<>(transactionCountByType); + + this.totalTransactionCount = this.transactionCountByType.values().stream().mapToInt(Integer::intValue).sum(); + } + } diff --git a/src/main/java/org/qortal/api/model/NodeInfo.java b/src/main/java/org/qortal/api/model/NodeInfo.java index 86ed6971..16a4df75 100644 --- a/src/main/java/org/qortal/api/model/NodeInfo.java +++ b/src/main/java/org/qortal/api/model/NodeInfo.java @@ -11,6 +11,7 @@ public class NodeInfo { public String buildVersion; public long buildTimestamp; public String nodeId; + public boolean isTestNet; public NodeInfo() { } diff --git a/src/main/java/org/qortal/api/resource/AddressesResource.java b/src/main/java/org/qortal/api/resource/AddressesResource.java index 39b4bd71..20e4da5a 100644 --- a/src/main/java/org/qortal/api/resource/AddressesResource.java +++ b/src/main/java/org/qortal/api/resource/AddressesResource.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.math.BigDecimal; @@ -473,6 +474,7 @@ public class AddressesResource { } ) @ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String computePublicize(String rawBytes58) { Security.checkApiCallAllowed(request); diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 7b07551a..f24389bf 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.io.IOException; @@ -40,7 +41,6 @@ import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; -import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.ActivitySummary; @@ -57,6 +57,7 @@ import org.qortal.network.PeerAddress; 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; @@ -118,6 +119,7 @@ public class AdminResource { nodeInfo.buildVersion = Controller.getInstance().getVersionString(); nodeInfo.buildTimestamp = Controller.getInstance().getBuildTimestamp(); nodeInfo.nodeId = Network.getInstance().getOurNodeId(); + nodeInfo.isTestNet = Settings.getInstance().isTestNet(); return nodeInfo; } @@ -132,6 +134,7 @@ public class AdminResource { ) } ) + @SecurityRequirement(name = "apiKey") public NodeStatus status() { Security.checkApiCallAllowed(request); @@ -152,6 +155,7 @@ public class AdminResource { ) } ) + @SecurityRequirement(name = "apiKey") public String shutdown() { Security.checkApiCallAllowed(request); @@ -180,7 +184,10 @@ public class AdminResource { } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public ActivitySummary summary() { + Security.checkApiCallAllowed(request); + ActivitySummary summary = new ActivitySummary(); LocalDate date = LocalDate.now(); @@ -192,16 +199,13 @@ public class AdminResource { int startHeight = repository.getBlockRepository().getHeightFromTimestamp(start); int endHeight = repository.getBlockRepository().getBlockchainHeight(); - summary.blockCount = endHeight - startHeight; + summary.setBlockCount(endHeight - startHeight); - summary.transactionCountByType = repository.getTransactionRepository().getTransactionSummary(startHeight + 1, endHeight); + summary.setTransactionCountByType(repository.getTransactionRepository().getTransactionSummary(startHeight + 1, endHeight)); - for (Integer count : summary.transactionCountByType.values()) - summary.transactionCount += count; + summary.setAssetsIssued(repository.getAssetRepository().getRecentAssetIds(start).size()); - summary.assetsIssued = repository.getAssetRepository().getRecentAssetIds(start).size(); - - summary.namesRegistered = repository.getNameRepository().getRecentNames(start).size(); + summary.setNamesRegistered (repository.getNameRepository().getRecentNames(start).size()); return summary; } catch (DataException e) { @@ -209,6 +213,30 @@ public class AdminResource { } } + @GET + @Path("/enginestats") + @Operation( + summary = "Fetch statistics snapshot for core engine", + responses = { + @ApiResponse( + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + array = @ArraySchema( + schema = @Schema( + implementation = Controller.StatsSnapshot.class + ) + ) + ) + ) + } + ) + @SecurityRequirement(name = "apiKey") + public Controller.StatsSnapshot getEngineStats() { + Security.checkApiCallAllowed(request); + + return Controller.getInstance().getStatsSnapshot(); + } + @GET @Path("/mintingaccounts") @Operation( @@ -221,6 +249,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public List getMintingAccounts() { Security.checkApiCallAllowed(request); @@ -267,6 +296,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE, ApiError.CANNOT_MINT}) + @SecurityRequirement(name = "apiKey") public String addMintingAccount(String seed58) { Security.checkApiCallAllowed(request); @@ -319,6 +349,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String deleteMintingAccount(String key58) { Security.checkApiCallAllowed(request); @@ -418,6 +449,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.INVALID_HEIGHT, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String orphan(String targetHeightString) { Security.checkApiCallAllowed(request); @@ -435,8 +467,6 @@ public class AdminResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } catch (NumberFormatException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT); - } catch (ApiException e) { - throw e; } } @@ -461,6 +491,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String forceSync(String targetPeerAddress) { Security.checkApiCallAllowed(request); @@ -492,8 +523,6 @@ public class AdminResource { return syncResult.name(); } catch (IllegalArgumentException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - } catch (ApiException e) { - throw e; } catch (UnknownHostException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); } catch (InterruptedException e) { @@ -501,4 +530,151 @@ public class AdminResource { } } + @GET + @Path("/repository/data") + @Operation( + summary = "Export sensitive/node-local data from repository.", + description = "Exports data to .script files on local machine" + ) + @ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String exportRepository() { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + repository.exportNodeLocalData(); + return "true"; + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // We couldn't lock blockchain to perform export + return "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/repository/data") + @Operation( + summary = "Import data into repository.", + description = "Imports data from file on local machine. Filename is forced to 'import.script' if apiKey is not set.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", example = "MintingAccounts.script" + ) + ) + ), + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String importRepository(String filename) { + Security.checkApiCallAllowed(request); + + // Hard-coded because it's too dangerous to allow user-supplied filenames in weaker security contexts + if (Settings.getInstance().getApiKey() == null) + filename = "import.script"; + + try (final Repository repository = RepositoryManager.getRepository()) { + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + repository.importDataFromFile(filename); + repository.saveChanges(); + + return "true"; + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // We couldn't lock blockchain to perform import + return "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/repository/checkpoint") + @Operation( + summary = "Checkpoint data in repository.", + description = "Forces repository to checkpoint uncommitted writes.", + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String checkpointRepository() { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + repository.checkpoint(true); + repository.saveChanges(); + + return "true"; + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // We couldn't lock blockchain to perform checkpoint + return "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @DELETE + @Path("/repository") + @Operation( + summary = "Perform maintenance on repository.", + description = "Requires enough free space to rebuild repository. This will pause your node for a while." + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public void performRepositoryMaintenance() { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + repository.performPeriodicMaintenance(); + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // No big deal + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + } diff --git a/src/main/java/org/qortal/api/resource/ApiDefinition.java b/src/main/java/org/qortal/api/resource/ApiDefinition.java index ae7de00c..f9ec7459 100644 --- a/src/main/java/org/qortal/api/resource/ApiDefinition.java +++ b/src/main/java/org/qortal/api/resource/ApiDefinition.java @@ -1,11 +1,17 @@ package org.qortal.api.resource; import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.security.SecuritySchemes; import io.swagger.v3.oas.annotations.tags.Tag; +import org.qortal.api.Security; + @OpenAPIDefinition( info = @Info( title = "Qortal API", description = "NOTE: byte-arrays are encoded in Base58" ), tags = { @@ -30,5 +36,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; }) } ) +@SecuritySchemes({ + @SecurityScheme(name = "basicAuth", type = SecuritySchemeType.HTTP, scheme = "basic"), + @SecurityScheme(name = "apiKey", type = SecuritySchemeType.APIKEY, in = SecuritySchemeIn.HEADER, paramName = Security.API_KEY_HEADER) +}) public class ApiDefinition { } \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index 9a8fc8d5..6ad7d6ea 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; @@ -156,6 +157,7 @@ public class ChatResource { } ) @ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String buildChat(ChatTransactionData transactionData) { Security.checkApiCallAllowed(request); @@ -203,6 +205,7 @@ public class ChatResource { } ) @ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String buildChat(String rawBytes58) { Security.checkApiCallAllowed(request); diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index a5a21336..47c0ed62 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.ArrayList; @@ -224,6 +225,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String cancelTrade(CrossChainCancelRequest cancelRequest) { Security.checkApiCallAllowed(request); diff --git a/src/main/java/org/qortal/api/resource/PeersResource.java b/src/main/java/org/qortal/api/resource/PeersResource.java index 80cb5fa5..70f0e3e9 100644 --- a/src/main/java/org/qortal/api/resource/PeersResource.java +++ b/src/main/java/org/qortal/api/resource/PeersResource.java @@ -6,8 +6,11 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -26,10 +29,17 @@ import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.ConnectedPeer; +import org.qortal.controller.Controller; +import org.qortal.controller.Synchronizer; +import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.data.block.BlockSummaryData; import org.qortal.data.network.PeerData; import org.qortal.network.Network; +import org.qortal.network.Peer; import org.qortal.network.PeerAddress; import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.utils.ExecuteProduceConsume; import org.qortal.utils.NTP; @@ -122,6 +132,7 @@ public class PeersResource { ) } ) + @SecurityRequirement(name = "apiKey") public ExecuteProduceConsume.StatsSnapshot getEngineStats() { Security.checkApiCallAllowed(request); @@ -159,6 +170,7 @@ public class PeersResource { @ApiErrors({ ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE }) + @SecurityRequirement(name = "apiKey") public String addPeer(String address) { Security.checkApiCallAllowed(request); @@ -213,6 +225,7 @@ public class PeersResource { @ApiErrors({ ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE }) + @SecurityRequirement(name = "apiKey") public String removePeer(String address) { Security.checkApiCallAllowed(request); @@ -248,6 +261,7 @@ public class PeersResource { @ApiErrors({ ApiError.REPOSITORY_ISSUE }) + @SecurityRequirement(name = "apiKey") public String removeKnownPeers(String address) { Security.checkApiCallAllowed(request); @@ -260,4 +274,68 @@ public class PeersResource { } } + @POST + @Path("/commonblock") + @Operation( + summary = "Report common block with given peer.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", example = "node2.qortal.org" + ) + ) + ), + responses = { + @ApiResponse( + description = "the block", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = BlockSummaryData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public List commonBlock(String targetPeerAddress) { + Security.checkApiCallAllowed(request); + + try { + // Try to resolve passed address to make things easier + PeerAddress peerAddress = PeerAddress.fromString(targetPeerAddress); + InetSocketAddress resolvedAddress = peerAddress.toSocketAddress(); + + List peers = Network.getInstance().getHandshakedPeers(); + Peer targetPeer = peers.stream().filter(peer -> peer.getResolvedAddress().equals(resolvedAddress)).findFirst().orElse(null); + + if (targetPeer == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + try (final Repository repository = RepositoryManager.getRepository()) { + int ourInitialHeight = Controller.getInstance().getChainHeight(); + boolean force = true; + List peerBlockSummaries = new ArrayList<>(); + + SynchronizationResult findCommonBlockResult = Synchronizer.getInstance().fetchSummariesFromCommonBlock(repository, targetPeer, ourInitialHeight, force, peerBlockSummaries); + if (findCommonBlockResult != SynchronizationResult.OK) + return null; + + return peerBlockSummaries; + } + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + } catch (UnknownHostException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (InterruptedException e) { + return null; + } + } + } diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index 683258f9..24d00a4c 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -257,7 +257,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { if (crossChainTradeData.mode == AcctMode.OFFERING) // We want when trade was created, not when it was last updated - atStateTimestamp = atState.getCreation(); + atStateTimestamp = crossChainTradeData.creationTimestamp; else atStateTimestamp = timestamp != null ? timestamp : repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); diff --git a/src/main/java/org/qortal/at/AT.java b/src/main/java/org/qortal/at/AT.java index 5aba1b73..e82ab14e 100644 --- a/src/main/java/org/qortal/at/AT.java +++ b/src/main/java/org/qortal/at/AT.java @@ -51,16 +51,17 @@ public class AT { MachineState machineState = new MachineState(api, loggerFactory, deployATTransactionData.getCreationBytes()); - byte[] codeHash = Crypto.digest(machineState.getCodeBytes()); + byte[] codeBytes = machineState.getCodeBytes(); + byte[] codeHash = Crypto.digest(codeBytes); - this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, machineState.getCodeBytes(), codeHash, + this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, codeBytes, codeHash, machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(), machineState.isFrozen(), machineState.getFrozenBalance()); byte[] stateData = machineState.toBytes(); byte[] stateHash = Crypto.digest(stateData); - this.atStateData = new ATStateData(atAddress, height, creation, stateData, stateHash, 0L, true); + this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true); } // Getters / setters @@ -106,12 +107,11 @@ public class AT { throw new DataException(String.format("Uncaught exception while running AT '%s'", atAddress), e); } - long creation = this.atData.getCreation(); byte[] stateData = state.toBytes(); byte[] stateHash = Crypto.digest(stateData); long atFees = api.calcFinalFees(state); - this.atStateData = new ATStateData(atAddress, blockHeight, creation, stateData, stateHash, atFees, false); + this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false); return api.getTransactions(); } diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index e1273072..b977a613 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -6,6 +6,8 @@ import static java.util.stream.Collectors.toMap; import java.math.BigDecimal; import java.math.BigInteger; import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.text.NumberFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -15,6 +17,7 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.account.Account; @@ -791,15 +794,46 @@ public class Block { return BigInteger.valueOf(blockSummaryData.getOnlineAccountsCount()).shiftLeft(ACCOUNTS_COUNT_SHIFT).add(keyDistance); } - public static BigInteger calcChainWeight(int commonBlockHeight, byte[] commonBlockSignature, List blockSummaries) { + public static BigInteger calcChainWeight(int commonBlockHeight, byte[] commonBlockSignature, List blockSummaries, int maxHeight) { BigInteger cumulativeWeight = BigInteger.ZERO; int parentHeight = commonBlockHeight; byte[] parentBlockSignature = commonBlockSignature; + NumberFormat formatter = new DecimalFormat("0.###E0"); + boolean isLogging = LOGGER.getLevel().isLessSpecificThan(Level.TRACE); for (BlockSummaryData blockSummaryData : blockSummaries) { - cumulativeWeight = cumulativeWeight.shiftLeft(CHAIN_WEIGHT_SHIFT).add(calcBlockWeight(parentHeight, parentBlockSignature, blockSummaryData)); + StringBuilder stringBuilder = isLogging ? new StringBuilder(512) : null; + + if (isLogging) + stringBuilder.append(formatter.format(cumulativeWeight)).append(" -> "); + + cumulativeWeight = cumulativeWeight.shiftLeft(CHAIN_WEIGHT_SHIFT); + if (isLogging) + stringBuilder.append(formatter.format(cumulativeWeight)).append(" + "); + + BigInteger blockWeight = calcBlockWeight(parentHeight, parentBlockSignature, blockSummaryData); + if (isLogging) + stringBuilder.append("(height: ") + .append(parentHeight + 1) + .append(", online: ") + .append(blockSummaryData.getOnlineAccountsCount()) + .append(") ") + .append(formatter.format(blockWeight)); + + cumulativeWeight = cumulativeWeight.add(blockWeight); + if (isLogging) + stringBuilder.append(" -> ").append(formatter.format(cumulativeWeight)); + + if (isLogging && blockSummaries.size() > 1) + LOGGER.debug(() -> stringBuilder.toString()); //NOSONAR S1612 (false positive?) + parentHeight = blockSummaryData.getHeight(); parentBlockSignature = blockSummaryData.getSignature(); + + /* Potential future consensus change: only comparing the same number of blocks. + if (parentHeight >= maxHeight) + break; + */ } return cumulativeWeight; diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 8f6ddab7..e631f930 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -531,7 +531,8 @@ public class BlockChain { private static void rebuildBlockchain() throws DataException { // (Re)build repository - RepositoryManager.rebuild(); + if (!RepositoryManager.wasPristineAtOpen()) + RepositoryManager.rebuild(); try (final Repository repository = RepositoryManager.getRepository()) { GenesisBlock genesisBlock = GenesisBlock.getInstance(repository); @@ -567,7 +568,8 @@ public class BlockChain { --height; orphanBlockData = repository.getBlockRepository().fromHeight(height); - Controller.getInstance().onNewBlock(orphanBlockData); + repository.discardChanges(); // clear transaction status to prevent deadlocks + Controller.getInstance().onOrphanedBlock(orphanBlockData); } return true; diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java new file mode 100644 index 00000000..f141abd7 --- /dev/null +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -0,0 +1,77 @@ +package org.qortal.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +public class AtStatesTrimmer implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(AtStatesTrimmer.class); + + @Override + public void run() { + Thread.currentThread().setName("AT States trimmer"); + + try (final Repository repository = RepositoryManager.getRepository()) { + repository.getATRepository().prepareForAtStateTrimming(); + repository.saveChanges(); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(Settings.getInstance().getAtStatesTrimInterval()); + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) + continue; + + long currentTrimmableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); + // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks + long chainTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); + + long upperTrimmableTimestamp = Math.min(currentTrimmableTimestamp, chainTrimmableTimestamp); + int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); + + int trimStartHeight = repository.getATRepository().getAtTrimHeight(); + + int upperBatchHeight = trimStartHeight + Settings.getInstance().getAtStatesTrimBatchSize(); + int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); + + if (trimStartHeight >= upperTrimHeight) + continue; + + int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimStartHeight, upperTrimHeight, Settings.getInstance().getAtStatesTrimLimit()); + repository.saveChanges(); + + if (numAtStatesTrimmed > 0) { + LOGGER.debug(() -> String.format("Trimmed %d AT state%s between blocks %d and %d", + numAtStatesTrimmed, (numAtStatesTrimmed != 1 ? "s" : ""), + trimStartHeight, upperTrimHeight)); + } else { + // Can we move onto next batch? + if (upperTrimmableHeight > upperBatchHeight) { + repository.getATRepository().setAtTrimHeight(upperBatchHeight); + repository.getATRepository().prepareForAtStateTrimming(); + repository.saveChanges(); + + LOGGER.debug(() -> String.format("Bumping AT state trim height to %d", upperBatchHeight)); + } + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to trim AT states: %s", e.getMessage())); + } catch (InterruptedException e) { + // Time to exit + } + } + +} diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index aa80246d..46a29cf9 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -306,8 +306,10 @@ public class BlockMinter extends Thread { } if (newBlockMinted) { - BlockData newBlockData = newBlock.getBlockData(); // Notify Controller and broadcast our new chain to network + BlockData newBlockData = newBlock.getBlockData(); + + repository.discardChanges(); // clear transaction status to prevent deadlocks Controller.getInstance().onNewBlock(newBlockData); Network network = Network.getInstance(); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index c9510742..de1be71e 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -16,16 +16,24 @@ import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bouncycastle.jce.provider.BouncyCastleProvider; @@ -87,6 +95,7 @@ import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.utils.Base58; import org.qortal.utils.ByteArray; +import org.qortal.utils.DaemonThreadFactory; import org.qortal.utils.NTP; import org.qortal.utils.Triple; @@ -134,11 +143,24 @@ public class Controller extends Thread { private ExecutorService callbackExecutor = Executors.newFixedThreadPool(3); private volatile boolean notifyGroupMembershipChange = false; - private volatile BlockData chainTip = null; + private static final int BLOCK_CACHE_SIZE = 10; // To cover typical Synchronizer request + a few spare + /** Latest blocks on our chain. Note: tail/last is the latest block. */ + private final Deque latestBlocks = new LinkedList<>(); + + /** Cache of BlockMessages, indexed by block signature */ + @SuppressWarnings("serial") + private final LinkedHashMap blockMessageCache = new LinkedHashMap<>() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return this.size() > BLOCK_CACHE_SIZE; + } + }; private long repositoryBackupTimestamp = startTime; // ms + private long repositoryCheckpointTimestamp = startTime; // ms private long ntpCheckTimestamp = startTime; // ms private long deleteExpiredTimestamp = startTime + DELETE_EXPIRED_INTERVAL; // ms + private long onlineAccountsTasksTimestamp = startTime + ONLINE_ACCOUNTS_TASKS_INTERVAL; // ms /** Whether we can mint new blocks, as reported by BlockMinter. */ @@ -181,6 +203,47 @@ public class Controller extends Thread { /** Cache of latest blocks' online accounts */ Deque> latestBlocksOnlineAccounts = new ArrayDeque<>(MAX_BLOCKS_CACHED_ONLINE_ACCOUNTS); + // Stats + @XmlAccessorType(XmlAccessType.FIELD) + public static class StatsSnapshot { + public static class GetBlockMessageStats { + public AtomicLong requests = new AtomicLong(); + public AtomicLong cacheHits = new AtomicLong(); + public AtomicLong unknownBlocks = new AtomicLong(); + public AtomicLong cacheFills = new AtomicLong(); + + public GetBlockMessageStats() { + } + } + public GetBlockMessageStats getBlockMessageStats = new GetBlockMessageStats(); + + public static class GetBlockSummariesStats { + public AtomicLong requests = new AtomicLong(); + public AtomicLong cacheHits = new AtomicLong(); + public AtomicLong fullyFromCache = new AtomicLong(); + + public GetBlockSummariesStats() { + } + } + public GetBlockSummariesStats getBlockSummariesStats = new GetBlockSummariesStats(); + + public static class GetBlockSignaturesV2Stats { + public AtomicLong requests = new AtomicLong(); + public AtomicLong cacheHits = new AtomicLong(); + public AtomicLong fullyFromCache = new AtomicLong(); + + public GetBlockSignaturesV2Stats() { + } + } + public GetBlockSignaturesV2Stats getBlockSignaturesV2Stats = new GetBlockSignaturesV2Stats(); + + public AtomicLong latestBlocksCacheRefills = new AtomicLong(); + + public StatsSnapshot() { + } + } + private final StatsSnapshot stats = new StatsSnapshot(); + // Constructors private Controller(String[] args) { @@ -236,21 +299,36 @@ public class Controller extends Thread { /** Returns current blockchain height, or 0 if it's not available. */ public int getChainHeight() { - BlockData blockData = this.chainTip; - if (blockData == null) - return 0; + synchronized (this.latestBlocks) { + BlockData blockData = this.latestBlocks.peekLast(); + if (blockData == null) + return 0; - return blockData.getHeight(); + return blockData.getHeight(); + } } /** Returns highest block, or null if it's not available. */ public BlockData getChainTip() { - return this.chainTip; + synchronized (this.latestBlocks) { + return this.latestBlocks.peekLast(); + } } - /** Cache new blockchain tip. */ - public void setChainTip(BlockData blockData) { - this.chainTip = blockData; + public void refillLatestBlocksCache() throws DataException { + // Set initial chain height/tip + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData blockData = repository.getBlockRepository().getLastBlock(); + + synchronized (this.latestBlocks) { + this.latestBlocks.clear(); + + for (int i = 0; i < BLOCK_CACHE_SIZE && blockData != null; ++i) { + this.latestBlocks.addFirst(blockData); + blockData = repository.getBlockRepository().fromHeight(blockData.getHeight() - 1); + } + } + } } public ReentrantLock getBlockchainLock() { @@ -332,13 +410,8 @@ public class Controller extends Thread { try { BlockChain.validate(); - // Set initial chain height/tip - try (final Repository repository = RepositoryManager.getRepository()) { - BlockData blockData = repository.getBlockRepository().getLastBlock(); - - Controller.getInstance().setChainTip(blockData); - LOGGER.info(String.format("Our chain height at start-up: %d", blockData.getHeight())); - } + Controller.getInstance().refillLatestBlocksCache(); + LOGGER.info(String.format("Our chain height at start-up: %d", Controller.getInstance().getChainHeight())); } catch (DataException e) { LOGGER.error("Couldn't validate blockchain", e); Gui.getInstance().fatalError("Blockchain validation issue", e); @@ -413,6 +486,11 @@ public class Controller extends Thread { Thread.currentThread().setName("Controller"); final long repositoryBackupInterval = Settings.getInstance().getRepositoryBackupInterval(); + final long repositoryCheckpointInterval = Settings.getInstance().getRepositoryCheckpointInterval(); + + ExecutorService trimExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); + trimExecutor.execute(new AtStatesTrimmer()); + trimExecutor.execute(new OnlineAccountsSignaturesTrimmer()); try { while (!isStopping) { @@ -454,6 +532,18 @@ public class Controller extends Thread { final long requestMinimumTimestamp = now - ARBITRARY_REQUEST_TIMEOUT; arbitraryDataRequests.entrySet().removeIf(entry -> entry.getValue().getC() < requestMinimumTimestamp); + // Time to 'checkpoint' uncommitted repository writes? + if (now >= repositoryCheckpointTimestamp + repositoryCheckpointInterval) { + repositoryCheckpointTimestamp = now + repositoryCheckpointInterval; + + if (Settings.getInstance().getShowCheckpointNotification()) + SysTray.getInstance().showMessage(Translator.INSTANCE.translate("SysTray", "DB_CHECKPOINT"), + Translator.INSTANCE.translate("SysTray", "PERFORMING_DB_CHECKPOINT"), + MessageType.INFO); + + RepositoryManager.checkpoint(true); + } + // Give repository a chance to backup (if enabled) if (repositoryBackupInterval > 0 && now >= repositoryBackupTimestamp + repositoryBackupInterval) { repositoryBackupTimestamp = now + repositoryBackupInterval; @@ -486,7 +576,17 @@ public class Controller extends Thread { } } } catch (InterruptedException e) { + // Clear interrupted flag so we can shutdown trim threads + Thread.interrupted(); // Fall-through to exit + } finally { + trimExecutor.shutdownNow(); + + try { + trimExecutor.awaitTermination(2L, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // We tried... + } } } @@ -556,9 +656,10 @@ public class Controller extends Thread { public SynchronizationResult actuallySynchronize(Peer peer, boolean force) throws InterruptedException { boolean hasStatusChanged = false; + BlockData priorChainTip = this.getChainTip(); synchronized (this.syncLock) { - this.syncPercent = (this.chainTip.getHeight() * 100) / peer.getChainTipData().getLastHeight(); + this.syncPercent = (priorChainTip.getHeight() * 100) / peer.getChainTipData().getLastHeight(); // Only update SysTray if we're potentially changing height if (this.syncPercent < 100) { @@ -570,8 +671,6 @@ public class Controller extends Thread { if (hasStatusChanged) updateSysTray(); - BlockData priorChainTip = this.chainTip; - try { SynchronizationResult syncResult = Synchronizer.getInstance().synchronize(peer, force); switch (syncResult) { @@ -640,9 +739,6 @@ public class Controller extends Thread { // Reset our cache of inferior chains inferiorChainSignatures.clear(); - // Update chain-tip, systray, notify peers, websockets, etc. - this.onNewBlock(newChainTip); - Network network = Network.getInstance(); network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newChainTip)); } @@ -827,15 +923,109 @@ public class Controller extends Thread { } } + /** + * Callback for when we've received a new block. + *

+ * See WARNING for {@link EventBus#notify(Event)} + * to prevent deadlocks. + */ public void onNewBlock(BlockData latestBlockData) { // Protective copy BlockData blockDataCopy = new BlockData(latestBlockData); - this.setChainTip(blockDataCopy); + synchronized (this.latestBlocks) { + BlockData cachedChainTip = this.latestBlocks.peekLast(); + + if (cachedChainTip != null && Arrays.equals(cachedChainTip.getSignature(), blockDataCopy.getReference())) { + // Chain tip is parent for new latest block, so we can safely add new latest block + this.latestBlocks.addLast(latestBlockData); + + // Trim if necessary + if (this.latestBlocks.size() >= BLOCK_CACHE_SIZE) + this.latestBlocks.pollFirst(); + } else { + if (cachedChainTip != null) + // Chain tip didn't match - potentially abnormal behaviour? + LOGGER.debug(() -> String.format("Cached chain tip %.8s not parent for new latest block %.8s (reference %.8s)", + Base58.encode(cachedChainTip.getSignature()), + Base58.encode(blockDataCopy.getSignature()), + Base58.encode(blockDataCopy.getReference()))); + + // Defensively rebuild cache + try { + this.stats.latestBlocksCacheRefills.incrementAndGet(); + + this.refillLatestBlocksCache(); + } catch (DataException e) { + LOGGER.warn(() -> "Couldn't refill latest blocks cache?", e); + } + } + } + + this.onNewOrOrphanedBlock(blockDataCopy, NewBlockEvent::new); + } + + public static class OrphanedBlockEvent implements Event { + private final BlockData blockData; + + public OrphanedBlockEvent(BlockData blockData) { + this.blockData = blockData; + } + + public BlockData getBlockData() { + return this.blockData; + } + } + + /** + * Callback for when we've orphaned a block. + *

+ * See WARNING for {@link EventBus#notify(Event)} + * to prevent deadlocks. + */ + public void onOrphanedBlock(BlockData latestBlockData) { + // Protective copy + BlockData blockDataCopy = new BlockData(latestBlockData); + + synchronized (this.latestBlocks) { + BlockData cachedChainTip = this.latestBlocks.pollLast(); + boolean refillNeeded = false; + + if (cachedChainTip != null && Arrays.equals(cachedChainTip.getReference(), blockDataCopy.getSignature())) { + // Chain tip was parent for new latest block that has been orphaned, so we're good + + // However, if we've emptied the cache then we will need to refill it + refillNeeded = this.latestBlocks.isEmpty(); + } else { + if (cachedChainTip != null) + // Chain tip didn't match - potentially abnormal behaviour? + LOGGER.debug(() -> String.format("Cached chain tip %.8s (reference %.8s) was not parent for new latest block %.8s", + Base58.encode(cachedChainTip.getSignature()), + Base58.encode(cachedChainTip.getReference()), + Base58.encode(blockDataCopy.getSignature()))); + + // Defensively rebuild cache + refillNeeded = true; + } + + if (refillNeeded) + try { + this.stats.latestBlocksCacheRefills.incrementAndGet(); + + this.refillLatestBlocksCache(); + } catch (DataException e) { + LOGGER.warn(() -> "Couldn't refill latest blocks cache?", e); + } + } + + this.onNewOrOrphanedBlock(blockDataCopy, OrphanedBlockEvent::new); + } + + private void onNewOrOrphanedBlock(BlockData blockDataCopy, Function eventConstructor) { requestSysTrayUpdate = true; // Notify listeners, trade-bot, etc. - EventBus.INSTANCE.notify(new NewBlockEvent(blockDataCopy)); + EventBus.INSTANCE.notify(eventConstructor.apply(blockDataCopy)); if (this.notifyGroupMembershipChange) { this.notifyGroupMembershipChange = false; @@ -935,11 +1125,31 @@ public class Controller extends Thread { private void onNetworkGetBlockMessage(Peer peer, Message message) { GetBlockMessage getBlockMessage = (GetBlockMessage) message; byte[] signature = getBlockMessage.getSignature(); + this.stats.getBlockMessageStats.requests.incrementAndGet(); + + ByteArray signatureAsByteArray = new ByteArray(signature); + + BlockMessage cachedBlockMessage = this.blockMessageCache.get(signatureAsByteArray); + + // Check cached latest block message + if (cachedBlockMessage != null) { + this.stats.getBlockMessageStats.cacheHits.incrementAndGet(); + + // We need to duplicate it to prevent multiple threads setting ID on the same message + BlockMessage clonedBlockMessage = cachedBlockMessage.cloneWithNewId(message.getId()); + + if (!peer.sendMessage(clonedBlockMessage)) + peer.disconnect("failed to send block"); + + return; + } try (final Repository repository = RepositoryManager.getRepository()) { BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData == null) { // We don't have this block + this.stats.getBlockMessageStats.unknownBlocks.getAndIncrement(); // 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))); @@ -954,10 +1164,19 @@ public class Controller extends Thread { Block block = new Block(repository, blockData); - Message blockMessage = new BlockMessage(block); + BlockMessage blockMessage = new BlockMessage(block); blockMessage.setId(message.getId()); + + // This call also causes the other needed data to be pulled in from repository if (!peer.sendMessage(blockMessage)) peer.disconnect("failed to send block"); + + // If request is for a recent block, cache it + if (getChainHeight() - blockData.getHeight() <= BLOCK_CACHE_SIZE) { + this.stats.getBlockMessageStats.cacheFills.incrementAndGet(); + + this.blockMessageCache.put(new ByteArray(blockData.getSignature()), blockMessage); + } } catch (DataException e) { LOGGER.error(String.format("Repository issue while send block %s to peer %s", Base58.encode(signature), peer), e); } @@ -1004,59 +1223,110 @@ public class Controller extends Thread { private void onNetworkGetBlockSummariesMessage(Peer peer, Message message) { GetBlockSummariesMessage getBlockSummariesMessage = (GetBlockSummariesMessage) message; - byte[] parentSignature = getBlockSummariesMessage.getParentSignature(); + final byte[] parentSignature = getBlockSummariesMessage.getParentSignature(); + this.stats.getBlockSummariesStats.requests.incrementAndGet(); - try (final Repository repository = RepositoryManager.getRepository()) { - List blockSummaries = new ArrayList<>(); - - int numberRequested = Math.min(Network.MAX_BLOCK_SUMMARIES_PER_REPLY, getBlockSummariesMessage.getNumberRequested()); - - do { - BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); - - if (blockData == null) - // No more blocks to send to peer - break; - - BlockSummaryData blockSummary = new BlockSummaryData(blockData); - blockSummaries.add(blockSummary); - parentSignature = blockData.getSignature(); - } while (blockSummaries.size() < numberRequested); - - Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); + // If peer's parent signature matches our latest block signature + // then we can short-circuit with an empty response + BlockData chainTip = getChainTip(); + if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { + Message blockSummariesMessage = new BlockSummariesMessage(Collections.emptyList()); blockSummariesMessage.setId(message.getId()); if (!peer.sendMessage(blockSummariesMessage)) peer.disconnect("failed to send block summaries"); - } catch (DataException e) { - LOGGER.error(String.format("Repository issue while sending block summaries after %s to peer %s", Base58.encode(parentSignature), peer), e); + + return; } + + List blockSummaries = new ArrayList<>(); + + // Attempt to serve from our cache of latest blocks + synchronized (this.latestBlocks) { + blockSummaries = this.latestBlocks.stream() + .dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature)) + .map(BlockSummaryData::new) + .collect(Collectors.toList()); + } + + if (blockSummaries.isEmpty()) { + try (final Repository repository = RepositoryManager.getRepository()) { + int numberRequested = Math.min(Network.MAX_BLOCK_SUMMARIES_PER_REPLY, getBlockSummariesMessage.getNumberRequested()); + + BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); + + while (blockData != null && blockSummaries.size() < numberRequested) { + BlockSummaryData blockSummary = new BlockSummaryData(blockData); + blockSummaries.add(blockSummary); + + blockData = repository.getBlockRepository().fromReference(blockData.getSignature()); + } + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending block summaries after %s to peer %s", Base58.encode(parentSignature), peer), e); + } + } else { + this.stats.getBlockSummariesStats.cacheHits.incrementAndGet(); + + if (blockSummaries.size() >= getBlockSummariesMessage.getNumberRequested()) + this.stats.getBlockSummariesStats.fullyFromCache.incrementAndGet(); + } + + Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); + blockSummariesMessage.setId(message.getId()); + if (!peer.sendMessage(blockSummariesMessage)) + peer.disconnect("failed to send block summaries"); } private void onNetworkGetSignaturesV2Message(Peer peer, Message message) { GetSignaturesV2Message getSignaturesMessage = (GetSignaturesV2Message) message; - byte[] parentSignature = getSignaturesMessage.getParentSignature(); + final byte[] parentSignature = getSignaturesMessage.getParentSignature(); + this.stats.getBlockSignaturesV2Stats.requests.incrementAndGet(); - try (final Repository repository = RepositoryManager.getRepository()) { - List signatures = new ArrayList<>(); - - do { - BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); - - if (blockData == null) - // No more signatures to send to peer - break; - - parentSignature = blockData.getSignature(); - signatures.add(parentSignature); - } while (signatures.size() < getSignaturesMessage.getNumberRequested()); - - Message signaturesMessage = new SignaturesMessage(signatures); + // If peer's parent signature matches our latest block signature + // then we can short-circuit with an empty response + BlockData chainTip = getChainTip(); + if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { + Message signaturesMessage = new SignaturesMessage(Collections.emptyList()); signaturesMessage.setId(message.getId()); if (!peer.sendMessage(signaturesMessage)) peer.disconnect("failed to send signatures (v2)"); - } catch (DataException e) { - LOGGER.error(String.format("Repository issue while sending V2 signatures after %s to peer %s", Base58.encode(parentSignature), peer), e); + + return; } + + List signatures = new ArrayList<>(); + + // Attempt to serve from our cache of latest blocks + synchronized (this.latestBlocks) { + signatures = this.latestBlocks.stream() + .dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature)) + .map(BlockData::getSignature) + .collect(Collectors.toList()); + } + + if (signatures.isEmpty()) { + try (final Repository repository = RepositoryManager.getRepository()) { + int numberRequested = getSignaturesMessage.getNumberRequested(); + BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); + + while (blockData != null && signatures.size() < numberRequested) { + signatures.add(blockData.getSignature()); + + blockData = repository.getBlockRepository().fromReference(blockData.getSignature()); + } + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending V2 signatures after %s to peer %s", Base58.encode(parentSignature), peer), e); + } + } else { + this.stats.getBlockSignaturesV2Stats.cacheHits.incrementAndGet(); + + if (signatures.size() >= getSignaturesMessage.getNumberRequested()) + this.stats.getBlockSignaturesV2Stats.fullyFromCache.incrementAndGet(); + } + + Message signaturesMessage = new SignaturesMessage(signatures); + signaturesMessage.setId(message.getId()); + if (!peer.sendMessage(signaturesMessage)) + peer.disconnect("failed to send signatures (v2)"); } private void onNetworkHeightV2Message(Peer peer, Message message) { @@ -1406,26 +1676,6 @@ public class Controller extends Thread { // Refresh our online accounts signatures? sendOurOnlineAccountsInfo(); - - // Trim blockchain by removing 'old' online accounts signatures - long upperMintedTimestamp = now - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); - trimOldOnlineAccountsSignatures(upperMintedTimestamp); - } - - private void trimOldOnlineAccountsSignatures(long upperMintedTimestamp) { - try (final Repository repository = RepositoryManager.tryRepository()) { - if (repository == null) - return; - - int numBlocksTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(upperMintedTimestamp); - - if (numBlocksTrimmed > 0) - LOGGER.debug(() -> String.format("Trimmed old online accounts signatures from %d block%s", numBlocksTrimmed, (numBlocksTrimmed != 1 ? "s" : ""))); - - repository.saveChanges(); - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue trying to trim old online accounts signatures: %s", e.getMessage())); - } } private void sendOurOnlineAccountsInfo() { @@ -1687,4 +1937,8 @@ public class Controller extends Thread { return now - offset; } + public StatsSnapshot getStatsSnapshot() { + return this.stats; + } + } diff --git a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java new file mode 100644 index 00000000..e9b846fc --- /dev/null +++ b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java @@ -0,0 +1,75 @@ +package org.qortal.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.block.BlockChain; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +public class OnlineAccountsSignaturesTrimmer implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(OnlineAccountsSignaturesTrimmer.class); + + private static final long INITIAL_SLEEP_PERIOD = 5 * 60 * 1000L + 1234L; // ms + + public void run() { + Thread.currentThread().setName("Online Accounts trimmer"); + + try (final Repository repository = RepositoryManager.getRepository()) { + // Don't even start trimming until initial rush has ended + Thread.sleep(INITIAL_SLEEP_PERIOD); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(Settings.getInstance().getOnlineSignaturesTrimInterval()); + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) + continue; + + // Trim blockchain by removing 'old' online accounts signatures + long upperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); + int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); + + int trimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + + int upperBatchHeight = trimStartHeight + Settings.getInstance().getOnlineSignaturesTrimBatchSize(); + int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); + + if (trimStartHeight >= upperTrimHeight) + continue; + + int numSigsTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(trimStartHeight, upperTrimHeight); + repository.saveChanges(); + + if (numSigsTrimmed > 0) { + LOGGER.debug(() -> String.format("Trimmed %d online accounts signature%s between blocks %d and %d", + numSigsTrimmed, (numSigsTrimmed != 1 ? "s" : ""), + trimStartHeight, upperTrimHeight)); + } else { + // Can we move onto next batch? + if (upperTrimmableHeight > upperBatchHeight) { + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(upperBatchHeight); + repository.saveChanges(); + + LOGGER.debug(() -> String.format("Bumping online accounts signatures trim height to %d", upperBatchHeight)); + } + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to trim online accounts signatures: %s", e.getMessage())); + } catch (InterruptedException e) { + // Time to exit + } + } + +} diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 8dca5b05..06850a1b 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -175,7 +175,7 @@ public class Synchronizer { * @throws DataException * @throws InterruptedException */ - private SynchronizationResult fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight, boolean force, List blockSummariesFromCommon) throws DataException, InterruptedException { + public SynchronizationResult fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight, boolean force, List blockSummariesFromCommon) throws DataException, InterruptedException { // Start by asking for a few recent block hashes as this will cover a majority of reorgs // Failing that, back off exponentially int step = INITIAL_BLOCK_STEP; @@ -313,18 +313,21 @@ public class Synchronizer { List ourBlockSummaries = repository.getBlockRepository().getBlockSummaries(commonBlockHeight + 1, ourLatestBlockData.getHeight()); // Populate minter account levels for both lists of block summaries - populateBlockSummariesMinterLevels(repository, peerBlockSummaries); populateBlockSummariesMinterLevels(repository, ourBlockSummaries); + populateBlockSummariesMinterLevels(repository, peerBlockSummaries); + + final int mutualHeight = commonBlockHeight - 1 + Math.min(ourBlockSummaries.size(), peerBlockSummaries.size()); // Calculate cumulative chain weights of both blockchain subsets, from common block to highest mutual block. - BigInteger ourChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, ourBlockSummaries); - BigInteger peerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, peerBlockSummaries); + BigInteger ourChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, ourBlockSummaries, mutualHeight); + BigInteger peerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, peerBlockSummaries, mutualHeight); + + NumberFormat formatter = new DecimalFormat("0.###E0"); + LOGGER.debug(String.format("Our chain weight: %s, peer's chain weight: %s (higher is better)", formatter.format(ourChainWeight), formatter.format(peerChainWeight))); // If our blockchain has greater weight then don't synchronize with peer if (ourChainWeight.compareTo(peerChainWeight) >= 0) { LOGGER.debug(String.format("Not synchronizing with peer %s as we have better blockchain", peer)); - NumberFormat formatter = new DecimalFormat("0.###E0"); - LOGGER.debug(String.format("Our chain weight: %s, peer's chain weight: %s (higher is better)", formatter.format(ourChainWeight), formatter.format(peerChainWeight))); return SynchronizationResult.INFERIOR_CHAIN; } } @@ -405,12 +408,15 @@ public class Synchronizer { Block block = new Block(repository, orphanBlockData); block.orphan(); + LOGGER.trace(String.format("Orphaned block height %d, sig %.8s", ourHeight, Base58.encode(orphanBlockData.getSignature()))); + repository.saveChanges(); --ourHeight; orphanBlockData = repository.getBlockRepository().fromHeight(ourHeight); - Controller.getInstance().onNewBlock(orphanBlockData); + repository.discardChanges(); // clear transaction status to prevent deadlocks + Controller.getInstance().onOrphanedBlock(orphanBlockData); } LOGGER.debug(String.format("Orphaned blocks back to height %d, sig %.8s - applying new blocks from peer %s", commonBlockHeight, commonBlockSig58, peer)); @@ -431,6 +437,8 @@ public class Synchronizer { newBlock.process(); + LOGGER.trace(String.format("Processed block height %d, sig %.8s", newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getBlockData().getSignature()))); + repository.saveChanges(); Controller.getInstance().onNewBlock(newBlock.getBlockData()); @@ -513,6 +521,8 @@ public class Synchronizer { newBlock.process(); + LOGGER.trace(String.format("Processed block height %d, sig %.8s", newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getBlockData().getSignature()))); + repository.saveChanges(); Controller.getInstance().onNewBlock(newBlock.getBlockData()); diff --git a/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java index ba173f43..d708f91c 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java +++ b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java @@ -610,38 +610,26 @@ public class BitcoinACCTv1 implements ACCT { /** * Returns CrossChainTradeData with useful info extracted from AT. - * - * @param repository - * @param atAddress - * @throws DataException */ @Override public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); - return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); } /** * Returns CrossChainTradeData with useful info extracted from AT. - * - * @param repository - * @param atAddress - * @throws DataException */ @Override public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException { - byte[] creatorPublicKey = repository.getATRepository().getCreatorPublicKey(atStateData.getATAddress()); - return populateTradeData(repository, creatorPublicKey, atStateData); + ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); } /** * Returns CrossChainTradeData with useful info extracted from AT. - * - * @param repository - * @param atAddress - * @throws DataException */ - public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, ATStateData atStateData) throws DataException { + public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException { byte[] addressBytes = new byte[25]; // for general use String atAddress = atStateData.getATAddress(); @@ -649,7 +637,7 @@ public class BitcoinACCTv1 implements ACCT { tradeData.qortalAtAddress = atAddress; tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); - tradeData.creationTimestamp = atStateData.getCreation(); + tradeData.creationTimestamp = creationTimestamp; Account atAccount = new Account(repository, atAddress); tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); diff --git a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java index 04f8fa72..082c78e9 100644 --- a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java +++ b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java @@ -561,38 +561,26 @@ public class LitecoinACCTv1 implements ACCT { /** * Returns CrossChainTradeData with useful info extracted from AT. - * - * @param repository - * @param atAddress - * @throws DataException */ @Override public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); - return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); } /** * Returns CrossChainTradeData with useful info extracted from AT. - * - * @param repository - * @param atAddress - * @throws DataException */ @Override public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException { - byte[] creatorPublicKey = repository.getATRepository().getCreatorPublicKey(atStateData.getATAddress()); - return populateTradeData(repository, creatorPublicKey, atStateData); + ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); } /** * Returns CrossChainTradeData with useful info extracted from AT. - * - * @param repository - * @param atAddress - * @throws DataException */ - public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, ATStateData atStateData) throws DataException { + public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException { byte[] addressBytes = new byte[25]; // for general use String atAddress = atStateData.getATAddress(); @@ -600,7 +588,7 @@ public class LitecoinACCTv1 implements ACCT { tradeData.qortalAtAddress = atAddress; tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); - tradeData.creationTimestamp = atStateData.getCreation(); + tradeData.creationTimestamp = creationTimestamp; Account atAccount = new Account(repository, atAddress); tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); diff --git a/src/main/java/org/qortal/crypto/Crypto.java b/src/main/java/org/qortal/crypto/Crypto.java index a21ac594..49cdd2fb 100644 --- a/src/main/java/org/qortal/crypto/Crypto.java +++ b/src/main/java/org/qortal/crypto/Crypto.java @@ -1,5 +1,6 @@ package org.qortal.crypto; +import java.nio.ByteBuffer; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; @@ -42,6 +43,27 @@ public abstract class Crypto { } } + /** + * Returns 32-byte SHA-256 digest of message passed in input. + * + * @param input + * variable-length byte[] message + * @return byte[32] digest, or null if SHA-256 algorithm can't be accessed + */ + public static byte[] digest(ByteBuffer input) { + if (input == null) + return null; + + try { + // SHA2-256 + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(input); + return sha256.digest(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 message digest not available"); + } + } + /** * Returns 32-byte digest of two rounds of SHA-256 on message passed in input. * diff --git a/src/main/java/org/qortal/data/at/ATStateData.java b/src/main/java/org/qortal/data/at/ATStateData.java index b8c13e0d..e689f5ae 100644 --- a/src/main/java/org/qortal/data/at/ATStateData.java +++ b/src/main/java/org/qortal/data/at/ATStateData.java @@ -5,7 +5,6 @@ public class ATStateData { // Properties private String ATAddress; private Integer height; - private Long creation; private byte[] stateData; private byte[] stateHash; private Long fees; @@ -14,10 +13,9 @@ public class ATStateData { // Constructors /** Create new ATStateData */ - public ATStateData(String ATAddress, Integer height, Long creation, byte[] stateData, byte[] stateHash, Long fees, boolean isInitial) { + public ATStateData(String ATAddress, Integer height, byte[] stateData, byte[] stateHash, Long fees, boolean isInitial) { this.ATAddress = ATAddress; this.height = height; - this.creation = creation; this.stateData = stateData; this.stateHash = stateHash; this.fees = fees; @@ -26,21 +24,21 @@ public class ATStateData { /** For recreating per-block ATStateData from repository where not all info is needed */ public ATStateData(String ATAddress, int height, byte[] stateHash, Long fees, boolean isInitial) { - this(ATAddress, height, null, null, stateHash, fees, isInitial); + this(ATAddress, height, null, stateHash, fees, isInitial); } /** For creating ATStateData from serialized bytes when we don't have all the info */ public ATStateData(String ATAddress, byte[] stateHash) { // This won't ever be initial AT state from deployment as that's never serialized over the network, // but generated when the DeployAtTransaction is processed locally. - this(ATAddress, null, null, null, stateHash, null, false); + this(ATAddress, null, null, stateHash, null, false); } /** For creating ATStateData from serialized bytes when we don't have all the info */ public ATStateData(String ATAddress, byte[] stateHash, Long fees) { // This won't ever be initial AT state from deployment as that's never serialized over the network, // but generated when the DeployAtTransaction is processed locally. - this(ATAddress, null, null, null, stateHash, fees, false); + this(ATAddress, null, null, stateHash, fees, false); } // Getters / setters @@ -58,10 +56,6 @@ public class ATStateData { this.height = height; } - public Long getCreation() { - return this.creation; - } - public byte[] getStateData() { return this.stateData; } diff --git a/src/main/java/org/qortal/event/EventBus.java b/src/main/java/org/qortal/event/EventBus.java index e0014a20..63c80143 100644 --- a/src/main/java/org/qortal/event/EventBus.java +++ b/src/main/java/org/qortal/event/EventBus.java @@ -20,6 +20,21 @@ public enum EventBus { } } + /** + * WARNING: before calling this method, + * make sure repository holds no locks, e.g. by calling + * repository.discardChanges(). + *

+ * This is because event listeners might open a new + * repository session which will deadlock HSQLDB + * if it tries to CHECKPOINT. + *

+ * The HSQLDB deadlock occurs because the caller's + * repository session blocks the CHECKPOINT until + * their transaction is closed, yet event listeners + * new sessions are blocked until CHECKPOINT is + * completed, hence deadlock. + */ public void notify(Event event) { List clonedListeners; diff --git a/src/main/java/org/qortal/globalization/Translator.java b/src/main/java/org/qortal/globalization/Translator.java index 8f0b6136..6481dde7 100644 --- a/src/main/java/org/qortal/globalization/Translator.java +++ b/src/main/java/org/qortal/globalization/Translator.java @@ -10,12 +10,12 @@ import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.settings.Settings; public enum Translator { INSTANCE; private static final Logger LOGGER = LogManager.getLogger(Translator.class); - private static final String DEFAULT_LANG = Locale.getDefault().getLanguage(); private static final Map resourceBundles = new HashMap<>(); @@ -34,7 +34,7 @@ public enum Translator { } public String translate(String className, String key) { - return this.translate(className, DEFAULT_LANG, key); + return this.translate(className, Settings.getInstance().getLocaleLang(), key); } public Set keySet(String className, String lang) { diff --git a/src/main/java/org/qortal/network/Peer.java b/src/main/java/org/qortal/network/Peer.java index 9c8ce8a8..84f29ac9 100644 --- a/src/main/java/org/qortal/network/Peer.java +++ b/src/main/java/org/qortal/network/Peer.java @@ -33,6 +33,7 @@ import org.qortal.settings.Settings; import org.qortal.utils.ExecuteProduceConsume; import org.qortal.utils.NTP; +import com.google.common.hash.HashCode; import com.google.common.net.HostAndPort; import com.google.common.net.InetAddresses; @@ -348,21 +349,37 @@ public class Peer { if (this.byteBuffer == null) this.byteBuffer = ByteBuffer.allocate(Network.getInstance().getMaxMessageSize()); + final int priorPosition = this.byteBuffer.position(); final int bytesRead = this.socketChannel.read(this.byteBuffer); if (bytesRead == -1) { this.disconnect("EOF"); return; } - LOGGER.trace(() -> String.format("Received %d bytes from peer %s", bytesRead, this)); + LOGGER.trace(() -> { + if (bytesRead > 0) { + byte[] leadingBytes = new byte[Math.min(bytesRead, 8)]; + this.byteBuffer.asReadOnlyBuffer().position(priorPosition).get(leadingBytes); + String leadingHex = HashCode.fromBytes(leadingBytes).toString(); + + return String.format("Received %d bytes, starting %s, into byteBuffer[%d] from peer %s", + bytesRead, + leadingHex, + priorPosition, + this); + } else { + return String.format("Received %d bytes into byteBuffer[%d] from peer %s", bytesRead, priorPosition, this); + } + }); final boolean wasByteBufferFull = !this.byteBuffer.hasRemaining(); while (true) { final Message message; // Can we build a message from buffer now? + ByteBuffer readOnlyBuffer = this.byteBuffer.asReadOnlyBuffer().flip(); try { - message = Message.fromByteBuffer(this.byteBuffer); + message = Message.fromByteBuffer(readOnlyBuffer); } catch (MessageException e) { LOGGER.debug(String.format("%s, from peer %s", e.getMessage(), this)); this.disconnect(e.getMessage()); @@ -387,6 +404,13 @@ public class Peer { LOGGER.trace(() -> String.format("Received %s message with ID %d from peer %s", message.getType().name(), message.getId(), this)); + // Tidy up buffers: + this.byteBuffer.flip(); + // Read-only, flipped buffer's position will be after end of message, so copy that + this.byteBuffer.position(readOnlyBuffer.position()); + // Copy bytes after read message to front of buffer, adjusting position accordingly, reset limit to capacity + this.byteBuffer.compact(); + BlockingQueue queue = this.replyQueues.get(message.getId()); if (queue != null) { // Adding message to queue will unblock thread waiting for response @@ -399,7 +423,7 @@ public class Peer { // Add message to pending queue if (!this.pendingMessages.offer(message)) { - LOGGER.info(String.format("No room to queue message from peer %s - discarding", this)); + LOGGER.info(() -> String.format("No room to queue message from peer %s - discarding", this)); return; } @@ -454,10 +478,24 @@ public class Peer { while (outputBuffer.hasRemaining()) { int bytesWritten = this.socketChannel.write(outputBuffer); + LOGGER.trace(() -> String.format("Sent %d bytes of %s message with ID %d to peer %s", + bytesWritten, + message.getType().name(), + message.getId(), + this)); + if (bytesWritten == 0) // Underlying socket's internal buffer probably full, // so wait a short while for bytes to actually be transmitted over the wire - this.socketChannel.wait(1L); + + /* + * NOSONAR squid:S2276 - we don't want to use this.socketChannel.wait() + * as this releases the lock held by synchronized() above + * and would allow another thread to send another message, + * potentially interleaving them on-the-wire, causing checksum failures + * and connection loss. + */ + Thread.sleep(1L); //NOSONAR squid:S2276 } } } catch (MessageException e) { diff --git a/src/main/java/org/qortal/network/message/BlockMessage.java b/src/main/java/org/qortal/network/message/BlockMessage.java index 8ca86ee6..b07dc8b1 100644 --- a/src/main/java/org/qortal/network/message/BlockMessage.java +++ b/src/main/java/org/qortal/network/message/BlockMessage.java @@ -34,6 +34,7 @@ public class BlockMessage extends Message { super(MessageType.BLOCK); this.block = block; + this.blockData = block.getBlockData(); this.height = block.getBlockData().getHeight(); } @@ -93,4 +94,10 @@ public class BlockMessage extends Message { } } + public BlockMessage cloneWithNewId(int newId) { + BlockMessage clone = new BlockMessage(this.block); + clone.setId(newId); + return clone; + } + } diff --git a/src/main/java/org/qortal/network/message/Message.java b/src/main/java/org/qortal/network/message/Message.java index 9dfdc6bc..cc90fe81 100644 --- a/src/main/java/org/qortal/network/message/Message.java +++ b/src/main/java/org/qortal/network/message/Message.java @@ -160,80 +160,72 @@ public abstract class Message { /** * Attempt to read a message from byte buffer. * - * @param byteBuffer + * @param readOnlyBuffer * @return null if no complete message can be read * @throws MessageException */ - public static Message fromByteBuffer(ByteBuffer byteBuffer) throws MessageException { + public static Message fromByteBuffer(ByteBuffer readOnlyBuffer) throws MessageException { try { - byteBuffer.flip(); - - ByteBuffer readBuffer = byteBuffer.asReadOnlyBuffer(); - // Read only enough bytes to cover Message "magic" preamble byte[] messageMagic = new byte[MAGIC_LENGTH]; - readBuffer.get(messageMagic); + readOnlyBuffer.get(messageMagic); if (!Arrays.equals(messageMagic, Network.getInstance().getMessageMagic())) // Didn't receive correct Message "magic" throw new MessageException("Received incorrect message 'magic'"); // Find supporting object - int typeValue = readBuffer.getInt(); + int typeValue = readOnlyBuffer.getInt(); MessageType messageType = MessageType.valueOf(typeValue); if (messageType == null) // Unrecognised message type throw new MessageException(String.format("Received unknown message type [%d]", typeValue)); // Optional message ID - byte hasId = readBuffer.get(); + byte hasId = readOnlyBuffer.get(); int id = -1; if (hasId != 0) { - id = readBuffer.getInt(); + id = readOnlyBuffer.getInt(); if (id <= 0) // Invalid ID throw new MessageException("Invalid negative ID"); } - int dataSize = readBuffer.getInt(); + int dataSize = readOnlyBuffer.getInt(); if (dataSize > MAX_DATA_SIZE) // Too large throw new MessageException(String.format("Declared data length %d larger than max allowed %d", dataSize, MAX_DATA_SIZE)); + // Don't have all the data yet? + if (dataSize > 0 && dataSize + CHECKSUM_LENGTH > readOnlyBuffer.remaining()) + return null; + ByteBuffer dataSlice = null; if (dataSize > 0) { byte[] expectedChecksum = new byte[CHECKSUM_LENGTH]; - readBuffer.get(expectedChecksum); + readOnlyBuffer.get(expectedChecksum); - // Remember this position in readBuffer so we can pass to Message subclass - dataSlice = readBuffer.slice(); - - // Consume data from buffer - byte[] data = new byte[dataSize]; - readBuffer.get(data); - - // We successfully read all the data bytes, so we can set limit on dataSlice + // Slice data in readBuffer so we can pass to Message subclass + dataSlice = readOnlyBuffer.slice(); dataSlice.limit(dataSize); // Test checksum - byte[] actualChecksum = generateChecksum(data); + byte[] actualChecksum = generateChecksum(dataSlice); if (!Arrays.equals(expectedChecksum, actualChecksum)) throw new MessageException("Message checksum incorrect"); + + // Reset position after being consumed by generateChecksum + dataSlice.position(0); + // Update position in readOnlyBuffer + readOnlyBuffer.position(readOnlyBuffer.position() + dataSize); } - Message message = messageType.fromByteBuffer(id, dataSlice); - - // We successfully read a message, so bump byteBuffer's position to reflect this - byteBuffer.position(readBuffer.position()); - - return message; + return messageType.fromByteBuffer(id, dataSlice); } catch (BufferUnderflowException e) { // Not enough bytes to fully decode message... return null; - } finally { - byteBuffer.compact(); } } @@ -241,6 +233,10 @@ public abstract class Message { return Arrays.copyOfRange(Crypto.digest(data), 0, CHECKSUM_LENGTH); } + protected static byte[] generateChecksum(ByteBuffer dataBuffer) { + return Arrays.copyOfRange(Crypto.digest(dataBuffer), 0, CHECKSUM_LENGTH); + } + public byte[] toBytes() throws MessageException { try { ByteArrayOutputStream bytes = new ByteArrayOutputStream(256); diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index f3c2b16d..b21a4909 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -87,6 +87,21 @@ public interface ATRepository { */ public List getBlockATStatesAtHeight(int height) throws DataException; + /** Returns height of first trimmable AT state. */ + public int getAtTrimHeight() throws DataException; + + /** Sets new base height for AT state trimming. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ + public void setAtTrimHeight(int trimHeight) throws DataException; + + /** Hook to allow repository to prepare/cache info for AT state trimming. */ + public void prepareForAtStateTrimming() throws DataException; + + /** Trims full AT state data between passed heights. Returns number of trimmed rows. */ + public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException; + /** * Save ATStateData into repository. *

diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index 4265b71f..937607cf 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -143,13 +143,21 @@ public interface BlockRepository { */ public List getBlockInfos(Integer startHeight, Integer endHeight, Integer count) throws DataException; + /** Returns height of first trimmable online accounts signatures. */ + public int getOnlineAccountsSignaturesTrimHeight() throws DataException; + + /** Sets new base height for trimming online accounts signatures. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ + public void setOnlineAccountsSignaturesTrimHeight(int trimHeight) throws DataException; + /** - * Trim online accounts signatures from blocks older than passed timestamp. + * Trim online accounts signatures from blocks between passed heights. * - * @param timestamp * @return number of blocks trimmed */ - public int trimOldOnlineAccountsSignatures(long timestamp) throws DataException; + public int trimOldOnlineAccountsSignatures(int minHeight, int maxHeight) throws DataException; /** * Returns first (lowest height) block that doesn't link back to specified block. diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index cc3a5336..527b23f3 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -47,6 +47,12 @@ public interface Repository extends AutoCloseable { public void backup(boolean quick) throws DataException; + public void checkpoint(boolean quick) throws DataException; + public void performPeriodicMaintenance() throws DataException; + public void exportNodeLocalData() throws DataException; + + public void importDataFromFile(String filename) throws DataException; + } diff --git a/src/main/java/org/qortal/repository/RepositoryFactory.java b/src/main/java/org/qortal/repository/RepositoryFactory.java index 94c9627e..e5b29d1b 100644 --- a/src/main/java/org/qortal/repository/RepositoryFactory.java +++ b/src/main/java/org/qortal/repository/RepositoryFactory.java @@ -2,6 +2,8 @@ package org.qortal.repository; public interface RepositoryFactory { + public boolean wasPristineAtOpen(); + public RepositoryFactory reopen() throws DataException; public Repository getRepository() throws DataException; diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index e375be96..9f5cf239 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -8,6 +8,13 @@ public abstract class RepositoryManager { repositoryFactory = newRepositoryFactory; } + public static boolean wasPristineAtOpen() throws DataException { + if (repositoryFactory == null) + throw new DataException("No repository available"); + + return repositoryFactory.wasPristineAtOpen(); + } + public static Repository getRepository() throws DataException { if (repositoryFactory == null) throw new DataException("No repository available"); @@ -35,6 +42,14 @@ public abstract class RepositoryManager { } } + public static void checkpoint(boolean quick) { + try (final Repository repository = getRepository()) { + repository.checkpoint(quick); + } catch (DataException e) { + // Checkpoint is best-effort so don't complain + } + } + public static void rebuild() throws DataException { RepositoryFactory oldRepositoryFactory = repositoryFactory; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index f8b6ce4d..82af283b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -50,7 +50,7 @@ public class HSQLDBATRepository implements ATRepository { boolean hadFatalError = resultSet.getBoolean(10); boolean isFrozen = resultSet.getBoolean(11); - Long frozenBalance = resultSet.getLong(11); + Long frozenBalance = resultSet.getLong(12); if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; @@ -118,7 +118,7 @@ public class HSQLDBATRepository implements ATRepository { boolean hadFatalError = resultSet.getBoolean(10); boolean isFrozen = resultSet.getBoolean(11); - Long frozenBalance = resultSet.getLong(11); + Long frozenBalance = resultSet.getLong(12); if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; @@ -147,7 +147,7 @@ public class HSQLDBATRepository implements ATRepository { bindParams.add(codeHash); if (isExecutable != null) { - sql.append("AND is_finished = ? "); + sql.append("AND is_finished != ? "); bindParams.add(isExecutable); } @@ -248,22 +248,22 @@ public class HSQLDBATRepository implements ATRepository { @Override public ATStateData getATStateAtHeight(String atAddress, int height) throws DataException { - String sql = "SELECT created_when, state_data, state_hash, fees, is_initial " + String sql = "SELECT state_data, state_hash, fees, is_initial " + "FROM ATStates " - + "WHERE AT_address = ? AND height = ? " + + "LEFT OUTER JOIN ATStatesData USING (AT_address, height) " + + "WHERE ATStates.AT_address = ? AND ATStates.height = ? " + "LIMIT 1"; try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress, height)) { if (resultSet == null) return null; - long created = resultSet.getLong(1); - byte[] stateData = resultSet.getBytes(2); // Actually BLOB - byte[] stateHash = resultSet.getBytes(3); - long fees = resultSet.getLong(4); - boolean isInitial = resultSet.getBoolean(5); + byte[] stateData = resultSet.getBytes(1); // Actually BLOB + byte[] stateHash = resultSet.getBytes(2); + long fees = resultSet.getLong(3); + boolean isInitial = resultSet.getBoolean(4); - return new ATStateData(atAddress, height, created, stateData, stateHash, fees, isInitial); + return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); } catch (SQLException e) { throw new DataException("Unable to fetch AT state from repository", e); } @@ -271,12 +271,13 @@ public class HSQLDBATRepository implements ATRepository { @Override public ATStateData getLatestATState(String atAddress) throws DataException { - String sql = "SELECT height, created_when, state_data, state_hash, fees, is_initial " + String sql = "SELECT height, state_data, state_hash, fees, is_initial " + "FROM ATStates " - + "WHERE AT_address = ? " - // AT_address then height so the compound primary key is used as an index - // Both must be the same direction also - + "ORDER BY AT_address DESC, height DESC " + + "JOIN ATStatesData USING (AT_address, height) " + + "WHERE ATStates.AT_address = ? " + // Order by AT_address and height to use compound primary key as index + // Both must be the same direction (DESC) also + + "ORDER BY ATStates.AT_address DESC, ATStates.height DESC " + "LIMIT 1 "; try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress)) { @@ -284,13 +285,12 @@ public class HSQLDBATRepository implements ATRepository { return null; int height = resultSet.getInt(1); - long created = resultSet.getLong(2); - byte[] stateData = resultSet.getBytes(3); // Actually BLOB - byte[] stateHash = resultSet.getBytes(4); - long fees = resultSet.getLong(5); - boolean isInitial = resultSet.getBoolean(6); + byte[] stateData = resultSet.getBytes(2); // Actually BLOB + byte[] stateHash = resultSet.getBytes(3); + long fees = resultSet.getLong(4); + boolean isInitial = resultSet.getBoolean(5); - return new ATStateData(atAddress, height, created, stateData, stateHash, fees, isInitial); + return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); } catch (SQLException e) { throw new DataException("Unable to fetch latest AT state from repository", e); } @@ -303,21 +303,22 @@ public class HSQLDBATRepository implements ATRepository { StringBuilder sql = new StringBuilder(1024); List bindParams = new ArrayList<>(); - sql.append("SELECT AT_address, height, created_when, state_data, state_hash, fees, is_initial " + sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial " + "FROM ATs " + "CROSS JOIN LATERAL(" - + "SELECT height, created_when, state_data, state_hash, fees, is_initial " + + "SELECT height, state_data, state_hash, fees, is_initial " + "FROM ATStates " + + "JOIN ATStatesData USING (AT_address, height) " + "WHERE ATStates.AT_address = ATs.AT_address "); if (minimumFinalHeight != null) { - sql.append("AND height >= ? "); + sql.append("AND ATStates.height >= ? "); bindParams.add(minimumFinalHeight); } - // AT_address then height so the compound primary key is used as an index - // Both must be the same direction also - sql.append("ORDER BY AT_address DESC, height DESC " + // Order by AT_address and height to use compound primary key as index + // Both must be the same direction (DESC) also + sql.append("ORDER BY ATStates.AT_address DESC, ATStates.height DESC " + "LIMIT 1 " + ") AS FinalATStates " + "WHERE code_hash = ? "); @@ -339,7 +340,7 @@ public class HSQLDBATRepository implements ATRepository { bindParams.add(rawExpectedValue); } - sql.append(" ORDER BY height "); + sql.append(" ORDER BY FinalATStates.height "); if (reverse != null && reverse) sql.append("DESC"); @@ -354,13 +355,12 @@ public class HSQLDBATRepository implements ATRepository { do { String atAddress = resultSet.getString(1); int height = resultSet.getInt(2); - long created = resultSet.getLong(3); - byte[] stateData = resultSet.getBytes(4); // Actually BLOB - byte[] stateHash = resultSet.getBytes(5); - long fees = resultSet.getLong(6); - boolean isInitial = resultSet.getBoolean(7); + byte[] stateData = resultSet.getBytes(3); // Actually BLOB + byte[] stateHash = resultSet.getBytes(4); + long fees = resultSet.getLong(5); + boolean isInitial = resultSet.getBoolean(6); - ATStateData atStateData = new ATStateData(atAddress, height, created, stateData, stateHash, fees, isInitial); + ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); atStates.add(atStateData); } while (resultSet.next()); @@ -374,8 +374,10 @@ public class HSQLDBATRepository implements ATRepository { @Override public List getBlockATStatesAtHeight(int height) throws DataException { String sql = "SELECT AT_address, state_hash, fees, is_initial " - + "FROM ATStates " - + "WHERE height = ? " + + "FROM ATs " + + "LEFT OUTER JOIN ATStates " + + "ON ATStates.AT_address = ATs.AT_address AND height = ? " + + "WHERE ATStates.AT_address IS NOT NULL " + "ORDER BY created_when ASC"; List atStates = new ArrayList<>(); @@ -402,29 +404,131 @@ public class HSQLDBATRepository implements ATRepository { } @Override - public void save(ATStateData atStateData) throws DataException { - // We shouldn't ever save partial ATStateData - if (atStateData.getCreation() == null || atStateData.getStateHash() == null || atStateData.getHeight() == null) - throw new IllegalArgumentException("Refusing to save partial AT state into repository!"); + public int getAtTrimHeight() throws DataException { + String sql = "SELECT AT_trim_height FROM DatabaseInfo"; - HSQLDBSaver saveHelper = new HSQLDBSaver("ATStates"); + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return 0; - saveHelper.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight()) - .bind("created_when", atStateData.getCreation()).bind("state_data", atStateData.getStateData()) - .bind("state_hash", atStateData.getStateHash()).bind("fees", atStateData.getFees()) - .bind("is_initial", atStateData.isInitial()); + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch AT state trim height from repository", e); + } + } + + @Override + public void setAtTrimHeight(int trimHeight) throws DataException { + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET AT_trim_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, trimHeight); + this.repository.saveChanges(); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set AT state trim height in repository", e); + } + } + } + + @Override + public void prepareForAtStateTrimming() throws DataException { + // Rebuild cache of latest AT states that we can't trim + String deleteSql = "DELETE FROM LatestATStates"; + try { + this.repository.executeCheckedUpdate(deleteSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to delete temporary latest AT states cache from repository", e); + } + + String insertSql = "INSERT INTO LatestATStates (" + + "SELECT AT_address, height FROM ATs " + + "CROSS JOIN LATERAL(" + + "SELECT height FROM ATStates " + + "WHERE ATStates.AT_address = ATs.AT_address " + + "ORDER BY AT_address DESC, height DESC LIMIT 1" + + ") " + + ")"; + try { + this.repository.executeCheckedUpdate(insertSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to populate temporary latest AT states cache in repository", e); + } + } + + @Override + public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException { + if (minHeight >= maxHeight) + return 0; + + // We're often called so no need to trim all states in one go. + // Limit updates to reduce CPU and memory load. + String sql = "DELETE FROM ATStatesData " + + "WHERE height BETWEEN ? AND ? " + + "AND NOT EXISTS(" + + "SELECT TRUE FROM LatestATStates " + + "WHERE LatestATStates.AT_address = ATStatesData.AT_address " + + "AND LatestATStates.height = ATStatesData.height" + + ") " + + "LIMIT ?"; try { - saveHelper.execute(this.repository); + return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to trim AT states in repository", e); + } + } + + @Override + public void save(ATStateData atStateData) throws DataException { + // We shouldn't ever save partial ATStateData + if (atStateData.getStateHash() == null || atStateData.getHeight() == null) + throw new IllegalArgumentException("Refusing to save partial AT state into repository!"); + + HSQLDBSaver atStatesSaver = new HSQLDBSaver("ATStates"); + + atStatesSaver.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight()) + .bind("state_hash", atStateData.getStateHash()) + .bind("fees", atStateData.getFees()).bind("is_initial", atStateData.isInitial()); + + try { + atStatesSaver.execute(this.repository); } catch (SQLException e) { throw new DataException("Unable to save AT state into repository", e); } + + if (atStateData.getStateData() != null) { + HSQLDBSaver atStatesDataSaver = new HSQLDBSaver("ATStatesData"); + + atStatesDataSaver.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight()) + .bind("state_data", atStateData.getStateData()); + + try { + atStatesDataSaver.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save AT state data into repository", e); + } + } else { + try { + this.repository.delete("ATStatesData", "AT_address = ? AND height = ?", + atStateData.getATAddress(), atStateData.getHeight()); + } catch (SQLException e) { + throw new DataException("Unable to delete AT state data from repository", e); + } + } } @Override public void delete(String atAddress, int height) throws DataException { try { this.repository.delete("ATStates", "AT_address = ? AND height = ?", atAddress, height); + this.repository.delete("ATStatesData", "AT_address = ? AND height = ?", atAddress, height); } catch (SQLException e) { throw new DataException("Unable to delete AT state from repository", e); } @@ -434,6 +538,7 @@ public class HSQLDBATRepository implements ATRepository { public void deleteATStates(int height) throws DataException { try { this.repository.delete("ATStates", "height = ?", height); + this.repository.delete("ATStatesData", "height = ?", height); } catch (SQLException e) { throw new DataException("Unable to delete AT states from repository", e); } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index 563148fd..d9d6ed51 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -462,13 +462,46 @@ public class HSQLDBBlockRepository implements BlockRepository { } @Override - public int trimOldOnlineAccountsSignatures(long timestamp) throws DataException { + public int getOnlineAccountsSignaturesTrimHeight() throws DataException { + String sql = "SELECT online_signatures_trim_height FROM DatabaseInfo"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return 0; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch online accounts signatures trim height from repository", e); + } + } + + @Override + public void setOnlineAccountsSignaturesTrimHeight(int trimHeight) throws DataException { + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET online_signatures_trim_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, trimHeight); + this.repository.saveChanges(); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set online accounts signatures trim height in repository", e); + } + } + } + + @Override + public int trimOldOnlineAccountsSignatures(int minHeight, int maxHeight) throws DataException { // We're often called so no need to trim all blocks in one go. // Limit updates to reduce CPU and memory load. - String sql = "UPDATE Blocks set online_accounts_signatures = NULL WHERE minted_when < ? AND online_accounts_signatures IS NOT NULL LIMIT 1440"; + String sql = "UPDATE Blocks SET online_accounts_signatures = NULL " + + "WHERE online_accounts_signatures IS NOT NULL " + + "AND height BETWEEN ? AND ?"; try { - return this.repository.executeCheckedUpdate(sql, timestamp); + return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight); } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to trim old online accounts signatures in repository", e); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 53af196a..bffc0d5a 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -21,11 +21,16 @@ public class HSQLDBDatabaseUpdates { /** * Apply any incremental changes to database schema. * + * @return true if database was non-existent/empty, false otherwise * @throws SQLException */ - public static void updateDatabase(Connection connection) throws SQLException { - while (databaseUpdating(connection)) + public static boolean updateDatabase(Connection connection) throws SQLException { + final boolean wasPristine = fetchDatabaseVersion(connection) == 0; + + while (databaseUpdating(connection, wasPristine)) incrementDatabaseVersion(connection); + + return wasPristine; } /** @@ -43,23 +48,21 @@ public class HSQLDBDatabaseUpdates { /** * Fetch current version of database schema. * - * @return int, 0 if no schema yet + * @return database version, or 0 if no schema yet * @throws SQLException */ private static int fetchDatabaseVersion(Connection connection) throws SQLException { - int databaseVersion = 0; - try (Statement stmt = connection.createStatement()) { if (stmt.execute("SELECT version FROM DatabaseInfo")) try (ResultSet resultSet = stmt.getResultSet()) { if (resultSet.next()) - databaseVersion = resultSet.getInt(1); + return resultSet.getInt(1); } } catch (SQLException e) { // empty database } - return databaseVersion; + return 0; } /** @@ -68,7 +71,7 @@ public class HSQLDBDatabaseUpdates { * @return true - if a schema update happened, false otherwise * @throws SQLException */ - private static boolean databaseUpdating(Connection connection) throws SQLException { + private static boolean databaseUpdating(Connection connection, boolean wasPristine) throws SQLException { int databaseVersion = fetchDatabaseVersion(connection); try (Statement stmt = connection.createStatement()) { @@ -215,6 +218,8 @@ public class HSQLDBDatabaseUpdates { + "PRIMARY KEY (account))"); // For looking up an account by public key stmt.execute("CREATE INDEX AccountPublicKeyIndex on Accounts (public_key)"); + // Use a separate table space as this table will be very large. + stmt.execute("SET TABLE Accounts NEW SPACE"); // Account balances stmt.execute("CREATE TABLE AccountBalances (account QortalAddress, asset_id AssetID, balance QortalAmount NOT NULL, " @@ -223,6 +228,8 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE INDEX AccountBalancesAssetBalanceIndex ON AccountBalances (asset_id, balance)"); // Add CHECK constraint to account balances stmt.execute("ALTER TABLE AccountBalances ADD CONSTRAINT CheckBalanceNotNegative CHECK (balance >= 0)"); + // Use a separate table space as this table will be very large. + stmt.execute("SET TABLE AccountBalances NEW SPACE"); // Keeping track of QORT gained from holding legacy QORA stmt.execute("CREATE TABLE AccountQortFromQoraInfo (account QortalAddress, final_qort_from_qora QortalAmount, final_block_height INT, " @@ -420,6 +427,8 @@ public class HSQLDBDatabaseUpdates { + "PRIMARY KEY (AT_address, height), FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); // For finding per-block AT states, ordered by creation timestamp stmt.execute("CREATE INDEX BlockATStateIndex on ATStates (height, created_when)"); + // Use a separate table space as this table will be very large. + stmt.execute("SET TABLE ATStates NEW SPACE"); // Deploy CIYAM AT Transactions stmt.execute("CREATE TABLE DeployATTransactions (signature Signature, creator QortalPublicKey NOT NULL, AT_name ATName NOT NULL, " @@ -658,6 +667,115 @@ public class HSQLDBDatabaseUpdates { break; case 25: + // DISABLED: improved version in case 30! + // Remove excess created_when from ATStates + // stmt.execute("ALTER TABLE ATStates DROP created_when"); + // stmt.execute("CREATE INDEX ATStateHeightIndex on ATStates (height)"); + break; + + case 26: + // Support for trimming + stmt.execute("ALTER TABLE DatabaseInfo ADD AT_trim_height INT NOT NULL DEFAULT 0"); + stmt.execute("ALTER TABLE DatabaseInfo ADD online_signatures_trim_height INT NOT NULL DEFAULT 0"); + break; + + case 27: + // More indexes + stmt.execute("CREATE INDEX IF NOT EXISTS PaymentTransactionsRecipientIndex ON PaymentTransactions (recipient)"); + stmt.execute("CREATE INDEX IF NOT EXISTS ATTransactionsRecipientIndex ON ATTransactions (recipient)"); + break; + + case 28: + // Latest AT state cache + stmt.execute("CREATE TEMPORARY TABLE IF NOT EXISTS LatestATStates (" + + "AT_address QortalAddress NOT NULL, " + + "height INT NOT NULL" + + ")"); + break; + + case 29: + // Turn off HSQLDB redo-log "blockchain.log" and periodically call "CHECKPOINT" ourselves + stmt.execute("SET FILES LOG FALSE"); + stmt.execute("CHECKPOINT"); + break; + + case 30: + // Split AT state data off to new table for better performance/management. + + if (!wasPristine && !"mem".equals(HSQLDBRepository.getDbPathname(connection.getMetaData().getURL()))) { + // First, backup node-local data in case user wants to avoid long reshape and use bootstrap instead + try (ResultSet resultSet = stmt.executeQuery("SELECT COUNT(*) FROM MintingAccounts")) { + int rowCount = resultSet.next() ? resultSet.getInt(1) : 0; + if (rowCount > 0) { + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE MintingAccounts DATA TO 'MintingAccounts.script'"); + LOGGER.info("Exported sensitive/node-local minting keys into MintingAccounts.script"); + } + } + + try (ResultSet resultSet = stmt.executeQuery("SELECT COUNT(*) FROM TradeBotStates")) { + int rowCount = resultSet.next() ? resultSet.getInt(1) : 0; + if (rowCount > 0) { + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA TO 'TradeBotStates.script'"); + LOGGER.info("Exported sensitive/node-local trade-bot states into TradeBotStates.script"); + } + } + + LOGGER.info("If following reshape takes too long, use bootstrap and import node-local data using API's POST /admin/repository/data"); + } + + // Create new AT-states table without full state data + stmt.execute("CREATE TABLE ATStatesNew (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " + + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, " + + "PRIMARY KEY (AT_address, height), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + stmt.execute("SET TABLE ATStatesNew NEW SPACE"); + stmt.execute("CHECKPOINT"); + + ResultSet resultSet = stmt.executeQuery("SELECT height FROM Blocks ORDER BY height DESC LIMIT 1"); + final int blockchainHeight = resultSet.next() ? resultSet.getInt(1) : 0; + final int heightStep = 100; + + LOGGER.info("Rebuilding AT state summaries in repository - this might take a while... (approx. 2 mins on high-spec)"); + for (int minHeight = 1; minHeight < blockchainHeight; minHeight += heightStep) { + stmt.execute("INSERT INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial " + + "FROM ATStates " + + "WHERE height BETWEEN " + minHeight + " AND " + (minHeight + heightStep - 1) + + ")"); + stmt.execute("COMMIT"); + } + stmt.execute("CHECKPOINT"); + + LOGGER.info("Rebuilding AT states height index in repository - this might take about 3x longer..."); + stmt.execute("CREATE INDEX ATStatesHeightIndex ON ATStatesNew (height)"); + stmt.execute("CHECKPOINT"); + + stmt.execute("CREATE TABLE ATStatesData (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_data ATState NOT NULL, " + + "PRIMARY KEY (height, AT_address), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + stmt.execute("SET TABLE ATStatesData NEW SPACE"); + stmt.execute("CHECKPOINT"); + + LOGGER.info("Rebuilding AT state data in repository - this might take a while... (approx. 2 mins on high-spec)"); + for (int minHeight = 1; minHeight < blockchainHeight; minHeight += heightStep) { + stmt.execute("INSERT INTO ATStatesData (" + + "SELECT AT_address, height, state_data " + + "FROM ATstates " + + "WHERE state_data IS NOT NULL " + + "AND height BETWEEN " + minHeight + " AND " + (minHeight + heightStep - 1) + + ")"); + stmt.execute("COMMIT"); + } + stmt.execute("CHECKPOINT"); + + stmt.execute("DROP TABLE ATStates"); + stmt.execute("ALTER TABLE ATStatesNew RENAME TO ATStates"); + stmt.execute("CHECKPOINT"); + break; + + case 31: // Multiple blockchains, ACCTs and trade-bots stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN acct_name VARCHAR(40) BEFORE trade_state"); stmt.execute("UPDATE TradeBotStates SET acct_name = 'BitcoinACCTv1' WHERE acct_name IS NULL"); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 43c480c6..7c694b53 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -53,12 +54,27 @@ public class HSQLDBRepository implements Repository { private static final Logger LOGGER = LogManager.getLogger(HSQLDBRepository.class); protected Connection connection; - protected Deque savepoints = new ArrayDeque<>(3); + protected final Deque savepoints = new ArrayDeque<>(3); protected boolean debugState = false; protected Long slowQueryThreshold = null; protected List sqlStatements; protected long sessionId; - protected Map preparedStatementCache = new HashMap<>(); + protected final Map preparedStatementCache = new HashMap<>(); + protected final Object trimHeightsLock = new Object(); + + private final ATRepository atRepository = new HSQLDBATRepository(this); + private final AccountRepository accountRepository = new HSQLDBAccountRepository(this); + private final ArbitraryRepository arbitraryRepository = new HSQLDBArbitraryRepository(this); + private final AssetRepository assetRepository = new HSQLDBAssetRepository(this); + private final BlockRepository blockRepository = new HSQLDBBlockRepository(this); + private final ChatRepository chatRepository = new HSQLDBChatRepository(this); + private final CrossChainRepository crossChainRepository = new HSQLDBCrossChainRepository(this); + private final GroupRepository groupRepository = new HSQLDBGroupRepository(this); + private final MessageRepository messageRepository = new HSQLDBMessageRepository(this); + private final NameRepository nameRepository = new HSQLDBNameRepository(this); + private final NetworkRepository networkRepository = new HSQLDBNetworkRepository(this); + private final TransactionRepository transactionRepository = new HSQLDBTransactionRepository(this); + private final VotingRepository votingRepository = new HSQLDBVotingRepository(this); // Constructors @@ -92,67 +108,67 @@ public class HSQLDBRepository implements Repository { @Override public ATRepository getATRepository() { - return new HSQLDBATRepository(this); + return this.atRepository; } @Override public AccountRepository getAccountRepository() { - return new HSQLDBAccountRepository(this); + return this.accountRepository; } @Override public ArbitraryRepository getArbitraryRepository() { - return new HSQLDBArbitraryRepository(this); + return this.arbitraryRepository; } @Override public AssetRepository getAssetRepository() { - return new HSQLDBAssetRepository(this); + return this.assetRepository; } @Override public BlockRepository getBlockRepository() { - return new HSQLDBBlockRepository(this); + return this.blockRepository; } @Override public ChatRepository getChatRepository() { - return new HSQLDBChatRepository(this); + return this.chatRepository; } @Override public CrossChainRepository getCrossChainRepository() { - return new HSQLDBCrossChainRepository(this); + return this.crossChainRepository; } @Override public GroupRepository getGroupRepository() { - return new HSQLDBGroupRepository(this); + return this.groupRepository; } @Override public MessageRepository getMessageRepository() { - return new HSQLDBMessageRepository(this); + return this.messageRepository; } @Override public NameRepository getNameRepository() { - return new HSQLDBNameRepository(this); + return this.nameRepository; } @Override public NetworkRepository getNetworkRepository() { - return new HSQLDBNetworkRepository(this); + return this.networkRepository; } @Override public TransactionRepository getTransactionRepository() { - return new HSQLDBTransactionRepository(this); + return this.transactionRepository; } @Override public VotingRepository getVotingRepository() { - return new HSQLDBVotingRepository(this); + return this.votingRepository; } @Override @@ -169,8 +185,20 @@ public class HSQLDBRepository implements Repository { @Override public void saveChanges() throws DataException { + long beforeQuery = this.slowQueryThreshold == null ? 0 : System.currentTimeMillis(); + try { this.connection.commit(); + + if (this.slowQueryThreshold != null) { + long queryTime = System.currentTimeMillis() - beforeQuery; + + if (queryTime > this.slowQueryThreshold) { + LOGGER.info(() -> String.format("[Session %d] HSQLDB COMMIT took %d ms", this.sessionId, queryTime), new SQLException("slow commit")); + + logStatements(); + } + } } catch (SQLException e) { throw new DataException("commit error", e); } finally { @@ -194,7 +222,7 @@ public class HSQLDBRepository implements Repository { this.savepoints.clear(); // Before clearing statements so we can log what led to assertion error - assertEmptyTransaction("transaction commit"); + assertEmptyTransaction("transaction rollback"); if (this.sqlStatements != null) this.sqlStatements.clear(); @@ -284,11 +312,12 @@ public class HSQLDBRepository implements Repository { Path oldRepoDirPath = Paths.get(dbPathname).getParent(); // Delete old repository files - Files.walk(oldRepoDirPath) - .sorted(Comparator.reverseOrder()) + try (Stream paths = Files.walk(oldRepoDirPath)) { + paths.sorted(Comparator.reverseOrder()) .map(Path::toFile) .filter(file -> file.getPath().startsWith(dbPathname)) .forEach(File::delete); + } } } catch (NoSuchFileException e) { // Nothing to remove @@ -328,11 +357,12 @@ public class HSQLDBRepository implements Repository { Path backupDirPath = Paths.get(backupPathname).getParent(); String backupDirPathname = backupDirPath.toString(); - Files.walk(backupDirPath) - .sorted(Comparator.reverseOrder()) + try (Stream paths = Files.walk(backupDirPath)) { + paths.sorted(Comparator.reverseOrder()) .map(Path::toFile) .filter(file -> file.getPath().startsWith(backupDirPathname)) .forEach(File::delete); + } } catch (NoSuchFileException e) { // Nothing to remove } catch (SQLException | IOException e) { @@ -347,18 +377,56 @@ public class HSQLDBRepository implements Repository { } } + @Override + public void checkpoint(boolean quick) throws DataException { + try (Statement stmt = this.connection.createStatement()) { + stmt.execute(quick ? "CHECKPOINT" : "CHECKPOINT DEFRAG"); + } catch (SQLException e) { + throw new DataException("Unable to perform repository checkpoint"); + } + } + @Override public void performPeriodicMaintenance() throws DataException { // Defrag DB - takes a while! try (Statement stmt = this.connection.createStatement()) { + LOGGER.info("performing maintenance - this will take a while"); + stmt.execute("CHECKPOINT"); stmt.execute("CHECKPOINT DEFRAG"); + LOGGER.info("maintenance completed"); } catch (SQLException e) { throw new DataException("Unable to defrag repository"); } } + @Override + public void exportNodeLocalData() throws DataException { + try (Statement stmt = this.connection.createStatement()) { + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE MintingAccounts DATA TO 'MintingAccounts.script'"); + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA TO 'TradeBotStates.script'"); + LOGGER.info("Exported sensitive/node-local data: minting keys and trade bot states"); + } catch (SQLException e) { + throw new DataException("Unable to export sensitive/node-local data from repository"); + } + } + + @Override + public void importDataFromFile(String filename) throws DataException { + try (Statement stmt = this.connection.createStatement()) { + LOGGER.info(() -> String.format("Importing data into repository from %s", filename)); + + String escapedFilename = stmt.enquoteLiteral(filename); + stmt.execute("PERFORM IMPORT SCRIPT DATA FROM " + escapedFilename + " STOP ON ERROR"); + + LOGGER.info(() -> String.format("Imported data into repository from %s", filename)); + } catch (SQLException e) { + LOGGER.info(() -> String.format("Failed to import data into repository from %s: %s", filename, e.getMessage())); + throw new DataException("Unable to export sensitive/node-local data from repository: " + e.getMessage()); + } + } + /** Returns DB pathname from passed connection URL. If memory DB, returns "mem". */ - private static String getDbPathname(String connectionUrl) { + /*package*/ static String getDbPathname(String connectionUrl) { Pattern pattern = Pattern.compile("hsqldb:(mem|file):(.*?)(;|$)"); Matcher matcher = pattern.matcher(connectionUrl); @@ -394,11 +462,12 @@ public class HSQLDBRepository implements Repository { LOGGER.info("Attempting repository recovery using backup"); // Move old repository files out the way - Files.walk(oldRepoDirPath) - .sorted(Comparator.reverseOrder()) + try (Stream paths = Files.walk(oldRepoDirPath)) { + paths.sorted(Comparator.reverseOrder()) .map(Path::toFile) .filter(file -> file.getPath().startsWith(dbPathname)) .forEach(File::delete); + } try (Statement stmt = connection.createStatement()) { // Now "backup" the backup back to original repository location (the parent). @@ -438,6 +507,10 @@ public class HSQLDBRepository implements Repository { if (this.sqlStatements != null) this.sqlStatements.add(sql); + return cachePreparedStatement(sql); + } + + private PreparedStatement cachePreparedStatement(String sql) throws SQLException { /* * We cache a duplicate PreparedStatement for this SQL string, * which we never close, which means HSQLDB also caches a parsed, @@ -446,10 +519,21 @@ public class HSQLDBRepository implements Repository { * * See org.hsqldb.StatementManager for more details. */ - if (!this.preparedStatementCache.containsKey(sql)) - this.preparedStatementCache.put(sql, this.connection.prepareStatement(sql)); + PreparedStatement preparedStatement = this.preparedStatementCache.get(sql); + if (preparedStatement == null || preparedStatement.isClosed()) { + if (preparedStatement != null) + // This shouldn't occur, so log, but recompile + LOGGER.debug(() -> String.format("Recompiling closed PreparedStatement: %s", sql)); - return this.connection.prepareStatement(sql); + preparedStatement = this.connection.prepareStatement(sql); + this.preparedStatementCache.put(sql, preparedStatement); + } else { + // Clean up ready for reuse + preparedStatement.clearBatch(); + preparedStatement.clearParameters(); + } + + return preparedStatement; } /** @@ -465,9 +549,8 @@ public class HSQLDBRepository implements Repository { public ResultSet checkedExecute(String sql, Object... objects) throws SQLException { PreparedStatement preparedStatement = this.prepareStatement(sql); - // Close the PreparedStatement when the ResultSet is closed otherwise there's a potential resource leak. - // We can't use try-with-resources here as closing the PreparedStatement on return would also prematurely close the ResultSet. - preparedStatement.closeOnCompletion(); + // We don't close the PreparedStatement when the ResultSet is closed because we cached PreparedStatements now. + // They are cleaned up when connection/session is closed. long beforeQuery = this.slowQueryThreshold == null ? 0 : System.currentTimeMillis(); @@ -477,7 +560,7 @@ public class HSQLDBRepository implements Repository { long queryTime = System.currentTimeMillis() - beforeQuery; if (queryTime > this.slowQueryThreshold) { - LOGGER.info(() -> String.format("HSQLDB query took %d ms: %s", queryTime, sql), new SQLException("slow query")); + LOGGER.info(() -> String.format("[Session %d] HSQLDB query took %d ms: %s", this.sessionId, queryTime, sql), new SQLException("slow query")); logStatements(); } @@ -556,36 +639,35 @@ public class HSQLDBRepository implements Repository { if (batchedObjects == null || batchedObjects.isEmpty()) return 0; - try (PreparedStatement preparedStatement = this.prepareStatement(sql)) { - for (Object[] objects : batchedObjects) { - this.bindStatementParams(preparedStatement, objects); - preparedStatement.addBatch(); - } - - long beforeQuery = this.slowQueryThreshold == null ? 0 : System.currentTimeMillis(); - - int[] updateCounts = preparedStatement.executeBatch(); - - if (this.slowQueryThreshold != null) { - long queryTime = System.currentTimeMillis() - beforeQuery; - - if (queryTime > this.slowQueryThreshold) { - LOGGER.info(() -> String.format("HSQLDB query took %d ms: %s", queryTime, sql), new SQLException("slow query")); - - logStatements(); - } - } - - int totalCount = 0; - for (int i = 0; i < updateCounts.length; ++i) { - if (updateCounts[i] < 0) - throw new SQLException("Database returned invalid row count"); - - totalCount += updateCounts[i]; - } - - return totalCount; + PreparedStatement preparedStatement = this.prepareStatement(sql); + for (Object[] objects : batchedObjects) { + this.bindStatementParams(preparedStatement, objects); + preparedStatement.addBatch(); } + + long beforeQuery = this.slowQueryThreshold == null ? 0 : System.currentTimeMillis(); + + int[] updateCounts = preparedStatement.executeBatch(); + + if (this.slowQueryThreshold != null) { + long queryTime = System.currentTimeMillis() - beforeQuery; + + if (queryTime > this.slowQueryThreshold) { + LOGGER.info(() -> String.format("[Session %d] HSQLDB query took %d ms: %s", this.sessionId, queryTime, sql), new SQLException("slow query")); + + logStatements(); + } + } + + int totalCount = 0; + for (int i = 0; i < updateCounts.length; ++i) { + if (updateCounts[i] < 0) + throw new SQLException("Database returned invalid row count"); + + totalCount += updateCounts[i]; + } + + return totalCount; } /** @@ -765,15 +847,15 @@ public class HSQLDBRepository implements Repository { if (this.sqlStatements == null) return; - LOGGER.info(() -> String.format("HSQLDB SQL statements (session %d) leading up to this were:", this.sessionId)); + LOGGER.info(() -> String.format("[Session %d] HSQLDB SQL statements leading up to this were:", this.sessionId)); for (String sql : this.sqlStatements) - LOGGER.info(sql); + LOGGER.info(() -> String.format("[Session %d] %s", this.sessionId, sql)); } /** Logs other HSQLDB sessions then returns passed exception */ public SQLException examineException(SQLException e) { - LOGGER.error(String.format("HSQLDB error (session %d): %s", this.sessionId, e.getMessage()), e); + LOGGER.error(() -> String.format("[Session %d] HSQLDB error: %s", this.sessionId, e.getMessage()), e); logStatements(); @@ -807,14 +889,19 @@ public class HSQLDBRepository implements Repository { } private void assertEmptyTransaction(String context) throws DataException { - try (Statement stmt = this.connection.createStatement()) { + String sql = "SELECT transaction, transaction_size FROM information_schema.system_sessions WHERE session_id = ?"; + + try { + PreparedStatement stmt = this.cachePreparedStatement(sql); + stmt.setLong(1, this.sessionId); + // Diagnostic check for uncommitted changes - if (!stmt.execute("SELECT transaction, transaction_size FROM information_schema.system_sessions WHERE session_id = " + this.sessionId)) // TRANSACTION_SIZE() broken? + if (!stmt.execute()) // TRANSACTION_SIZE() broken? throw new DataException("Unable to check repository status after " + context); try (ResultSet resultSet = stmt.getResultSet()) { if (resultSet == null || !resultSet.next()) { - LOGGER.warn(String.format("Unable to check repository status after %s", context)); + LOGGER.warn(() -> String.format("Unable to check repository status after %s", context)); return; } @@ -822,7 +909,11 @@ public class HSQLDBRepository implements Repository { int transactionCount = resultSet.getInt(2); if (inTransaction && transactionCount != 0) { - LOGGER.warn(String.format("Uncommitted changes (%d) after %s, session [%d]", transactionCount, context, this.sessionId), new Exception("Uncommitted repository changes")); + LOGGER.warn(() -> String.format("Uncommitted changes (%d) after %s, session [%d]", + transactionCount, + context, + this.sessionId), + new Exception("Uncommitted repository changes")); logStatements(); } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java index 8561b698..81bf320b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java @@ -25,6 +25,7 @@ public class HSQLDBRepositoryFactory implements RepositoryFactory { private String connectionUrl; private HSQLDBPool connectionPool; + private final boolean wasPristine; /** * Constructs new RepositoryFactory using passed connectionUrl. @@ -65,12 +66,17 @@ public class HSQLDBRepositoryFactory implements RepositoryFactory { // Perform DB updates? try (final Connection connection = this.connectionPool.getConnection()) { - HSQLDBDatabaseUpdates.updateDatabase(connection); + this.wasPristine = HSQLDBDatabaseUpdates.updateDatabase(connection); } catch (SQLException e) { throw new DataException("Repository initialization error", e); } } + @Override + public boolean wasPristineAtOpen() { + return this.wasPristine; + } + @Override public RepositoryFactory reopen() throws DataException { return new HSQLDBRepositoryFactory(this.connectionUrl); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java index 8384c22f..c1b6ee9b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java @@ -60,7 +60,9 @@ public class HSQLDBSaver { */ public boolean execute(HSQLDBRepository repository) throws SQLException { String sql = this.formatInsertWithPlaceholders(); - try (PreparedStatement preparedStatement = repository.prepareStatement(sql)) { + + try { + PreparedStatement preparedStatement = repository.prepareStatement(sql); this.bindValues(preparedStatement); return preparedStatement.execute(); diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 6a150889..22d8da24 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -5,6 +5,7 @@ import java.io.FileNotFoundException; import java.io.FileReader; import java.io.IOException; import java.io.Reader; +import java.util.Locale; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; @@ -42,6 +43,9 @@ public class Settings { // Settings, and other config files private String userPath; + // General + private String localeLang = Locale.getDefault().getLanguage(); + // Common to all networking (API/P2P) private String bindAddress = "::"; // Use IPv6 wildcard to listen on all local addresses @@ -62,6 +66,7 @@ public class Settings { "::1", "127.0.0.1" }; private Boolean apiRestricted; + private String apiKey = null; private boolean apiLoggingEnabled = false; private boolean apiDocumentationEnabled = false; // Both of these need to be set for API to use SSL @@ -80,6 +85,26 @@ public class Settings { private long repositoryBackupInterval = 0; // ms /** Whether to show a notification when we backup repository. */ private boolean showBackupNotification = false; + /** How long between repository checkpoints (ms). */ + private long repositoryCheckpointInterval = 60 * 60 * 1000L; // 1 hour (ms) default + /** Whether to show a notification when we perform repository 'checkpoint'. */ + private boolean showCheckpointNotification = false; + + /** How long to keep old, full, AT state data (ms). */ + private long atStatesMaxLifetime = 2 * 7 * 24 * 60 * 60 * 1000L; // milliseconds + /** How often to attempt AT state trimming (ms). */ + private long atStatesTrimInterval = 5678L; // milliseconds + /** Block height range to scan for trimmable AT states.
+ * This has a significant effect on execution time. */ + private int atStatesTrimBatchSize = 100; // blocks + /** Max number of AT states to trim in one go. */ + private int atStatesTrimLimit = 4000; // records + + /** How often to attempt online accounts signatures trimming (ms). */ + private long onlineSignaturesTrimInterval = 9876L; // milliseconds + /** Block height range to scan for trimmable online accounts signatures.
+ * This has a significant effect on execution time. */ + private int onlineSignaturesTrimBatchSize = 100; // blocks // Peer-to-peer related private boolean isTestNet = false; @@ -253,6 +278,9 @@ public class Settings { // Validation goes here if (this.minBlockchainPeers < 1) throwValidationError("minBlockchainPeers must be at least 1"); + + if (this.apiKey != null && this.apiKey.trim().length() < 8) + throwValidationError("apiKey must be at least 8 characters"); } // Getters / setters @@ -261,6 +289,10 @@ public class Settings { return this.userPath; } + public String getLocaleLang() { + return this.localeLang; + } + public int getUiServerPort() { return this.uiPort; } @@ -297,6 +329,10 @@ public class Settings { return !BlockChain.getInstance().isTestChain(); } + public String getApiKey() { + return this.apiKey; + } + public boolean isApiLoggingEnabled() { return this.apiLoggingEnabled; } @@ -412,4 +448,36 @@ public class Settings { return this.showBackupNotification; } + public long getRepositoryCheckpointInterval() { + return this.repositoryCheckpointInterval; + } + + public boolean getShowCheckpointNotification() { + return this.showCheckpointNotification; + } + + public long getAtStatesMaxLifetime() { + return this.atStatesMaxLifetime; + } + + public long getAtStatesTrimInterval() { + return this.atStatesTrimInterval; + } + + public int getAtStatesTrimBatchSize() { + return this.atStatesTrimBatchSize; + } + + public int getAtStatesTrimLimit() { + return this.atStatesTrimLimit; + } + + public long getOnlineSignaturesTrimInterval() { + return this.onlineSignaturesTrimInterval; + } + + public int getOnlineSignaturesTrimBatchSize() { + return this.onlineSignaturesTrimBatchSize; + } + } diff --git a/src/main/resources/i18n/ApiError_de.properties b/src/main/resources/i18n/ApiError_de.properties index b2825e0d..490aac0d 100644 --- a/src/main/resources/i18n/ApiError_de.properties +++ b/src/main/resources/i18n/ApiError_de.properties @@ -1,13 +1,13 @@ -INVALID_ADDRESS = ung\u00FCltige adresse +INVALID_ADDRESS = ungültige adresse -INVALID_ASSET_ID = ung\u00FCltige asset ID +INVALID_ASSET_ID = ungültige asset ID -INVALID_DATA = ung\u00FCltige daten +INVALID_DATA = ungültige daten -INVALID_PUBLIC_KEY = ung\u00FCltiger public key +INVALID_PUBLIC_KEY = ungültiger public key -INVALID_SIGNATURE = ung\u00FCltige signatur +INVALID_SIGNATURE = ungültige signatur JSON = JSON nachricht konnte nicht geparsed werden diff --git a/src/main/resources/i18n/ApiError_ru.properties b/src/main/resources/i18n/ApiError_ru.properties index 014fc4ad..e67be901 100644 --- a/src/main/resources/i18n/ApiError_ru.properties +++ b/src/main/resources/i18n/ApiError_ru.properties @@ -1,53 +1,57 @@ -#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) -# Keys are from api.ApiError enum - -ADDRESS_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0083\u00D1\u0087\u00D0\u00B5\u00D1\u0082\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00B7\u00D0\u00B0\u00D0\u00BF\u00D0\u00B8\u00D1\u0081\u00D1\u008C - -# Blocks -BLOCK_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B1\u00D0\u00BB\u00D0\u00BE\u00D0\u00BA - -CANNOT_MINT = \u00D0\u00B0\u00D0\u00BA\u00D0\u00BA\u00D0\u00B0\u00D1\u0083\u00D0\u00BD\u00D1\u0082 \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BC\u00D0\u00BE\u00D0\u00B6\u00D0\u00B5\u00D1\u0082 \u00D1\u0087\u00D0\u00B5\u00D0\u00BA\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u0082\u00D1\u008C - -GROUP_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00B0 - -INVALID_ADDRESS = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B0\u00D0\u00B4\u00D1\u0080\u00D0\u00B5\u00D1\u0081 - -# Assets -INVALID_ASSET_ID = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0 - -INVALID_CRITERIA = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B5 \u00D0\u00BA\u00D1\u0080\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D1\u0080\u00D0\u00B8\u00D0\u00B8 \u00D0\u00BF\u00D0\u00BE\u00D0\u00B8\u00D1\u0081\u00D0\u00BA\u00D0\u00B0 - -INVALID_DATA = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B5 \u00D0\u00B4\u00D0\u00B0\u00D0\u00BD\u00D0\u00BD\u00D1\u008B\u00D0\u00B5 - -INVALID_HEIGHT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D0\u00B2\u00D1\u008B\u00D1\u0081\u00D0\u00BE\u00D1\u0082\u00D0\u00B0 \u00D0\u00B1\u00D0\u00BB\u00D0\u00BE\u00D0\u00BA\u00D0\u00B0 - -INVALID_NETWORK_ADDRESS = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D1\u0081\u00D0\u00B5\u00D1\u0082\u00D0\u00B5\u00D0\u00B2\u00D0\u00BE\u00D0\u00B9 \u00D0\u00B0\u00D0\u00B4\u00D1\u0080\u00D0\u00B5\u00D1\u0081 - -INVALID_ORDER_ID = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7\u00D0\u00B0 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0 - -INVALID_PRIVATE_KEY = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D1\u0080\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 - -INVALID_PUBLIC_KEY = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 - -INVALID_REFERENCE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0081\u00D1\u008B\u00D0\u00BB\u00D0\u00BA\u00D0\u00B0 - -# Validation -INVALID_SIGNATURE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00BF\u00D0\u00BE\u00D0\u00B4\u00D0\u00BF\u00D0\u00B8\u00D1\u0081\u00D1\u008C - -JSON = \u00D0\u00BD\u00D0\u00B5 \u00D1\u0083\u00D0\u00B4\u00D0\u00B0\u00D0\u00BB\u00D0\u00BE\u00D1\u0081\u00D1\u008C \u00D1\u0080\u00D0\u00B0\u00D0\u00B7\u00D0\u00BE\u00D0\u00B1\u00D1\u0080\u00D0\u00B0\u00D1\u0082\u00D1\u008C \u00D1\u0081\u00D0\u00BE\u00D0\u00BE\u00D0\u00B1\u00D1\u0089\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 json - -NAME_UNKNOWN = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00BE - -ORDER_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7\u00D0\u00B0 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0 - -PUBLIC_KEY_NOT_FOUND = \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BD\u00D0\u00B0\u00D0\u00B9\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD - -REPOSITORY_ISSUE = \u00D0\u00BE\u00D1\u0088\u00D0\u00B8\u00D0\u00B1\u00D0\u00BA\u00D0\u00B0 \u00D1\u0080\u00D0\u00B5\u00D0\u00BF\u00D0\u00BE\u00D0\u00B7\u00D0\u00B8\u00D1\u0082\u00D0\u00BE\u00D1\u0080\u00D0\u00B8\u00D1\u008F - -TRANSACTION_INVALID = \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00B0: %s (%s) - -TRANSACTION_UNKNOWN = \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00B0 - -TRANSFORMATION_ERROR = \u00D0\u00BD\u00D0\u00B5 \u00D1\u0083\u00D0\u00B4\u00D0\u00B0\u00D0\u00BB\u00D0\u00BE\u00D1\u0081\u00D1\u008C \u00D0\u00BF\u00D1\u0080\u00D0\u00B5\u00D0\u00BE\u00D0\u00B1\u00D1\u0080\u00D0\u00B0\u00D0\u00B7\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D1\u0082\u00D1\u008C JSON \u00D0\u00B2 \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008E - -UNAUTHORIZED = \u00D0\u00B2\u00D1\u008B\u00D0\u00B7\u00D0\u00BE\u00D0\u00B2 API \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B0\u00D0\u00B2\u00D1\u0082\u00D0\u00BE\u00D1\u0080\u00D0\u00B8\u00D0\u00B7\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D0\u00BD +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# Keys are from api.ApiError enum + +ADDRESS_UNKNOWN = неизвестная учетная запись + +BLOCKCHAIN_NEEDS_SYNC = блокчейн должен сначала синхронизироваться + +# Blocks +BLOCK_UNKNOWN = неизвестный блок + +CANNOT_MINT = аккаунт не может чеканить + +GROUP_UNKNOWN = неизвестная группа + +INVALID_ADDRESS = неизвестный адрес + +# Assets +INVALID_ASSET_ID = неверный идентификатор актива + +INVALID_CRITERIA = неверные критерии поиска + +INVALID_DATA = неверные данные + +INVALID_HEIGHT = недопустимая высота блока + +INVALID_NETWORK_ADDRESS = неверный сетевой адрес + +INVALID_ORDER_ID = неверный идентификатор заказа актива + +INVALID_PRIVATE_KEY = неверный приватный ключ + +INVALID_PUBLIC_KEY = недействительный открытый ключ + +INVALID_REFERENCE = неверная ссылка + +# Validation +INVALID_SIGNATURE = недействительная подпись + +JSON = не удалось разобрать сообщение json + +NAME_UNKNOWN = имя неизвестно + +NON_PRODUCTION = этот вызов API не разрешен для производственных систем + +ORDER_UNKNOWN = неизвестный идентификатор заказа актива + +PUBLIC_KEY_NOT_FOUND = открытый ключ не найден + +REPOSITORY_ISSUE = ошибка репозитория + +TRANSACTION_INVALID = транзакция недействительна: %s (%s) + +TRANSACTION_UNKNOWN = транзакция неизвестна + +TRANSFORMATION_ERROR = не удалось преобразовать JSON в транзакцию + +UNAUTHORIZED = вызов API не авторизован diff --git a/src/main/resources/i18n/SysTray_en.properties b/src/main/resources/i18n/SysTray_en.properties index f41c1a32..e581335d 100644 --- a/src/main/resources/i18n/SysTray_en.properties +++ b/src/main/resources/i18n/SysTray_en.properties @@ -19,6 +19,8 @@ CREATING_BACKUP_OF_DB_FILES = Creating backup of database files... DB_BACKUP = Database Backup +DB_CHECKPOINT = Database Checkpoint + EXIT = Exit MINTING_DISABLED = NOT minting @@ -34,6 +36,8 @@ NTP_NAG_TEXT_WINDOWS = Select "Synchronize clock" from menu to fix. OPEN_UI = Open UI +PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... + SYNCHRONIZE_CLOCK = Synchronize clock SYNCHRONIZING_BLOCKCHAIN = Synchronizing diff --git a/src/main/resources/i18n/SysTray_zh.properties b/src/main/resources/i18n/SysTray_zh.properties index bb2e1426..0aaa2e33 100644 --- a/src/main/resources/i18n/SysTray_zh.properties +++ b/src/main/resources/i18n/SysTray_zh.properties @@ -1,31 +1,31 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -BLOCK_HEIGHT = \u5757\u9AD8\u5EA6 +BLOCK_HEIGHT = 块高度 -CHECK_TIME_ACCURACY = \u68C0\u67E5\u65F6\u95F4\u51C6\u786E\u6027 +CHECK_TIME_ACCURACY = 检查时间准确性 -CONNECTION = \u4E2A\u8FDE\u63A5 +CONNECTION = 个连接 -CONNECTIONS = \u4E2A\u8FDE\u63A5 +CONNECTIONS = 个连接 -EXIT = \u9000\u51FA\u8F6F\u4EF6 +EXIT = 退出软件 -MINTING_DISABLED = \u6CA1\u6709\u94F8\u5E01 +MINTING_DISABLED = 没有铸币 -MINTING_ENABLED = \u2714 \u94F8\u5E01 +MINTING_ENABLED = ✔ 铸币 # Nagging about lack of NTP time sync -NTP_NAG_CAPTION = \u7535\u8111\u7684\u65F6\u949F\u4E0D\u51C6\u786E\uFF01 +NTP_NAG_CAPTION = 电脑的时钟不准确! -NTP_NAG_TEXT_UNIX = \u5B89\u88C5NTP\u670D\u52A1\u4EE5\u83B7\u5F97\u51C6\u786E\u7684\u65F6\u949F\u3002 +NTP_NAG_TEXT_UNIX = 安装NTP服务以获得准确的时钟。 -NTP_NAG_TEXT_WINDOWS = \u4ECE\u83DC\u5355\u4E2D\u9009\u62E9\u201C\u540C\u6B65\u65F6\u949F\u201D\u8FDB\u884C\u4FEE\u590D\u3002 +NTP_NAG_TEXT_WINDOWS = 从菜单中选择“同步时钟”进行修复。 -OPEN_UI = \u5F00\u542F\u754C\u9762 +OPEN_UI = 开启界面 -SYNCHRONIZE_CLOCK = \u540C\u6B65\u65F6\u949F +SYNCHRONIZE_CLOCK = 同步时钟 -SYNCHRONIZING_BLOCKCHAIN = \u540C\u6B65\u533A\u5757\u94FE +SYNCHRONIZING_BLOCKCHAIN = 同步区块链 -SYNCHRONIZING_CLOCK = \u540C\u6B65\u7740\u65F6\u949F +SYNCHRONIZING_CLOCK = 同步着时钟 diff --git a/src/main/resources/i18n/TransactionValidity_ru.properties b/src/main/resources/i18n/TransactionValidity_ru.properties index 40112726..c2dbe5df 100644 --- a/src/main/resources/i18n/TransactionValidity_ru.properties +++ b/src/main/resources/i18n/TransactionValidity_ru.properties @@ -1,164 +1,176 @@ - -ACCOUNT_ALREADY_EXISTS = \u00D0\u00B0\u00D0\u00BA\u00D0\u00BA\u00D0\u00B0\u00D1\u0083\u00D0\u00BD\u00D1\u0082 \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -ACCOUNT_CANNOT_REWARD_SHARE = \u00D0\u00B0\u00D0\u00BA\u00D0\u00BA\u00D0\u00B0\u00D1\u0083\u00D0\u00BD\u00D1\u0082 \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BC\u00D0\u00BE\u00D0\u00B6\u00D0\u00B5\u00D1\u0082 \u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B8\u00D1\u0082\u00D1\u008C\u00D1\u0081\u00D1\u008F \u00D0\u00B2\u00D0\u00BE\u00D0\u00B7\u00D0\u00BD\u00D0\u00B0\u00D0\u00B3\u00D1\u0080\u00D0\u00B0\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5\u00D0\u00BC - -ALREADY_GROUP_ADMIN = \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00B0\u00D0\u00B4\u00D0\u00BC\u00D0\u00B8\u00D0\u00BD\u00D0\u00B8\u00D1\u0081\u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -ALREADY_GROUP_MEMBER = \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0087\u00D0\u00BB\u00D0\u00B5\u00D0\u00BD \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -ALREADY_VOTED_FOR_THAT_OPTION = \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D0\u00B3\u00D0\u00BE\u00D0\u00BB\u00D0\u00BE\u00D1\u0081\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D0\u00BB\u00D0\u00B8 \u00D0\u00B7\u00D0\u00B0 \u00D1\u008D\u00D1\u0082\u00D0\u00BE\u00D1\u0082 \u00D0\u00B2\u00D0\u00B0\u00D1\u0080\u00D0\u00B8\u00D0\u00B0\u00D0\u00BD\u00D1\u0082 - -ASSET_ALREADY_EXISTS = \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2 \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -ASSET_DOES_NOT_EXIST = \u00D0\u0090\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -ASSET_DOES_NOT_MATCH_AT = \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D0\u00BE\u00D0\u00B2\u00D0\u00BF\u00D0\u00B0\u00D0\u00B4\u00D0\u00B0\u00D0\u00B5\u00D1\u0082 \u00D1\u0081 \u00D0\u0090\u00D0\u00A2 - -AT_ALREADY_EXISTS = AT \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -AT_IS_FINISHED = AT \u00D0\u00B2 \u00D0\u00B7\u00D0\u00B0\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D1\u0088\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B8 - -AT_UNKNOWN = \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u0090\u00D0\u00A2 - -BANNED_FROM_GROUP = \u00D0\u00B8\u00D1\u0081\u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087\u00D0\u00B5\u00D0\u00BD \u00D0\u00B8\u00D0\u00B7 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -BAN_EXISTS = \u00D0\u0091\u00D0\u00B0\u00D0\u00BD - -BAN_UNKNOWN = \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B1\u00D0\u00B0\u00D0\u00BD - -BUYER_ALREADY_OWNER = \u00D0\u00BF\u00D0\u00BE\u00D0\u00BA\u00D1\u0083\u00D0\u00BF\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D0\u00BE\u00D0\u00B1\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D0\u00BD\u00D0\u00BD\u00D0\u00B8\u00D0\u00BA - -DUPLICATE_OPTION = \u00D0\u00B4\u00D1\u0083\u00D0\u00B1\u00D0\u00BB\u00D0\u00B8\u00D1\u0080\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D1\u0082\u00D1\u008C \u00D0\u00B2\u00D0\u00B0\u00D1\u0080\u00D0\u00B8\u00D0\u00B0\u00D0\u00BD\u00D1\u0082 - -GROUP_ALREADY_EXISTS = \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00B0 \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -GROUP_APPROVAL_DECIDED = \u00D0\u00B3\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00B0 \u00D0\u00BE\u00D0\u00B4\u00D0\u00BE\u00D0\u00B1\u00D1\u0080\u00D0\u00B5\u00D0\u00BD\u00D0\u00B0 - -GROUP_APPROVAL_NOT_REQUIRED = \u00D0\u00B3\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00BE\u00D0\u00B2\u00D0\u00BE\u00D0\u00B5 \u00D0\u00BE\u00D0\u00B4\u00D0\u00BE\u00D0\u00B1\u00D1\u0080\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0082\u00D1\u0080\u00D0\u00B5\u00D0\u00B1\u00D1\u0083\u00D0\u00B5\u00D1\u0082\u00D1\u0081\u00D1\u008F - -GROUP_DOES_NOT_EXIST = \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -GROUP_ID_MISMATCH = \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D0\u00BE\u00D0\u00BE\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D1\u0082\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D0\u00B5 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080\u00D0\u00B0 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -GROUP_OWNER_CANNOT_LEAVE = \u00D0\u00B2\u00D0\u00BB\u00D0\u00B0\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B5\u00D1\u0086 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BC\u00D0\u00BE\u00D0\u00B6\u00D0\u00B5\u00D1\u0082 \u00D1\u0083\u00D0\u00B9\u00D1\u0082\u00D0\u00B8 - -HAVE_EQUALS_WANT = \u00D0\u00B8\u00D0\u00BC\u00D0\u00BC\u00D0\u00B5\u00D1\u008E\u00D1\u0082\u00D1\u0081\u00D1\u008F \u00D1\u0080\u00D0\u00B0\u00D0\u00B2\u00D0\u00BD\u00D1\u008B\u00D0\u00B5 \u00D0\u00B6\u00D0\u00B5\u00D0\u00BB\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u008F - -INSUFFICIENT_FEE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00BF\u00D0\u00BB\u00D0\u00B0\u00D1\u0082\u00D0\u00B0 - -INVALID_ADDRESS = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B0\u00D0\u00B4\u00D1\u0080\u00D0\u00B5\u00D1\u0081 - -INVALID_AMOUNT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0083\u00D0\u00BC\u00D0\u00BC\u00D0\u00B0 - -INVALID_ASSET_OWNER = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B2\u00D0\u00BB\u00D0\u00B0\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B5\u00D1\u0086 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0 - -INVALID_AT_TRANSACTION = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u0090\u00D0\u00A2 \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F - -INVALID_AT_TYPE_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00BE \u00D0\u00B4\u00D0\u00BB\u00D1\u008F \u00D1\u0082\u00D0\u00B8\u00D0\u00BF\u00D0\u00B0 \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D1\u008B AT - -INVALID_CREATION_BYTES = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B5 \u00D0\u00B1\u00D0\u00B0\u00D0\u00B9\u00D1\u0082\u00D1\u008B \u00D1\u0081\u00D0\u00BE\u00D0\u00B7\u00D0\u00B4\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u008F - -INVALID_DESCRIPTION_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D0\u00B0 \u00D0\u00BE\u00D0\u00BF\u00D0\u00B8\u00D1\u0081\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u008F - -INVALID_GROUP_APPROVAL_THRESHOLD = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D0\u00BE\u00D1\u0080\u00D0\u00BE\u00D0\u00B3 \u00D1\u0083\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D1\u008F \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -INVALID_GROUP_ID = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -INVALID_GROUP_OWNER = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083 \u00D0\u00B2\u00D0\u00BB\u00D0\u00B0\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B5\u00D1\u0086 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -INVALID_LIFETIME = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083 \u00D1\u0081\u00D1\u0080\u00D0\u00BE\u00D0\u00BA \u00D1\u0081\u00D0\u00BB\u00D1\u0083\u00D0\u00B6\u00D0\u00B1\u00D1\u008B - -INVALID_NAME_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D0\u00B0 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -INVALID_NAME_OWNER = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00BE\u00D0\u00B5 \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D0\u00B2\u00D0\u00BB\u00D0\u00B0\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D1\u0086\u00D0\u00B0 - -INVALID_OPTIONS_COUNT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D0\u00BE\u00D0\u00B5 \u00D0\u00BA\u00D0\u00BE\u00D0\u00BB\u00D0\u00B8\u00D1\u0087\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00BE \u00D0\u00BE\u00D0\u00BF\u00D1\u0086\u00D0\u00B8\u00D0\u00B9 - -INVALID_OPTION_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D0\u00B0 \u00D0\u00BE\u00D0\u00BF\u00D1\u0086\u00D0\u00B8\u00D0\u00B8 - -INVALID_ORDER_CREATOR = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B9 \u00D1\u0081\u00D0\u00BE\u00D0\u00B7\u00D0\u00B4\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7\u00D0\u00B0 - -INVALID_PAYMENTS_COUNT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D0\u00BE\u00D0\u00B4\u00D1\u0081\u00D1\u0087\u00D0\u00B5\u00D1\u0082 \u00D0\u00BF\u00D0\u00BB\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00B6\u00D0\u00B5\u00D0\u00B9 - -INVALID_PUBLIC_KEY = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 - -INVALID_QUANTITY = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00BE\u00D0\u00B5 \u00D0\u00BA\u00D0\u00BE\u00D0\u00BB\u00D0\u00B8\u00D1\u0087\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00BE - -INVALID_REFERENCE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0081\u00D1\u008B\u00D0\u00BB\u00D0\u00BA\u00D0\u00B0 - -INVALID_RETURN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B9 \u00D0\u00B2\u00D0\u00BE\u00D0\u00B7\u00D0\u00B2\u00D1\u0080\u00D0\u00B0\u00D1\u0082 - -INVALID_REWARD_SHARE_PERCENT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D1\u0086\u00D0\u00B5\u00D0\u00BD\u00D1\u0082 \u00D0\u00BD\u00D0\u00B0\u00D0\u00B3\u00D1\u0080\u00D0\u00B0\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D1\u008F - -INVALID_SELLER = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D0\u00B4\u00D0\u00B0\u00D0\u00B2\u00D0\u00B5\u00D1\u0086 - -INVALID_TAGS_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D0\u00B0 \u00D1\u0082\u00D1\u008D\u00D0\u00B3\u00D0\u00BE\u00D0\u00B2 - -INVALID_TX_GROUP_ID = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B \u00D0\u00BF\u00D0\u00B5\u00D1\u0080\u00D0\u00B5\u00D0\u00B4\u00D0\u00B0\u00D1\u0087\u00D0\u00B8 - -INVALID_VALUE_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00BE\u00D0\u00B5 \u00D0\u00B7\u00D0\u00BD\u00D0\u00B0\u00D1\u0087\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D1\u008B - -JOIN_REQUEST_EXISTS = \u00D0\u00B7\u00D0\u00B0\u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D1\u0081 \u00D0\u00BD\u00D0\u00B0 \u00D0\u00BF\u00D1\u0080\u00D0\u00B8\u00D1\u0081\u00D0\u00BE\u00D0\u00B5\u00D0\u00B4\u00D0\u00B8\u00D0\u00BD\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -MAXIMUM_REWARD_SHARES = \u00D0\u00BC\u00D0\u00B0\u00D0\u00BA\u00D1\u0081\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00BE\u00D0\u00B5 \u00D0\u00B2\u00D0\u00BE\u00D0\u00B7\u00D0\u00BD\u00D0\u00B0\u00D0\u00B3\u00D1\u0080\u00D0\u00B0\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 - -MISSING_CREATOR = \u00D0\u00BE\u00D1\u0082\u00D1\u0081\u00D1\u0083\u00D1\u0082\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D1\u008E\u00D1\u0089\u00D0\u00B8\u00D0\u00B9 \u00D1\u0081\u00D0\u00BE\u00D0\u00B7\u00D0\u00B4\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C - -MULTIPLE_NAMES_FORBIDDEN = \u00D0\u00BD\u00D0\u00B5\u00D1\u0081\u00D0\u00BA\u00D0\u00BE\u00D0\u00BB\u00D1\u008C\u00D0\u00BA\u00D0\u00BE \u00D0\u00B8\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD \u00D0\u00B7\u00D0\u00B0\u00D0\u00BF\u00D1\u0080\u00D0\u00B5\u00D1\u0089\u00D0\u00B5\u00D0\u00BD\u00D0\u00BE - -NAME_ALREADY_FOR_SALE = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00B2 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D0\u00B4\u00D0\u00B0\u00D0\u00B6\u00D0\u00B5 - -NAME_ALREADY_REGISTERED = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00B7\u00D0\u00B0\u00D1\u0080\u00D0\u00B5\u00D0\u00B3\u00D0\u00B8\u00D1\u0081\u00D1\u0082\u00D1\u0080\u00D0\u00B8\u00D1\u0080\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D0\u00BD\u00D0\u00BE - -NAME_DOES_NOT_EXIST = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -NAME_NOT_FOR_SALE = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D0\u00B4\u00D0\u00B0\u00D0\u00B5\u00D1\u0082\u00D1\u0081\u00D1\u008F - -NAME_NOT_LOWER_CASE = \u00D0\u00B8\u00D0\u00BC\u00D0\u00BC\u00D1\u008F \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B4\u00D0\u00BE\u00D0\u00BB\u00D0\u00B6\u00D0\u00BD\u00D0\u00BE \u00D1\u0081\u00D0\u00BE\u00D0\u00B4\u00D0\u00B5\u00D1\u0080\u00D0\u00B6\u00D0\u00B0\u00D1\u0082\u00D1\u008C \u00D1\u0081\u00D1\u0082\u00D1\u0080\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D1\u0080\u00D0\u00B5\u00D0\u00B3\u00D0\u00B8\u00D1\u0081\u00D1\u0082\u00D1\u0080 - -NEGATIVE_AMOUNT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0083\u00D0\u00BC\u00D0\u00BC\u00D0\u00B0 - -NEGATIVE_FEE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00BA\u00D0\u00BE\u00D0\u00BC\u00D0\u00B8\u00D1\u0081\u00D1\u0081\u00D0\u00B8\u00D1\u008F - -NEGATIVE_PRICE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0082\u00D0\u00BE\u00D0\u00B8\u00D0\u00BC\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D1\u008C - -NOT_GROUP_ADMIN = \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B0\u00D0\u00B4\u00D0\u00BC\u00D0\u00B8\u00D0\u00BD\u00D0\u00B8\u00D1\u0081\u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -NOT_GROUP_MEMBER = \u00D0\u00BD\u00D0\u00B5 \u00D1\u0087\u00D0\u00BB\u00D0\u00B5\u00D0\u00BD \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -NOT_MINTING_ACCOUNT = \u00D1\u0081\u00D1\u0087\u00D0\u00B5\u00D1\u0082 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0087\u00D0\u00B5\u00D0\u00BA\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u0082 - -NOT_YET_RELEASED = \u00D0\u00B5\u00D1\u0089\u00D0\u00B5 \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B2\u00D1\u008B\u00D0\u00BF\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D0\u00BD\u00D0\u00BE - -NO_BALANCE = \u00D0\u00BD\u00D0\u00B5\u00D1\u0082 \u00D0\u00B1\u00D0\u00B0\u00D0\u00BB\u00D0\u00B0\u00D0\u00BD\u00D1\u0081\u00D0\u00B0 - -NO_BLOCKCHAIN_LOCK = \u00D0\u00B1\u00D0\u00BB\u00D0\u00BE\u00D0\u00BA\u00D1\u0087\u00D0\u00B5\u00D0\u00B9\u00D0\u00BD \u00D1\u0083\u00D0\u00B7\u00D0\u00BB\u00D0\u00B0 \u00D0\u00B2 \u00D0\u00BD\u00D0\u00B0\u00D1\u0081\u00D1\u0082\u00D0\u00BE\u00D1\u008F\u00D1\u0089\u00D0\u00B5\u00D0\u00B5 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D1\u008F \u00D0\u00B7\u00D0\u00B0\u00D0\u00BD\u00D1\u008F\u00D1\u0082 - -NO_FLAG_PERMISSION = \u00D0\u00BD\u00D0\u00B5\u00D1\u0082 \u00D1\u0080\u00D0\u00B0\u00D0\u00B7\u00D1\u0080\u00D0\u00B5\u00D1\u0088\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D1\u008F \u00D0\u00BD\u00D0\u00B0 \u00D1\u0084\u00D0\u00BB\u00D0\u00B0\u00D0\u00B3 - -OK = OK - -ORDER_ALREADY_CLOSED = \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7 \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082 - -ORDER_DOES_NOT_EXIST = \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -POLL_ALREADY_EXISTS = \u00D0\u00BE\u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D1\u0081 \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -POLL_DOES_NOT_EXIST = \u00D0\u00BE\u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D1\u0081\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -POLL_OPTION_DOES_NOT_EXIST = \u00D0\u00B2\u00D0\u00B0\u00D1\u0080\u00D0\u00B8\u00D0\u00B0\u00D0\u00BD\u00D1\u0082\u00D0\u00BE\u00D0\u00B2 \u00D0\u00BE\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D1\u0082\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -PUBLIC_KEY_UNKNOWN = \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B5\u00D0\u00BD - -SELF_SHARE_EXISTS = \u00D0\u00BF\u00D0\u00BE\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B8\u00D1\u0082\u00D1\u008C\u00D1\u0081\u00D1\u008F \u00D0\u00B4\u00D0\u00BE\u00D0\u00BB\u00D0\u00B5\u00D0\u00B9 - -TIMESTAMP_TOO_NEW = \u00D0\u00BD\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D1\u008F \u00D0\u00BC\u00D0\u00B5\u00D1\u0082\u00D0\u00BA\u00D0\u00B0 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8 - -TIMESTAMP_TOO_OLD = \u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0080\u00D0\u00B0\u00D1\u008F \u00D0\u00BC\u00D0\u00B5\u00D1\u0082\u00D0\u00BA\u00D0\u00B0 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8 - -TRANSACTION_ALREADY_CONFIRMED = \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00BF\u00D0\u00BE\u00D0\u00B4\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B0 - -TRANSACTION_ALREADY_EXISTS = \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -TRANSACTION_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F + +ACCOUNT_ALREADY_EXISTS = аккаунт уже существует + +ACCOUNT_CANNOT_REWARD_SHARE = аккаунт не может делиться вознаграждением + +ALREADY_GROUP_ADMIN = уже администратор группы + +ALREADY_GROUP_MEMBER = уже член группы + +ALREADY_VOTED_FOR_THAT_OPTION = уже проголосовали за этот вариант + +ASSET_ALREADY_EXISTS = актив уже существует + +ASSET_DOES_NOT_EXIST = Актив не существует + +ASSET_DOES_NOT_MATCH_AT = актив не совпадает с АТ + +ASSET_NOT_SPENDABLE = актив не подлежит расходованию + +AT_ALREADY_EXISTS = AT уже существует + +AT_IS_FINISHED = AT в завершении + +AT_UNKNOWN = не известный АТ + +BANNED_FROM_GROUP = исключен из группы + +BAN_EXISTS = Бан + +BAN_UNKNOWN = не известный бан + +BUYER_ALREADY_OWNER = покупатель уже собственник + +CLOCK_NOT_SYNCED = часы не синхронизированы + +DUPLICATE_OPTION = дублировать вариант + +GROUP_ALREADY_EXISTS = группа уже существует + +GROUP_APPROVAL_DECIDED = гуппа одобрена + +GROUP_APPROVAL_NOT_REQUIRED = гупповое одобрение не требуется + +GROUP_DOES_NOT_EXIST = группа не существует + +GROUP_ID_MISMATCH = не соответствие идентификатора группы + +GROUP_OWNER_CANNOT_LEAVE = владелец группы не может уйти + +HAVE_EQUALS_WANT = иммеются равные желания + +INSUFFICIENT_FEE = недостаточная плата + +INVALID_ADDRESS = недействительный адрес + +INVALID_AMOUNT = недопустимая сумма + +INVALID_ASSET_OWNER = недействительный владелец актива + +INVALID_AT_TRANSACTION = недействительная АТ транзакция + +INVALID_AT_TYPE_LENGTH = недействительно для типа длины AT + +INVALID_CREATION_BYTES = недопустимые байты создания + +INVALID_DATA_LENGTH = недопустимая длина данных + +INVALID_DESCRIPTION_LENGTH = недопустимая длина описания + +INVALID_GROUP_APPROVAL_THRESHOLD = недопустимый порог утверждения группы + +INVALID_GROUP_ID = недопустимый идентификатор группы + +INVALID_GROUP_OWNER = недопу владелец группы + +INVALID_LIFETIME = недопу срок службы + +INVALID_NAME_LENGTH = недопустимая длина группы + +INVALID_NAME_OWNER = недопустимое имя владельца + +INVALID_OPTIONS_COUNT = неверное количество опций + +INVALID_OPTION_LENGTH = недопустимая длина опции + +INVALID_ORDER_CREATOR = недопустимый создатель заказа + +INVALID_PAYMENTS_COUNT = неверный подсчет платежей + +INVALID_PUBLIC_KEY = недействительный открытый ключ + +INVALID_QUANTITY = недопустимое количество + +INVALID_REFERENCE = неверная ссылка + +INVALID_RETURN = недопустимый возврат + +INVALID_REWARD_SHARE_PERCENT = недействительный процент награждения + +INVALID_SELLER = недействительный продавец + +INVALID_TAGS_LENGTH = недействительная длина тэгов + +INVALID_TX_GROUP_ID = недействительный идентификатор группы передачи + +INVALID_VALUE_LENGTH = недопустимое значение длины + +INVITE_UNKNOWN = приглашать неизветсных + +JOIN_REQUEST_EXISTS = запрос на присоединение существует + +MAXIMUM_REWARD_SHARES = максимальное вознаграждение + +MISSING_CREATOR = отсутствующий создатель + +MULTIPLE_NAMES_FORBIDDEN = несколько имен запрещено + +NAME_ALREADY_FOR_SALE = имя уже в продаже + +NAME_ALREADY_REGISTERED = имя уже зарегистрировано + +NAME_DOES_NOT_EXIST = имя не существует + +NAME_NOT_FOR_SALE = имя не продается + +NAME_NOT_LOWER_CASE = иммя не должно содержать строчный регистр + +NEGATIVE_AMOUNT = недостаточная сумма + +NEGATIVE_FEE = недостаточная комиссия + +NEGATIVE_PRICE = недостаточная стоимость + +NOT_GROUP_ADMIN = не администратор группы + +NOT_GROUP_MEMBER = не член группы + +NOT_MINTING_ACCOUNT = счет не чеканит + +NOT_YET_RELEASED = еще не выпущено + +NO_BALANCE = нет баланса + +NO_BLOCKCHAIN_LOCK = блокчейн узла в настоящее время занят + +NO_FLAG_PERMISSION = нет разрешения на флаг + +OK = OK + +ORDER_ALREADY_CLOSED = заказ закрыт + +ORDER_DOES_NOT_EXIST = заказа не существует + +POLL_ALREADY_EXISTS = опрос уже существует + +POLL_DOES_NOT_EXIST = опроса не существует + +POLL_OPTION_DOES_NOT_EXIST = вариантов ответа не существует + +PUBLIC_KEY_UNKNOWN = открытый ключ неизвестен + +SELF_SHARE_EXISTS = поделиться долей + +TIMESTAMP_TOO_NEW = новая метка времени + +TIMESTAMP_TOO_OLD = старая метка времени + +TOO_MANY_UNCONFIRMED = много не подтвержденных + +TRANSACTION_ALREADY_CONFIRMED = транзакция уже подтверждена + +TRANSACTION_ALREADY_EXISTS = транзакция существует + +TRANSACTION_UNKNOWN = неизвестная транзакция + +TX_GROUP_ID_MISMATCH = не соответствие идентификатора группы c хэш транзации diff --git a/src/test/java/org/qortal/test/BlockTests.java b/src/test/java/org/qortal/test/BlockTests.java index 3e3d0ada..b6d4429d 100644 --- a/src/test/java/org/qortal/test/BlockTests.java +++ b/src/test/java/org/qortal/test/BlockTests.java @@ -1,6 +1,10 @@ package org.qortal.test; +import java.util.Arrays; +import java.util.Deque; +import java.util.LinkedList; import java.util.List; +import java.util.stream.Collectors; import org.junit.Before; import org.junit.Test; @@ -133,6 +137,129 @@ public class BlockTests extends Common { } } + @Test + public void testLatestBlockCacheWithLatestBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + byte[] parentSignature = latestBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(true, childBlocks.isEmpty()); + } + } + + @Test + public void testLatestBlockCacheWithPenultimateBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + BlockData penultimateBlock = repository.getBlockRepository().fromHeight(latestBlock.getHeight() - 1); + byte[] parentSignature = penultimateBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(false, childBlocks.isEmpty()); + assertEquals(1, childBlocks.size()); + + BlockData expectedBlock = latestBlock; + BlockData actualBlock = childBlocks.get(0); + assertArrayEquals(expectedBlock.getSignature(), actualBlock.getSignature()); + } + } + + @Test + public void testLatestBlockCacheWithMiddleBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + int tipOffset = 5; + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + BlockData parentBlock = repository.getBlockRepository().fromHeight(latestBlock.getHeight() - tipOffset); + byte[] parentSignature = parentBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(false, childBlocks.isEmpty()); + assertEquals(tipOffset, childBlocks.size()); + + BlockData expectedFirstBlock = repository.getBlockRepository().fromHeight(parentBlock.getHeight() + 1); + BlockData actualFirstBlock = childBlocks.get(0); + assertArrayEquals(expectedFirstBlock.getSignature(), actualFirstBlock.getSignature()); + + BlockData expectedLastBlock = latestBlock; + BlockData actualLastBlock = childBlocks.get(childBlocks.size() - 1); + assertArrayEquals(expectedLastBlock.getSignature(), actualLastBlock.getSignature()); + } + } + + @Test + public void testLatestBlockCacheWithFirstBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + int tipOffset = latestBlockCache.size(); + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + BlockData parentBlock = repository.getBlockRepository().fromHeight(latestBlock.getHeight() - tipOffset); + byte[] parentSignature = parentBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(false, childBlocks.isEmpty()); + assertEquals(tipOffset, childBlocks.size()); + + BlockData expectedFirstBlock = repository.getBlockRepository().fromHeight(parentBlock.getHeight() + 1); + BlockData actualFirstBlock = childBlocks.get(0); + assertArrayEquals(expectedFirstBlock.getSignature(), actualFirstBlock.getSignature()); + + BlockData expectedLastBlock = latestBlock; + BlockData actualLastBlock = childBlocks.get(childBlocks.size() - 1); + assertArrayEquals(expectedLastBlock.getSignature(), actualLastBlock.getSignature()); + } + } + + @Test + public void testLatestBlockCacheWithNoncachedBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + int tipOffset = latestBlockCache.size() + 1; // outside of cache + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + BlockData parentBlock = repository.getBlockRepository().fromHeight(latestBlock.getHeight() - tipOffset); + byte[] parentSignature = parentBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(true, childBlocks.isEmpty()); + } + } + + private Deque buildLatestBlockCache(Repository repository, int count) throws DataException { + Deque latestBlockCache = new LinkedList<>(); + + // Mint some blocks + for (int h = 0; h < count; ++h) + latestBlockCache.addLast(BlockUtils.mintBlock(repository).getBlockData()); + + // Reduce cache down to latest 10 blocks + while (latestBlockCache.size() > 10) + latestBlockCache.removeFirst(); + + return latestBlockCache; + } + + private List findCachedChildBlocks(Deque latestBlockCache, byte[] parentSignature) { + return latestBlockCache.stream() + .dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature)) + .collect(Collectors.toList()); + } + @Test public void testCommonBlockSearch() { // Given a list of block summaries, trim all trailing summaries after common block diff --git a/src/test/java/org/qortal/test/ChainWeightTests.java b/src/test/java/org/qortal/test/ChainWeightTests.java index c580f30c..b02c155e 100644 --- a/src/test/java/org/qortal/test/ChainWeightTests.java +++ b/src/test/java/org/qortal/test/ChainWeightTests.java @@ -103,8 +103,10 @@ public class ChainWeightTests extends Common { populateBlockSummariesMinterLevels(repository, shorterChain); populateBlockSummariesMinterLevels(repository, longerChain); - BigInteger shorterChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockGeneratorKey, shorterChain); - BigInteger longerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockGeneratorKey, longerChain); + final int mutualHeight = commonBlockHeight - 1 + Math.min(shorterChain.size(), longerChain.size()); + + BigInteger shorterChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockGeneratorKey, shorterChain, mutualHeight); + BigInteger longerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockGeneratorKey, longerChain, mutualHeight); assertEquals("longer chain should have greater weight", 1, longerChainWeight.compareTo(shorterChainWeight)); } diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index 2b0c4541..a16504b2 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -112,7 +112,7 @@ public class RepositoryTests extends Common { BlockUtils.mintBlock(repository1); // Perform database 'update', but don't commit at this stage - repository1.getBlockRepository().trimOldOnlineAccountsSignatures(System.currentTimeMillis()); + repository1.getBlockRepository().trimOldOnlineAccountsSignatures(1, 10); // Open connection 2 try (final Repository repository2 = RepositoryManager.getRepository()) { diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java new file mode 100644 index 00000000..9d19f0eb --- /dev/null +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -0,0 +1,426 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.List; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; + +public class AtRepositoryTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testGetATStateAtHeightWithData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + Integer testHeight = 8; + ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetATStateAtHeightWithoutData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + int maxHeight = 8; + Integer testHeight = maxHeight - 2; + + // Trim AT state data + repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().trimAtStates(2, maxHeight, 1000); + + ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNull(atStateData.getStateData()); + } + } + + @Test + public void testGetLatestATStateWithData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight; + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetLatestATStatePostTrimming() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + int maxHeight = blockchainHeight + 100; // more than latest block height + Integer testHeight = blockchainHeight; + + // Trim AT state data + repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().trimAtStates(2, maxHeight, 1000); + + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + + assertEquals(testHeight, atStateData.getHeight()); + // We should always have the latest AT state data available + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetMatchingFinalATStatesWithoutDataValue() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight; + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + + byte[] codeHash = atData.getCodeHash(); + Boolean isFinished = Boolean.FALSE; + Integer dataByteOffset = null; + Long expectedValue = null; + Integer minimumFinalHeight = null; + Integer limit = null; + Integer offset = null; + Boolean reverse = null; + + List atStates = repository.getATRepository().getMatchingFinalATStates( + codeHash, + isFinished, + dataByteOffset, + expectedValue, + minimumFinalHeight, + limit, offset, reverse); + + assertEquals(false, atStates.isEmpty()); + assertEquals(1, atStates.size()); + + ATStateData atStateData = atStates.get(0); + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetMatchingFinalATStatesWithDataValue() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight; + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + + byte[] codeHash = atData.getCodeHash(); + Boolean isFinished = Boolean.FALSE; + Integer dataByteOffset = MachineState.HEADER_LENGTH + 0; + Long expectedValue = 0L; + Integer minimumFinalHeight = null; + Integer limit = null; + Integer offset = null; + Boolean reverse = null; + + List atStates = repository.getATRepository().getMatchingFinalATStates( + codeHash, + isFinished, + dataByteOffset, + expectedValue, + minimumFinalHeight, + limit, offset, reverse); + + assertEquals(false, atStates.isEmpty()); + assertEquals(1, atStates.size()); + + ATStateData atStateData = atStates.get(0); + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetBlockATStatesAtHeightWithData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + doDeploy(repository, deployer, creationBytes, fundingAmount); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + Integer testHeight = 8; + List atStates = repository.getATRepository().getBlockATStatesAtHeight(testHeight); + + assertEquals(false, atStates.isEmpty()); + assertEquals(1, atStates.size()); + + ATStateData atStateData = atStates.get(0); + assertEquals(testHeight, atStateData.getHeight()); + // getBlockATStatesAtHeight never returns actual AT state data anyway + assertNull(atStateData.getStateData()); + } + } + + @Test + public void testGetBlockATStatesAtHeightWithoutData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + doDeploy(repository, deployer, creationBytes, fundingAmount); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + int maxHeight = 8; + Integer testHeight = maxHeight - 2; + + // Trim AT state data + repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().trimAtStates(2, maxHeight, 1000); + + List atStates = repository.getATRepository().getBlockATStatesAtHeight(testHeight); + + assertEquals(false, atStates.isEmpty()); + assertEquals(1, atStates.size()); + + ATStateData atStateData = atStates.get(0); + assertEquals(testHeight, atStateData.getHeight()); + // getBlockATStatesAtHeight never returns actual AT state data anyway + assertNull(atStateData.getStateData()); + } + } + + @Test + public void testSaveATStateWithData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight - 2; + ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + + repository.getATRepository().save(atStateData); + + atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testSaveATStateWithoutData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight - 2; + ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + + // Clear data + ATStateData newAtStateData = new ATStateData(atStateData.getATAddress(), + atStateData.getHeight(), + /*StateData*/ null, + atStateData.getStateHash(), + atStateData.getFees(), + atStateData.isInitial()); + repository.getATRepository().save(newAtStateData); + + atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNull(atStateData.getStateData()); + } + } + + private byte[] buildSimpleAT() { + // Pretend we use 4 values in data segment + int addrCounter = 4; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + +} diff --git a/tools/build-auto-update.sh b/tools/build-auto-update.sh index db651a39..4124f1e2 100755 --- a/tools/build-auto-update.sh +++ b/tools/build-auto-update.sh @@ -13,7 +13,7 @@ fi cd ${git_dir} # Check we are in 'master' branch -branch_name=$( git symbolic-ref -q HEAD ) +branch_name=$( git symbolic-ref -q HEAD || echo ) branch_name=${branch_name##refs/heads/} echo "Current git branch: ${branch_name}" if [ "${branch_name}" != "master" ]; then @@ -78,5 +78,7 @@ git add ${project}.update git commit --message "XORed, auto-update JAR based on commit ${short_hash}" git push --set-upstream origin --force-with-lease ${update_branch} +branch_name=${branch_name-master} + echo "Changing back to '${branch_name}' branch" git checkout --force ${branch_name}