Initial implementation of automatic bootstrapping

Currently supports block archive (i.e. full) bootstraps only. Still need to add support for top-only bootstraps.
This commit is contained in:
CalDescent 2021-09-28 20:17:19 +01:00
parent de8e96cd75
commit 0a4479fe9e
17 changed files with 590 additions and 22 deletions

18
pom.xml
View File

@ -14,6 +14,9 @@
<ciyam-at.version>1.3.8</ciyam-at.version>
<commons-net.version>3.6</commons-net.version>
<commons-text.version>1.8</commons-text.version>
<commons-io.version>2.6</commons-io.version>
<commons-compress.version>1.21</commons-compress.version>
<xz.version>1.9</xz.version>
<dagger.version>1.2.2</dagger.version>
<guava.version>28.1-jre</guava.version>
<hsqldb.version>2.5.1</hsqldb.version>
@ -449,6 +452,21 @@
<artifactId>commons-text</artifactId>
<version>${commons-text.version}</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>${commons-io.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>${commons-compress.version}</version>
</dependency>
<dependency>
<groupId>org.tukaani</groupId>
<artifactId>xz</artifactId>
<version>${xz.version}</version>
</dependency>
<!-- For bitset/bitmap compression -->
<dependency>
<groupId>io.druid</groupId>

View File

@ -0,0 +1,84 @@
package org.qortal.api.resource;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.api.ApiError;
import org.qortal.api.ApiExceptionFactory;
import org.qortal.api.Security;
import org.qortal.repository.Bootstrap;
import org.qortal.repository.DataException;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
@Path("/bootstrap")
@Tag(name = "Bootstrap")
public class BootstrapResource {
private static final Logger LOGGER = LogManager.getLogger(BootstrapResource.class);
@Context
HttpServletRequest request;
@POST
@Path("/create")
@Operation(
summary = "Create bootstrap",
description = "Builds a bootstrap file for distribution",
responses = {
@ApiResponse(
description = "path to file on success, an exception on failure",
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
)
}
)
public String createBootstrap() {
Security.checkApiCallAllowed(request);
Bootstrap bootstrap = new Bootstrap();
if (!bootstrap.canBootstrap()) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
}
boolean isBlockchainValid = bootstrap.validateBlockchain();
if (!isBlockchainValid) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE);
}
try {
return bootstrap.create();
} catch (DataException | InterruptedException | IOException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE);
}
}
@GET
@Path("/validate")
@Operation(
summary = "Validate blockchain",
description = "Useful to check database integrity prior to creating or after installing a bootstrap. " +
"This process is intensive and can take over an hour to run.",
responses = {
@ApiResponse(
description = "true if valid, false if invalid",
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
)
}
)
public boolean validateBootstrap() {
Security.checkApiCallAllowed(request);
Bootstrap bootstrap = new Bootstrap();
return bootstrap.validateCompleteBlockchain();
}
}

View File

@ -4,10 +4,7 @@ import java.io.File;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import javax.xml.bind.JAXBContext;
@ -27,11 +24,9 @@ import org.eclipse.persistence.jaxb.UnmarshallerProperties;
import org.qortal.controller.Controller;
import org.qortal.data.block.BlockData;
import org.qortal.network.Network;
import org.qortal.repository.BlockRepository;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.repository.*;
import org.qortal.settings.Settings;
import org.qortal.utils.Base58;
import org.qortal.utils.StringLongMapXmlAdapter;
/**
@ -506,23 +501,28 @@ public class BlockChain {
* @throws SQLException
*/
public static void validate() throws DataException {
BlockData chainTip;
try (final Repository repository = RepositoryManager.getRepository()) {
chainTip = repository.getBlockRepository().getLastBlock();
}
boolean pruningEnabled = Settings.getInstance().isPruningEnabled();
BlockData chainTip = repository.getBlockRepository().getLastBlock();
boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1);
boolean pruningEnabled = Settings.getInstance().isPruningEnabled();
boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1);
if (pruningEnabled && hasBlocks) {
// Pruning is enabled and we have blocks, so it's possible that the genesis block has been pruned
// It's best not to validate it, and there's no real need to
}
else {
// Check first block is Genesis Block
if (!isGenesisBlockValid()) {
rebuildBlockchain();
}
if (pruningEnabled && hasBlocks) {
// Pruning is enabled and we have blocks, so it's possible that the genesis block has been pruned
// It's best not to validate it, and there's no real need to
} else {
// Check first block is Genesis Block
if (!isGenesisBlockValid()) {
rebuildBlockchain();
}
}
// We need to create a new connection, as the previous repository and its connections may be been
// closed by rebuildBlockchain() if a bootstrap was applied
try (final Repository repository = RepositoryManager.getRepository()) {
repository.checkConsistency();
// Set the number of blocks to validate based on the pruned state of the chain
@ -615,6 +615,14 @@ public class BlockChain {
}
private static void rebuildBlockchain() throws DataException {
boolean shouldBootstrap = Settings.getInstance().getBootstrap();
if (shouldBootstrap) {
// Settings indicate that we should apply a bootstrap rather than rebuilding and syncing from genesis
Bootstrap bootstrap = new Bootstrap();
bootstrap.startImport();
return;
}
// (Re)build repository
if (!RepositoryManager.wasPristineAtOpen())
RepositoryManager.rebuild();

View File

@ -0,0 +1,362 @@
package org.qortal.repository;
import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.block.BlockChain;
import org.qortal.controller.Controller;
import org.qortal.data.account.MintingAccountData;
import org.qortal.data.block.BlockData;
import org.qortal.data.crosschain.TradeBotData;
import org.qortal.repository.hsqldb.HSQLDBImportExport;
import org.qortal.repository.hsqldb.HSQLDBRepository;
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
import org.qortal.settings.Settings;
import org.qortal.utils.NTP;
import org.qortal.utils.SevenZ;
import java.io.BufferedInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
public class Bootstrap {
private static final Logger LOGGER = LogManager.getLogger(Bootstrap.class);
/** The maximum number of untrimmed blocks allowed to be included in a bootstrap, beyond the trim threshold */
private static final int MAXIMUM_UNTRIMMED_BLOCKS = 100;
/** The maximum number of unpruned blocks allowed to be included in a bootstrap, beyond the prune threshold */
private static final int MAXIMUM_UNPRUNED_BLOCKS = 100;
public Bootstrap() {
}
/**
* canBootstrap()
* Performs basic initial checks to ensure everything is in order
* @return true if ready for bootstrap creation, or false if not
* All failure reasons are logged
*/
public boolean canBootstrap() {
LOGGER.info("Checking repository state...");
try (final Repository repository = RepositoryManager.getRepository()) {
final boolean pruningEnabled = Settings.getInstance().isPruningEnabled();
final boolean archiveEnabled = Settings.getInstance().isArchiveEnabled();
// Avoid creating bootstraps from pruned nodes until officially supported
if (pruningEnabled) {
LOGGER.info("Creating bootstraps from top-only nodes isn't yet supported.");
// TODO: add support for top-only bootstraps
return false;
}
// Require that a block archive has been built
if (!archiveEnabled) {
LOGGER.info("Unable to bootstrap because the block archive isn't enabled. " +
"Set {\"archivedEnabled\": true} in settings.json to fix.");
return false;
}
// Make sure that the block archiver is up to date
boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository);
if (!upToDate) {
LOGGER.info("Unable to bootstrap because the block archive isn't fully built yet.");
return false;
}
// Ensure that this database contains the ATStatesHeightIndex which was missing in some cases
boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex();
if (!hasAtStatesHeightIndex) {
LOGGER.info("Unable to bootstrap due to missing ATStatesHeightIndex. A re-sync from genesis is needed.");
return false;
}
// Ensure we have synced NTP time
if (NTP.getTime() == null) {
LOGGER.info("Unable to bootstrap because the node hasn't synced its time yet.");
return false;
}
// Ensure the chain is synced
final BlockData chainTip = Controller.getInstance().getChainTip();
final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp();
if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) {
LOGGER.info("Unable to bootstrap because the blockchain isn't fully synced.");
return false;
}
// FUTURE: ensure trim and prune settings are using default values
// Ensure that the online account signatures have been fully trimmed
final int accountsTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight();
final long accountsUpperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime();
final int accountsUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(accountsUpperTrimmableTimestamp);
final int accountsBlocksRemaining = accountsUpperTrimmableHeight - accountsTrimStartHeight;
if (accountsBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) {
LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " +
"then try again. Blocks remaining (online accounts signatures): {}", accountsBlocksRemaining);
return false;
}
// Ensure that the AT states data has been fully trimmed
final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight();
final long atUpperTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime();
final int atUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(atUpperTrimmableTimestamp);
final int atBlocksRemaining = atUpperTrimmableHeight - atTrimStartHeight;
if (atBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) {
LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " +
"then try again. Blocks remaining (AT states): {}", atBlocksRemaining);
return false;
}
// Ensure that blocks have been fully pruned
final int blockPruneStartHeight = repository.getBlockRepository().getBlockPruneHeight();
int blockUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit();
if (archiveEnabled) {
blockUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1;
}
final int blocksPruneRemaining = blockUpperPrunableHeight - blockPruneStartHeight;
if (blocksPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) {
LOGGER.info("Blockchain is not fully pruned. Please allow the node to run for longer, " +
"then try again. Blocks remaining: {}", blocksPruneRemaining);
return false;
}
// Ensure that AT states have been fully pruned
final int atPruneStartHeight = repository.getATRepository().getAtPruneHeight();
int atUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit();
if (archiveEnabled) {
atUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1;
}
final int atPruneRemaining = atUpperPrunableHeight - atPruneStartHeight;
if (atPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) {
LOGGER.info("Blockchain is not fully pruned. Please allow the node to run for longer, " +
"then try again. Blocks remaining (AT states): {}", atPruneRemaining);
return false;
}
LOGGER.info("Repository state checks passed");
return true;
}
catch (DataException e) {
LOGGER.info("Unable to create bootstrap: {}", e.getMessage());
return false;
}
}
/**
* validateBlockchain
* Performs quick validation of recent blocks in blockchain, prior to creating a bootstrap
* @return true if valid, false if not
*/
public boolean validateBlockchain() {
LOGGER.info("Validating blockchain...");
try {
BlockChain.validate();
LOGGER.info("Blockchain is valid");
return true;
} catch (DataException e) {
LOGGER.info("Blockchain validation failed: {}", e.getMessage());
return false;
}
}
/**
* validateCompleteBlockchain
* Performs intensive validation of all blocks in blockchain
* @return true if valid, false if not
*/
public boolean validateCompleteBlockchain() {
LOGGER.info("Validating blockchain...");
try {
// Perform basic startup validation
BlockChain.validate();
// Perform more intensive full-chain validation
BlockChain.validateAllBlocks();
LOGGER.info("Blockchain is valid");
return true;
} catch (DataException e) {
LOGGER.info("Blockchain validation failed: {}", e.getMessage());
return false;
}
}
public String create() throws DataException, InterruptedException, IOException {
try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) {
LOGGER.info("Acquiring blockchain lock...");
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
blockchainLock.lockInterruptibly();
Path inputPath = null;
try {
LOGGER.info("Exporting local data...");
repository.exportNodeLocalData();
LOGGER.info("Deleting trade bot states...");
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
for (TradeBotData tradeBotData : allTradeBotData) {
repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
}
LOGGER.info("Deleting minting accounts...");
List<MintingAccountData> mintingAccounts = repository.getAccountRepository().getMintingAccounts();
for (MintingAccountData mintingAccount : mintingAccounts) {
repository.getAccountRepository().delete(mintingAccount.getPrivateKey());
}
repository.saveChanges();
LOGGER.info("Performing repository maintenance...");
repository.performPeriodicMaintenance();
LOGGER.info("Creating bootstrap...");
repository.backup(true, "bootstrap");
LOGGER.info("Moving files to output directory...");
inputPath = Paths.get(Settings.getInstance().getRepositoryPath(), "bootstrap");
Path outputPath = Paths.get("bootstrap");
FileUtils.deleteDirectory(outputPath.toFile());
// Move the db backup to a "bootstrap" folder in the root directory
Files.move(inputPath, outputPath);
// Copy the archive folder to inside the bootstrap folder
FileUtils.copyDirectory(
Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toFile(),
Paths.get(outputPath.toString(), "archive").toFile()
);
LOGGER.info("Compressing...");
String fileName = "bootstrap.7z";
SevenZ.compress(fileName, outputPath.toFile());
// Return the path to the compressed bootstrap file
Path finalPath = Paths.get(outputPath.toString(), fileName);
return finalPath.toAbsolutePath().toString();
} finally {
LOGGER.info("Re-importing local data...");
Path exportPath = HSQLDBImportExport.getExportDirectory(false);
repository.importDataFromFile(Paths.get(exportPath.toString(), "TradeBotStates.json").toString());
repository.importDataFromFile(Paths.get(exportPath.toString(), "MintingAccounts.json").toString());
blockchainLock.unlock();
// Cleanup
if (inputPath != null) {
FileUtils.deleteDirectory(inputPath.toFile());
}
}
}
}
public void startImport() throws DataException {
Path path = null;
try {
Path tempDir = Files.createTempDirectory("qortal-bootstrap");
path = Paths.get(tempDir.toString(), "bootstrap.7z");
this.downloadToPath(path);
this.importFromPath(path);
} catch (InterruptedException | DataException | IOException e) {
throw new DataException(String.format("Unable to import bootstrap: %s", e.getMessage()));
}
finally {
if (path != null) {
try {
FileUtils.deleteDirectory(path.toFile());
} catch (IOException e) {
// Temp folder will be cleaned up by system anyway, so ignore this failure
}
}
}
}
private void downloadToPath(Path path) throws DataException {
String bootstrapUrl = "http://bootstrap.qortal.org/bootstrap.7z";
while (!Controller.isStopping()) {
try {
LOGGER.info("Downloading bootstrap...");
InputStream in = new URL(bootstrapUrl).openStream();
Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING);
break;
} catch (IOException e) {
LOGGER.info("Unable to download bootstrap: {}", e.getMessage());
LOGGER.info("Retrying in 5 minutes");
try {
Thread.sleep(5 * 60 * 1000L);
} catch (InterruptedException e2) {
break;
}
}
}
// It's best to throw an exception on all failures, even though we're most likely just stopping
throw new DataException("Unable to download bootstrap");
}
private void importFromPath(Path path) throws InterruptedException, DataException, IOException {
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
blockchainLock.lockInterruptibly();
try {
LOGGER.info("Extracting bootstrap...");
Path input = path.toAbsolutePath();
Path output = path.getParent().toAbsolutePath();
SevenZ.decompress(input.toString(), output.toFile());
LOGGER.info("Stopping repository...");
RepositoryManager.closeRepositoryFactory();
Path inputPath = Paths.get("bootstrap");
Path outputPath = Paths.get(Settings.getInstance().getRepositoryPath());
if (!inputPath.toFile().exists()) {
throw new DataException("Extracted bootstrap doesn't exist");
}
// Move the "bootstrap" folder in place of the "db" folder
LOGGER.info("Moving files to output directory...");
FileUtils.deleteDirectory(outputPath.toFile());
Files.move(inputPath, outputPath);
LOGGER.info("Starting repository from bootstrap...");
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
RepositoryManager.setRepositoryFactory(repositoryFactory);
}
finally {
blockchainLock.unlock();
}
}
}

View File

@ -50,9 +50,9 @@ public abstract class RepositoryManager {
repositoryFactory = null;
}
public static void backup(boolean quick) {
public static void backup(boolean quick, String name) {
try (final Repository repository = getRepository()) {
repository.backup(quick);
repository.backup(quick, name);
} catch (DataException e) {
// Backup is best-effort so don't complain
}

View File

@ -138,6 +138,10 @@ public class Settings {
private long archiveInterval = 7171L; // milliseconds
/** Whether to automatically bootstrap instead of syncing from genesis */
private boolean bootstrap = true;
// Peer-to-peer related
private boolean isTestNet = false;
/** Port number for inbound peer-to-peer connections. */
@ -603,4 +607,9 @@ public class Settings {
return this.archiveInterval;
}
public boolean getBootstrap() {
return this.bootstrap;
}
}

View File

@ -0,0 +1,77 @@
//
// Code originally written by memorynotfound
// https://memorynotfound.com/java-7z-seven-zip-example-compress-decompress-file/
// Modified Sept 2021 by Qortal Core dev team
//
package org.qortal.utils;
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
import org.apache.commons.compress.archivers.sevenz.SevenZOutputFile;
import java.io.*;
public class SevenZ {
private SevenZ() {
}
public static void compress(String name, File... files) throws IOException {
try (SevenZOutputFile out = new SevenZOutputFile(new File(name))){
for (File file : files){
addToArchiveCompression(out, file, ".");
}
}
}
public static void decompress(String in, File destination) throws IOException {
SevenZFile sevenZFile = new SevenZFile(new File(in));
SevenZArchiveEntry entry;
while ((entry = sevenZFile.getNextEntry()) != null){
if (entry.isDirectory()){
continue;
}
File curfile = new File(destination, entry.getName());
File parent = curfile.getParentFile();
if (!parent.exists()) {
parent.mkdirs();
}
FileOutputStream out = new FileOutputStream(curfile);
byte[] b = new byte[8192];
int count = 0;
while ((count = sevenZFile.read(b)) > 0) {
out.write(b, 0, count);
}
out.close();
}
}
private static void addToArchiveCompression(SevenZOutputFile out, File file, String dir) throws IOException {
String name = dir + File.separator + file.getName();
if (file.isFile()){
SevenZArchiveEntry entry = out.createArchiveEntry(file, name);
out.putArchiveEntry(entry);
FileInputStream in = new FileInputStream(file);
byte[] b = new byte[8192];
int count = 0;
while ((count = in.read(b)) > 0) {
out.write(b, 0, count);
}
out.closeArchiveEntry();
} else if (file.isDirectory()) {
File[] children = file.listFiles();
if (children != null){
for (File child : children){
addToArchiveCompression(out, child, name);
}
}
} else {
System.out.println(file.getName() + " is not supported");
}
}
}

View File

@ -4,6 +4,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0

View File

@ -4,6 +4,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0,

View File

@ -2,6 +2,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-founder-rewards.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0

View File

@ -2,6 +2,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-leftover-reward.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0

View File

@ -2,6 +2,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-minting.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0

View File

@ -2,6 +2,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-qora-holder-extremes.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0

View File

@ -2,6 +2,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-qora-holder.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0

View File

@ -2,6 +2,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-reward-levels.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0

View File

@ -2,6 +2,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-reward-scaling.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0

View File

@ -4,6 +4,7 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2.json",
"exportPath": "qortal-backup-test",
"bootstrap": false,
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0