diff --git a/.gitignore b/.gitignore
index e26d6244..fcc42db9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,7 @@
/WindowsInstaller/Install Files/qortal.jar
/*.7z
/tmp
+/wallets
/data*
/src/test/resources/arbitrary/*/.qortal/cache
apikey.txt
diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip
index 722d881e..0e3d5791 100755
--- a/WindowsInstaller/Qortal.aip
+++ b/WindowsInstaller/Qortal.aip
@@ -17,10 +17,10 @@
-
+
-
+
@@ -212,7 +212,7 @@
-
+
diff --git a/pom.xml b/pom.xml
index 7e293708..224640df 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
4.0.0org.qortalqortal
- 3.2.5
+ 3.3.2jartrue
diff --git a/src/main/java/org/qortal/ApplyUpdate.java b/src/main/java/org/qortal/ApplyUpdate.java
index 90171191..796bf580 100644
--- a/src/main/java/org/qortal/ApplyUpdate.java
+++ b/src/main/java/org/qortal/ApplyUpdate.java
@@ -37,7 +37,7 @@ public class ApplyUpdate {
private static final String JAVA_TOOL_OPTIONS_NAME = "JAVA_TOOL_OPTIONS";
private static final String JAVA_TOOL_OPTIONS_VALUE = "-XX:MaxRAMFraction=4";
- private static final long CHECK_INTERVAL = 10 * 1000L; // ms
+ private static final long CHECK_INTERVAL = 30 * 1000L; // ms
private static final int MAX_ATTEMPTS = 12;
public static void main(String[] args) {
diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java
index df14d88f..c3a25fb6 100644
--- a/src/main/java/org/qortal/account/Account.java
+++ b/src/main/java/org/qortal/account/Account.java
@@ -8,11 +8,13 @@ import javax.xml.bind.annotation.XmlAccessorType;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.block.BlockChain;
+import org.qortal.controller.LiteNode;
import org.qortal.data.account.AccountBalanceData;
import org.qortal.data.account.AccountData;
import org.qortal.data.account.RewardShareData;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
+import org.qortal.settings.Settings;
import org.qortal.utils.Base58;
@XmlAccessorType(XmlAccessType.NONE) // Stops JAX-RS errors when unmarshalling blockchain config
@@ -59,7 +61,17 @@ public class Account {
// Balance manipulations - assetId is 0 for QORT
public long getConfirmedBalance(long assetId) throws DataException {
- AccountBalanceData accountBalanceData = this.repository.getAccountRepository().getBalance(this.address, assetId);
+ AccountBalanceData accountBalanceData;
+
+ if (Settings.getInstance().isLite()) {
+ // Lite nodes request data from peers instead of the local db
+ accountBalanceData = LiteNode.getInstance().fetchAccountBalance(this.address, assetId);
+ }
+ else {
+ // All other node types fetch from the local db
+ accountBalanceData = this.repository.getAccountRepository().getBalance(this.address, assetId);
+ }
+
if (accountBalanceData == null)
return 0;
diff --git a/src/main/java/org/qortal/api/model/NodeInfo.java b/src/main/java/org/qortal/api/model/NodeInfo.java
index 16a4df75..6732357a 100644
--- a/src/main/java/org/qortal/api/model/NodeInfo.java
+++ b/src/main/java/org/qortal/api/model/NodeInfo.java
@@ -12,6 +12,7 @@ public class NodeInfo {
public long buildTimestamp;
public String nodeId;
public boolean isTestNet;
+ public String type;
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 b5268db7..4de8d908 100644
--- a/src/main/java/org/qortal/api/resource/AddressesResource.java
+++ b/src/main/java/org/qortal/api/resource/AddressesResource.java
@@ -30,6 +30,7 @@ import org.qortal.api.Security;
import org.qortal.api.model.ApiOnlineAccount;
import org.qortal.api.model.RewardShareKeyRequest;
import org.qortal.asset.Asset;
+import org.qortal.controller.LiteNode;
import org.qortal.controller.OnlineAccountsManager;
import org.qortal.crypto.Crypto;
import org.qortal.data.account.AccountData;
@@ -109,18 +110,26 @@ public class AddressesResource {
if (!Crypto.isValidAddress(address))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
- byte[] lastReference = null;
+ AccountData accountData;
- try (final Repository repository = RepositoryManager.getRepository()) {
- AccountData accountData = repository.getAccountRepository().getAccount(address);
- // Not found?
- if (accountData == null)
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
-
- lastReference = accountData.getReference();
- } catch (DataException e) {
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ if (Settings.getInstance().isLite()) {
+ // Lite nodes request data from peers instead of the local db
+ accountData = LiteNode.getInstance().fetchAccountData(address);
}
+ else {
+ // All other node types request data from local db
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ accountData = repository.getAccountRepository().getAccount(address);
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+ // Not found?
+ if (accountData == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
+
+ byte[] lastReference = accountData.getReference();
if (lastReference == null || lastReference.length == 0)
return "false";
diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java
index efb47acf..bf7294ab 100644
--- a/src/main/java/org/qortal/api/resource/AdminResource.java
+++ b/src/main/java/org/qortal/api/resource/AdminResource.java
@@ -119,10 +119,23 @@ public class AdminResource {
nodeInfo.buildTimestamp = Controller.getInstance().getBuildTimestamp();
nodeInfo.nodeId = Network.getInstance().getOurNodeId();
nodeInfo.isTestNet = Settings.getInstance().isTestNet();
+ nodeInfo.type = getNodeType();
return nodeInfo;
}
+ private String getNodeType() {
+ if (Settings.getInstance().isTopOnly()) {
+ return "topOnly";
+ }
+ else if (Settings.getInstance().isLite()) {
+ return "lite";
+ }
+ else {
+ return "full";
+ }
+ }
+
@GET
@Path("/status")
@Operation(
diff --git a/src/main/java/org/qortal/api/resource/NamesResource.java b/src/main/java/org/qortal/api/resource/NamesResource.java
index e380ab55..a900d6bf 100644
--- a/src/main/java/org/qortal/api/resource/NamesResource.java
+++ b/src/main/java/org/qortal/api/resource/NamesResource.java
@@ -26,6 +26,7 @@ import org.qortal.api.ApiErrors;
import org.qortal.api.ApiException;
import org.qortal.api.ApiExceptionFactory;
import org.qortal.api.model.NameSummary;
+import org.qortal.controller.LiteNode;
import org.qortal.crypto.Crypto;
import org.qortal.data.naming.NameData;
import org.qortal.data.transaction.BuyNameTransactionData;
@@ -101,7 +102,14 @@ public class NamesResource {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
try (final Repository repository = RepositoryManager.getRepository()) {
- List names = repository.getNameRepository().getNamesByOwner(address, limit, offset, reverse);
+ List names;
+
+ if (Settings.getInstance().isLite()) {
+ names = LiteNode.getInstance().fetchAccountNames(address);
+ }
+ else {
+ names = repository.getNameRepository().getNamesByOwner(address, limit, offset, reverse);
+ }
return names.stream().map(NameSummary::new).collect(Collectors.toList());
} catch (DataException e) {
@@ -126,10 +134,18 @@ public class NamesResource {
@ApiErrors({ApiError.NAME_UNKNOWN, ApiError.REPOSITORY_ISSUE})
public NameData getName(@PathParam("name") String name) {
try (final Repository repository = RepositoryManager.getRepository()) {
- NameData nameData = repository.getNameRepository().fromName(name);
+ NameData nameData;
- if (nameData == null)
+ if (Settings.getInstance().isLite()) {
+ nameData = LiteNode.getInstance().fetchNameData(name);
+ }
+ else {
+ nameData = repository.getNameRepository().fromName(name);
+ }
+
+ if (nameData == null) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NAME_UNKNOWN);
+ }
return nameData;
} catch (ApiException e) {
diff --git a/src/main/java/org/qortal/api/resource/TransactionsResource.java b/src/main/java/org/qortal/api/resource/TransactionsResource.java
index 55ad7cde..4c440304 100644
--- a/src/main/java/org/qortal/api/resource/TransactionsResource.java
+++ b/src/main/java/org/qortal/api/resource/TransactionsResource.java
@@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
@@ -32,6 +33,8 @@ import org.qortal.api.ApiException;
import org.qortal.api.ApiExceptionFactory;
import org.qortal.api.model.SimpleTransactionSignRequest;
import org.qortal.controller.Controller;
+import org.qortal.controller.LiteNode;
+import org.qortal.crypto.Crypto;
import org.qortal.data.transaction.TransactionData;
import org.qortal.globalization.Translator;
import org.qortal.repository.DataException;
@@ -250,14 +253,29 @@ public class TransactionsResource {
ApiError.REPOSITORY_ISSUE
})
public List getUnconfirmedTransactions(@Parameter(
+ description = "A list of transaction types"
+ ) @QueryParam("txType") List txTypes, @Parameter(
+ description = "Transaction creator's base58 encoded public key"
+ ) @QueryParam("creator") String creatorPublicKey58, @Parameter(
ref = "limit"
) @QueryParam("limit") Integer limit, @Parameter(
ref = "offset"
) @QueryParam("offset") Integer offset, @Parameter(
ref = "reverse"
) @QueryParam("reverse") Boolean reverse) {
+
+ // Decode public key if supplied
+ byte[] creatorPublicKey = null;
+ if (creatorPublicKey58 != null) {
+ try {
+ creatorPublicKey = Base58.decode(creatorPublicKey58);
+ } catch (NumberFormatException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY, e);
+ }
+ }
+
try (final Repository repository = RepositoryManager.getRepository()) {
- return repository.getTransactionRepository().getUnconfirmedTransactions(limit, offset, reverse);
+ return repository.getTransactionRepository().getUnconfirmedTransactions(txTypes, creatorPublicKey, limit, offset, reverse);
} catch (ApiException e) {
throw e;
} catch (DataException e) {
@@ -366,6 +384,73 @@ public class TransactionsResource {
}
}
+ @GET
+ @Path("/address/{address}")
+ @Operation(
+ summary = "Returns transactions for given address",
+ responses = {
+ @ApiResponse(
+ description = "transactions",
+ content = @Content(
+ array = @ArraySchema(
+ schema = @Schema(
+ implementation = TransactionData.class
+ )
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
+ public List getAddressTransactions(@PathParam("address") String address,
+ @Parameter(ref = "limit") @QueryParam("limit") Integer limit,
+ @Parameter(ref = "offset") @QueryParam("offset") Integer offset,
+ @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
+ if (!Crypto.isValidAddress(address)) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
+ }
+
+ if (limit == null) {
+ limit = 0;
+ }
+ if (offset == null) {
+ offset = 0;
+ }
+
+ List transactions;
+
+ if (Settings.getInstance().isLite()) {
+ // Fetch from network
+ transactions = LiteNode.getInstance().fetchAccountTransactions(address, limit, offset);
+
+ // Sort the data, since we can't guarantee the order that a peer sent it in
+ if (reverse) {
+ transactions.sort(Comparator.comparingLong(TransactionData::getTimestamp).reversed());
+ } else {
+ transactions.sort(Comparator.comparingLong(TransactionData::getTimestamp));
+ }
+ }
+ else {
+ // Fetch from local db
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null,
+ null, null, null, address, TransactionsResource.ConfirmationStatus.CONFIRMED, limit, offset, reverse);
+
+ // Expand signatures to transactions
+ transactions = new ArrayList<>(signatures.size());
+ for (byte[] signature : signatures) {
+ transactions.add(repository.getTransactionRepository().fromSignature(signature));
+ }
+ } catch (ApiException e) {
+ throw e;
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+ return transactions;
+ }
+
@GET
@Path("/unitfee")
@Operation(
diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java
index 9be4f145..1e86ee98 100644
--- a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java
+++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java
@@ -93,17 +93,10 @@ public class ArbitraryDataFile {
File outputFile = outputFilePath.toFile();
try (FileOutputStream outputStream = new FileOutputStream(outputFile)) {
outputStream.write(fileContent);
- outputStream.close();
this.filePath = outputFilePath;
- // Verify hash
- String digest58 = this.digest58();
- if (!this.hash58.equals(digest58)) {
- LOGGER.error("Hash {} does not match file digest {} for signature: {}", this.hash58, digest58, Base58.encode(signature));
- this.delete();
- throw new DataException("Data file digest validation failed");
- }
} catch (IOException e) {
- throw new DataException("Unable to write data to file");
+ this.delete();
+ throw new DataException(String.format("Unable to write data with hash %s: %s", this.hash58, e.getMessage()));
}
}
diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java
index 135708ab..b54c9613 100644
--- a/src/main/java/org/qortal/block/BlockChain.java
+++ b/src/main/java/org/qortal/block/BlockChain.java
@@ -69,7 +69,8 @@ public class BlockChain {
newBlockSigHeight,
shareBinFix,
calcChainWeightTimestamp,
- transactionV5Timestamp;
+ transactionV5Timestamp,
+ transactionV6Timestamp;
}
// Custom transaction fees
@@ -414,6 +415,10 @@ public class BlockChain {
return this.featureTriggers.get(FeatureTrigger.transactionV5Timestamp.name()).longValue();
}
+ public long getTransactionV6Timestamp() {
+ return this.featureTriggers.get(FeatureTrigger.transactionV6Timestamp.name()).longValue();
+ }
+
// More complex getters for aspects that change by height or timestamp
public long getRewardAtHeight(int ourHeight) {
diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java
index 04797314..9966d6a9 100644
--- a/src/main/java/org/qortal/controller/BlockMinter.java
+++ b/src/main/java/org/qortal/controller/BlockMinter.java
@@ -61,6 +61,11 @@ public class BlockMinter extends Thread {
public void run() {
Thread.currentThread().setName("BlockMinter");
+ if (Settings.getInstance().isLite()) {
+ // Lite nodes do not mint
+ return;
+ }
+
try (final Repository repository = RepositoryManager.getRepository()) {
if (Settings.getInstance().getWipeUnconfirmedOnStart()) {
// Wipe existing unconfirmed transactions
diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java
index 0690af92..a5ada0c2 100644
--- a/src/main/java/org/qortal/controller/Controller.java
+++ b/src/main/java/org/qortal/controller/Controller.java
@@ -32,6 +32,7 @@ import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
import org.qortal.api.ApiService;
import org.qortal.api.DomainMapService;
import org.qortal.api.GatewayService;
+import org.qortal.api.resource.TransactionsResource;
import org.qortal.block.Block;
import org.qortal.block.BlockChain;
import org.qortal.block.BlockChain.BlockTimingByHeight;
@@ -39,8 +40,11 @@ import org.qortal.controller.arbitrary.*;
import org.qortal.controller.repository.PruneManager;
import org.qortal.controller.repository.NamesDatabaseIntegrityCheck;
import org.qortal.controller.tradebot.TradeBot;
+import org.qortal.data.account.AccountBalanceData;
+import org.qortal.data.account.AccountData;
import org.qortal.data.block.BlockData;
import org.qortal.data.block.BlockSummaryData;
+import org.qortal.data.naming.NameData;
import org.qortal.data.network.PeerChainTipData;
import org.qortal.data.network.PeerData;
import org.qortal.data.transaction.ChatTransactionData;
@@ -179,6 +183,52 @@ public class Controller extends Thread {
}
public GetArbitraryMetadataMessageStats getArbitraryMetadataMessageStats = new GetArbitraryMetadataMessageStats();
+ public static class GetAccountMessageStats {
+ public AtomicLong requests = new AtomicLong();
+ public AtomicLong cacheHits = new AtomicLong();
+ public AtomicLong unknownAccounts = new AtomicLong();
+
+ public GetAccountMessageStats() {
+ }
+ }
+ public GetAccountMessageStats getAccountMessageStats = new GetAccountMessageStats();
+
+ public static class GetAccountBalanceMessageStats {
+ public AtomicLong requests = new AtomicLong();
+ public AtomicLong unknownAccounts = new AtomicLong();
+
+ public GetAccountBalanceMessageStats() {
+ }
+ }
+ public GetAccountBalanceMessageStats getAccountBalanceMessageStats = new GetAccountBalanceMessageStats();
+
+ public static class GetAccountTransactionsMessageStats {
+ public AtomicLong requests = new AtomicLong();
+ public AtomicLong unknownAccounts = new AtomicLong();
+
+ public GetAccountTransactionsMessageStats() {
+ }
+ }
+ public GetAccountTransactionsMessageStats getAccountTransactionsMessageStats = new GetAccountTransactionsMessageStats();
+
+ public static class GetAccountNamesMessageStats {
+ public AtomicLong requests = new AtomicLong();
+ public AtomicLong unknownAccounts = new AtomicLong();
+
+ public GetAccountNamesMessageStats() {
+ }
+ }
+ public GetAccountNamesMessageStats getAccountNamesMessageStats = new GetAccountNamesMessageStats();
+
+ public static class GetNameMessageStats {
+ public AtomicLong requests = new AtomicLong();
+ public AtomicLong unknownAccounts = new AtomicLong();
+
+ public GetNameMessageStats() {
+ }
+ }
+ public GetNameMessageStats getNameMessageStats = new GetNameMessageStats();
+
public AtomicLong latestBlocksCacheRefills = new AtomicLong();
public StatsSnapshot() {
@@ -363,23 +413,27 @@ public class Controller extends Thread {
return; // Not System.exit() so that GUI can display error
}
- // Rebuild Names table and check database integrity (if enabled)
- NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck();
- namesDatabaseIntegrityCheck.rebuildAllNames();
- if (Settings.getInstance().isNamesIntegrityCheckEnabled()) {
- namesDatabaseIntegrityCheck.runIntegrityCheck();
- }
+ // If we have a non-lite node, we need to perform some startup actions
+ if (!Settings.getInstance().isLite()) {
- LOGGER.info("Validating blockchain");
- try {
- BlockChain.validate();
+ // Rebuild Names table and check database integrity (if enabled)
+ NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck();
+ namesDatabaseIntegrityCheck.rebuildAllNames();
+ if (Settings.getInstance().isNamesIntegrityCheckEnabled()) {
+ namesDatabaseIntegrityCheck.runIntegrityCheck();
+ }
- 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);
- return; // Not System.exit() so that GUI can display error
+ LOGGER.info("Validating blockchain");
+ try {
+ BlockChain.validate();
+
+ 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);
+ return; // Not System.exit() so that GUI can display error
+ }
}
// Import current trade bot states and minting accounts if they exist
@@ -737,7 +791,11 @@ public class Controller extends Thread {
final Long minLatestBlockTimestamp = NTP.getTime() - (30 * 60 * 1000L);
synchronized (Synchronizer.getInstance().syncLock) {
- if (this.isMintingPossible) {
+ if (Settings.getInstance().isLite()) {
+ actionText = Translator.INSTANCE.translate("SysTray", "LITE_NODE");
+ SysTray.getInstance().setTrayIcon(4);
+ }
+ else if (this.isMintingPossible) {
actionText = Translator.INSTANCE.translate("SysTray", "MINTING_ENABLED");
SysTray.getInstance().setTrayIcon(2);
}
@@ -759,7 +817,11 @@ public class Controller extends Thread {
}
}
- String tooltip = String.format("%s - %d %s - %s %d", actionText, numberOfPeers, connectionsText, heightText, height) + "\n" + String.format("%s: %s", Translator.INSTANCE.translate("SysTray", "BUILD_VERSION"), this.buildVersion);
+ String tooltip = String.format("%s - %d %s", actionText, numberOfPeers, connectionsText);
+ if (!Settings.getInstance().isLite()) {
+ tooltip = tooltip.concat(String.format(" - %s %d", heightText, height));
+ }
+ tooltip = tooltip.concat(String.format("\n%s: %s", Translator.INSTANCE.translate("SysTray", "BUILD_VERSION"), this.buildVersion));
SysTray.getInstance().setToolTipText(tooltip);
this.callbackExecutor.execute(() -> {
@@ -916,6 +978,11 @@ public class Controller extends Thread {
// Callbacks for/from network
public void doNetworkBroadcast() {
+ if (Settings.getInstance().isLite()) {
+ // Lite nodes have nothing to broadcast
+ return;
+ }
+
Network network = Network.getInstance();
// Send (if outbound) / Request peer lists
@@ -1198,6 +1265,26 @@ public class Controller extends Thread {
TradeBot.getInstance().onTradePresencesMessage(peer, message);
break;
+ case GET_ACCOUNT:
+ onNetworkGetAccountMessage(peer, message);
+ break;
+
+ case GET_ACCOUNT_BALANCE:
+ onNetworkGetAccountBalanceMessage(peer, message);
+ break;
+
+ case GET_ACCOUNT_TRANSACTIONS:
+ onNetworkGetAccountTransactionsMessage(peer, message);
+ break;
+
+ case GET_ACCOUNT_NAMES:
+ onNetworkGetAccountNamesMessage(peer, message);
+ break;
+
+ case GET_NAME:
+ onNetworkGetNameMessage(peer, message);
+ break;
+
default:
LOGGER.debug(() -> String.format("Unhandled %s message [ID %d] from peer %s", message.getType().name(), message.getId(), peer));
break;
@@ -1434,11 +1521,13 @@ public class Controller extends Thread {
private void onNetworkHeightV2Message(Peer peer, Message message) {
HeightV2Message heightV2Message = (HeightV2Message) message;
- // If peer is inbound and we've not updated their height
- // then this is probably their initial HEIGHT_V2 message
- // so they need a corresponding HEIGHT_V2 message from us
- if (!peer.isOutbound() && (peer.getChainTipData() == null || peer.getChainTipData().getLastHeight() == null))
- peer.sendMessage(Network.getInstance().buildHeightMessage(peer, getChainTip()));
+ if (!Settings.getInstance().isLite()) {
+ // If peer is inbound and we've not updated their height
+ // then this is probably their initial HEIGHT_V2 message
+ // so they need a corresponding HEIGHT_V2 message from us
+ if (!peer.isOutbound() && (peer.getChainTipData() == null || peer.getChainTipData().getLastHeight() == null))
+ peer.sendMessage(Network.getInstance().buildHeightMessage(peer, getChainTip()));
+ }
// Update peer chain tip data
PeerChainTipData newChainTipData = new PeerChainTipData(heightV2Message.getHeight(), heightV2Message.getSignature(), heightV2Message.getTimestamp(), heightV2Message.getMinterPublicKey());
@@ -1448,6 +1537,193 @@ public class Controller extends Thread {
Synchronizer.getInstance().requestSync();
}
+ private void onNetworkGetAccountMessage(Peer peer, Message message) {
+ GetAccountMessage getAccountMessage = (GetAccountMessage) message;
+ String address = getAccountMessage.getAddress();
+ this.stats.getAccountMessageStats.requests.incrementAndGet();
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ AccountData accountData = repository.getAccountRepository().getAccount(address);
+
+ if (accountData == null) {
+ // We don't have this account
+ this.stats.getAccountMessageStats.unknownAccounts.getAndIncrement();
+
+ // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout
+ LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT request for unknown account %s", peer, address));
+
+ // We'll send empty block summaries message as it's very short
+ Message accountUnknownMessage = new BlockSummariesMessage(Collections.emptyList());
+ accountUnknownMessage.setId(message.getId());
+ if (!peer.sendMessage(accountUnknownMessage))
+ peer.disconnect("failed to send account-unknown response");
+ return;
+ }
+
+ AccountMessage accountMessage = new AccountMessage(accountData);
+ accountMessage.setId(message.getId());
+
+ if (!peer.sendMessage(accountMessage)) {
+ peer.disconnect("failed to send account");
+ }
+
+ } catch (DataException e) {
+ LOGGER.error(String.format("Repository issue while send account %s to peer %s", address, peer), e);
+ }
+ }
+
+ private void onNetworkGetAccountBalanceMessage(Peer peer, Message message) {
+ GetAccountBalanceMessage getAccountBalanceMessage = (GetAccountBalanceMessage) message;
+ String address = getAccountBalanceMessage.getAddress();
+ long assetId = getAccountBalanceMessage.getAssetId();
+ this.stats.getAccountBalanceMessageStats.requests.incrementAndGet();
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ AccountBalanceData accountBalanceData = repository.getAccountRepository().getBalance(address, assetId);
+
+ if (accountBalanceData == null) {
+ // We don't have this account
+ this.stats.getAccountBalanceMessageStats.unknownAccounts.getAndIncrement();
+
+ // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout
+ LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT_BALANCE request for unknown account %s and asset ID %d", peer, address, assetId));
+
+ // We'll send empty block summaries message as it's very short
+ Message accountUnknownMessage = new BlockSummariesMessage(Collections.emptyList());
+ accountUnknownMessage.setId(message.getId());
+ if (!peer.sendMessage(accountUnknownMessage))
+ peer.disconnect("failed to send account-unknown response");
+ return;
+ }
+
+ AccountBalanceMessage accountMessage = new AccountBalanceMessage(accountBalanceData);
+ accountMessage.setId(message.getId());
+
+ if (!peer.sendMessage(accountMessage)) {
+ peer.disconnect("failed to send account balance");
+ }
+
+ } catch (DataException e) {
+ LOGGER.error(String.format("Repository issue while send balance for account %s and asset ID %d to peer %s", address, assetId, peer), e);
+ }
+ }
+
+ private void onNetworkGetAccountTransactionsMessage(Peer peer, Message message) {
+ GetAccountTransactionsMessage getAccountTransactionsMessage = (GetAccountTransactionsMessage) message;
+ String address = getAccountTransactionsMessage.getAddress();
+ int limit = Math.min(getAccountTransactionsMessage.getLimit(), 100);
+ int offset = getAccountTransactionsMessage.getOffset();
+ this.stats.getAccountTransactionsMessageStats.requests.incrementAndGet();
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null,
+ null, null, null, address, TransactionsResource.ConfirmationStatus.CONFIRMED, limit, offset, false);
+
+ // Expand signatures to transactions
+ List transactions = new ArrayList<>(signatures.size());
+ for (byte[] signature : signatures) {
+ transactions.add(repository.getTransactionRepository().fromSignature(signature));
+ }
+
+ if (transactions == null) {
+ // We don't have this account
+ this.stats.getAccountTransactionsMessageStats.unknownAccounts.getAndIncrement();
+
+ // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout
+ LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT_TRANSACTIONS request for unknown account %s", peer, address));
+
+ // We'll send empty block summaries message as it's very short
+ Message accountUnknownMessage = new BlockSummariesMessage(Collections.emptyList());
+ accountUnknownMessage.setId(message.getId());
+ if (!peer.sendMessage(accountUnknownMessage))
+ peer.disconnect("failed to send account-unknown response");
+ return;
+ }
+
+ TransactionsMessage transactionsMessage = new TransactionsMessage(transactions);
+ transactionsMessage.setId(message.getId());
+
+ if (!peer.sendMessage(transactionsMessage)) {
+ peer.disconnect("failed to send account transactions");
+ }
+
+ } catch (DataException e) {
+ LOGGER.error(String.format("Repository issue while sending transactions for account %s %d to peer %s", address, peer), e);
+ } catch (MessageException e) {
+ LOGGER.error(String.format("Message serialization issue while sending transactions for account %s %d to peer %s", address, peer), e);
+ }
+ }
+
+ private void onNetworkGetAccountNamesMessage(Peer peer, Message message) {
+ GetAccountNamesMessage getAccountNamesMessage = (GetAccountNamesMessage) message;
+ String address = getAccountNamesMessage.getAddress();
+ this.stats.getAccountNamesMessageStats.requests.incrementAndGet();
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ List namesDataList = repository.getNameRepository().getNamesByOwner(address);
+
+ if (namesDataList == null) {
+ // We don't have this account
+ this.stats.getAccountNamesMessageStats.unknownAccounts.getAndIncrement();
+
+ // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout
+ LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT_NAMES request for unknown account %s", peer, address));
+
+ // We'll send empty block summaries message as it's very short
+ Message accountUnknownMessage = new BlockSummariesMessage(Collections.emptyList());
+ accountUnknownMessage.setId(message.getId());
+ if (!peer.sendMessage(accountUnknownMessage))
+ peer.disconnect("failed to send account-unknown response");
+ return;
+ }
+
+ NamesMessage namesMessage = new NamesMessage(namesDataList);
+ namesMessage.setId(message.getId());
+
+ if (!peer.sendMessage(namesMessage)) {
+ peer.disconnect("failed to send account names");
+ }
+
+ } catch (DataException e) {
+ LOGGER.error(String.format("Repository issue while send names for account %s to peer %s", address, peer), e);
+ }
+ }
+
+ private void onNetworkGetNameMessage(Peer peer, Message message) {
+ GetNameMessage getNameMessage = (GetNameMessage) message;
+ String name = getNameMessage.getName();
+ this.stats.getNameMessageStats.requests.incrementAndGet();
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ NameData nameData = repository.getNameRepository().fromName(name);
+
+ if (nameData == null) {
+ // We don't have this account
+ this.stats.getNameMessageStats.unknownAccounts.getAndIncrement();
+
+ // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout
+ LOGGER.debug(() -> String.format("Sending 'name unknown' response to peer %s for GET_NAME request for unknown name %s", peer, name));
+
+ // We'll send empty block summaries message as it's very short
+ Message nameUnknownMessage = new BlockSummariesMessage(Collections.emptyList());
+ nameUnknownMessage.setId(message.getId());
+ if (!peer.sendMessage(nameUnknownMessage))
+ peer.disconnect("failed to send name-unknown response");
+ return;
+ }
+
+ NamesMessage namesMessage = new NamesMessage(Arrays.asList(nameData));
+ namesMessage.setId(message.getId());
+
+ if (!peer.sendMessage(namesMessage)) {
+ peer.disconnect("failed to send name data");
+ }
+
+ } catch (DataException e) {
+ LOGGER.error(String.format("Repository issue while send name %s to peer %s", name, peer), e);
+ }
+ }
+
// Utilities
@@ -1499,6 +1775,11 @@ public class Controller extends Thread {
* @return boolean - whether our node's blockchain is up to date or not
*/
public boolean isUpToDate(Long minLatestBlockTimestamp) {
+ if (Settings.getInstance().isLite()) {
+ // Lite nodes are always "up to date"
+ return true;
+ }
+
// Do we even have a vaguely recent block?
if (minLatestBlockTimestamp == null)
return false;
diff --git a/src/main/java/org/qortal/controller/LiteNode.java b/src/main/java/org/qortal/controller/LiteNode.java
new file mode 100644
index 00000000..028fa36b
--- /dev/null
+++ b/src/main/java/org/qortal/controller/LiteNode.java
@@ -0,0 +1,189 @@
+package org.qortal.controller;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.qortal.data.account.AccountBalanceData;
+import org.qortal.data.account.AccountData;
+import org.qortal.data.naming.NameData;
+import org.qortal.data.transaction.TransactionData;
+import org.qortal.network.Network;
+import org.qortal.network.Peer;
+import org.qortal.network.message.*;
+
+import java.security.SecureRandom;
+import java.util.*;
+
+import static org.qortal.network.message.MessageType.*;
+
+public class LiteNode {
+
+ private static final Logger LOGGER = LogManager.getLogger(LiteNode.class);
+
+ private static LiteNode instance;
+
+
+ public Map pendingRequests = Collections.synchronizedMap(new HashMap<>());
+
+ public int MAX_TRANSACTIONS_PER_MESSAGE = 100;
+
+
+ public LiteNode() {
+
+ }
+
+ public static synchronized LiteNode getInstance() {
+ if (instance == null) {
+ instance = new LiteNode();
+ }
+
+ return instance;
+ }
+
+
+ /**
+ * Fetch account data from peers for given QORT address
+ * @param address - the QORT address to query
+ * @return accountData - the account data for this address, or null if not retrieved
+ */
+ public AccountData fetchAccountData(String address) {
+ GetAccountMessage getAccountMessage = new GetAccountMessage(address);
+ AccountMessage accountMessage = (AccountMessage) this.sendMessage(getAccountMessage, ACCOUNT);
+ if (accountMessage == null) {
+ return null;
+ }
+ return accountMessage.getAccountData();
+ }
+
+ /**
+ * Fetch account balance data from peers for given QORT address and asset ID
+ * @param address - the QORT address to query
+ * @return balance - the balance for this address and assetId, or null if not retrieved
+ */
+ public AccountBalanceData fetchAccountBalance(String address, long assetId) {
+ GetAccountBalanceMessage getAccountMessage = new GetAccountBalanceMessage(address, assetId);
+ AccountBalanceMessage accountMessage = (AccountBalanceMessage) this.sendMessage(getAccountMessage, ACCOUNT_BALANCE);
+ if (accountMessage == null) {
+ return null;
+ }
+ return accountMessage.getAccountBalanceData();
+ }
+
+ /**
+ * Fetch list of transactions for given QORT address
+ * @param address - the QORT address to query
+ * @param limit - the maximum number of results to return
+ * @param offset - the starting index
+ * @return a list of TransactionData objects, or null if not retrieved
+ */
+ public List fetchAccountTransactions(String address, int limit, int offset) {
+ List allTransactions = new ArrayList<>();
+ if (limit == 0) {
+ limit = Integer.MAX_VALUE;
+ }
+ int batchSize = Math.min(limit, MAX_TRANSACTIONS_PER_MESSAGE);
+
+ while (allTransactions.size() < limit) {
+ GetAccountTransactionsMessage getAccountTransactionsMessage = new GetAccountTransactionsMessage(address, batchSize, offset);
+ TransactionsMessage transactionsMessage = (TransactionsMessage) this.sendMessage(getAccountTransactionsMessage, TRANSACTIONS);
+ if (transactionsMessage == null) {
+ // An error occurred, so give up instead of returning partial results
+ return null;
+ }
+ allTransactions.addAll(transactionsMessage.getTransactions());
+ if (transactionsMessage.getTransactions().size() < batchSize) {
+ // No more transactions to fetch
+ break;
+ }
+ offset += batchSize;
+ }
+ return allTransactions;
+ }
+
+ /**
+ * Fetch list of names for given QORT address
+ * @param address - the QORT address to query
+ * @return a list of NameData objects, or null if not retrieved
+ */
+ public List fetchAccountNames(String address) {
+ GetAccountNamesMessage getAccountNamesMessage = new GetAccountNamesMessage(address);
+ NamesMessage namesMessage = (NamesMessage) this.sendMessage(getAccountNamesMessage, NAMES);
+ if (namesMessage == null) {
+ return null;
+ }
+ return namesMessage.getNameDataList();
+ }
+
+ /**
+ * Fetch info about a registered name
+ * @param name - the name to query
+ * @return a NameData object, or null if not retrieved
+ */
+ public NameData fetchNameData(String name) {
+ GetNameMessage getNameMessage = new GetNameMessage(name);
+ NamesMessage namesMessage = (NamesMessage) this.sendMessage(getNameMessage, NAMES);
+ if (namesMessage == null) {
+ return null;
+ }
+ List nameDataList = namesMessage.getNameDataList();
+ if (nameDataList == null || nameDataList.size() != 1) {
+ return null;
+ }
+ // We are only expecting a single item in the list
+ return nameDataList.get(0);
+ }
+
+
+ private Message sendMessage(Message message, MessageType expectedResponseMessageType) {
+ // This asks a random peer for the data
+ // TODO: ask multiple peers, and disregard everything if there are any significant differences in the responses
+
+ // Needs a mutable copy of the unmodifiableList
+ List peers = new ArrayList<>(Network.getInstance().getImmutableHandshakedPeers());
+
+ // Disregard peers that have "misbehaved" recently
+ peers.removeIf(Controller.hasMisbehaved);
+
+ // Disregard peers that only have genesis block
+ // TODO: peers.removeIf(Controller.hasOnlyGenesisBlock);
+
+ // Disregard peers that are on an old version
+ peers.removeIf(Controller.hasOldVersion);
+
+ // Disregard peers that are on a known inferior chain tip
+ // TODO: peers.removeIf(Controller.hasInferiorChainTip);
+
+ if (peers.isEmpty()) {
+ LOGGER.info("No peers available to send {} message to", message.getType());
+ return null;
+ }
+
+ // Pick random peer
+ int index = new SecureRandom().nextInt(peers.size());
+ Peer peer = peers.get(index);
+
+ LOGGER.info("Sending {} message to peer {}...", message.getType(), peer);
+
+ Message responseMessage;
+
+ try {
+ responseMessage = peer.getResponse(message);
+
+ } catch (InterruptedException e) {
+ return null;
+ }
+
+ if (responseMessage == null) {
+ LOGGER.info("Peer {} didn't respond to {} message", peer, message.getType());
+ return null;
+ }
+ else if (responseMessage.getType() != expectedResponseMessageType) {
+ LOGGER.info("Peer responded with unexpected message type {} (should be {})", peer, responseMessage.getType(), expectedResponseMessageType);
+ return null;
+ }
+
+ LOGGER.info("Peer {} responded with {} message", peer, responseMessage.getType());
+
+ return responseMessage;
+ }
+
+}
diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java
index 55aeae04..8f3a34bb 100644
--- a/src/main/java/org/qortal/controller/Synchronizer.java
+++ b/src/main/java/org/qortal/controller/Synchronizer.java
@@ -134,6 +134,11 @@ public class Synchronizer extends Thread {
public void run() {
Thread.currentThread().setName("Synchronizer");
+ if (Settings.getInstance().isLite()) {
+ // Lite nodes don't need to sync
+ return;
+ }
+
try {
while (running && !Controller.isStopping()) {
Thread.sleep(1000);
diff --git a/src/main/java/org/qortal/controller/TransactionImporter.java b/src/main/java/org/qortal/controller/TransactionImporter.java
index 16fd3a59..5c70f369 100644
--- a/src/main/java/org/qortal/controller/TransactionImporter.java
+++ b/src/main/java/org/qortal/controller/TransactionImporter.java
@@ -2,7 +2,9 @@ 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.data.transaction.TransactionData;
+import org.qortal.network.Network;
import org.qortal.network.Peer;
import org.qortal.network.message.GetTransactionMessage;
import org.qortal.network.message.Message;
@@ -11,14 +13,15 @@ import org.qortal.network.message.TransactionSignaturesMessage;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
+import org.qortal.settings.Settings;
import org.qortal.transaction.Transaction;
import org.qortal.transform.TransformationException;
import org.qortal.utils.Base58;
import org.qortal.utils.NTP;
import java.util.*;
-import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.Collectors;
public class TransactionImporter extends Thread {
@@ -55,12 +58,16 @@ public class TransactionImporter extends Thread {
@Override
public void run() {
+ Thread.currentThread().setName("Transaction Importer");
+
try {
while (!Controller.isStopping()) {
- Thread.sleep(1000L);
+ Thread.sleep(500L);
// Process incoming transactions queue
- processIncomingTransactionsQueue();
+ validateTransactionsInQueue();
+ importTransactionsInQueue();
+
// Clean up invalid incoming transactions list
cleanupInvalidTransactionsList(NTP.getTime());
}
@@ -87,7 +94,26 @@ public class TransactionImporter extends Thread {
incomingTransactions.keySet().removeIf(t -> Arrays.equals(t.getSignature(), signature));
}
- private void processIncomingTransactionsQueue() {
+ /**
+ * Retrieve all pending unconfirmed transactions that have had their signatures validated.
+ * @return a list of TransactionData objects, with valid signatures.
+ */
+ private List getCachedSigValidTransactions() {
+ synchronized (this.incomingTransactions) {
+ return this.incomingTransactions.entrySet().stream()
+ .filter(t -> Boolean.TRUE.equals(t.getValue()))
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toList());
+ }
+ }
+
+ /**
+ * Validate the signatures of any transactions pending import, then update their
+ * entries in the queue to mark them as valid/invalid.
+ *
+ * No database lock is required.
+ */
+ private void validateTransactionsInQueue() {
if (this.incomingTransactions.isEmpty()) {
// Nothing to do?
return;
@@ -104,8 +130,17 @@ public class TransactionImporter extends Thread {
LOGGER.debug("Validating signatures in incoming transactions queue (size {})...", unvalidatedCount);
}
+ // A list of all currently pending transactions that have valid signatures
List sigValidTransactions = new ArrayList<>();
+ // A list of signatures that became valid in this round
+ List newlyValidSignatures = new ArrayList<>();
+
+ boolean isLiteNode = Settings.getInstance().isLite();
+
+ // We need the latest block in order to check for expired transactions
+ BlockData latestBlock = Controller.getInstance().getChainTip();
+
// Signature validation round - does not require blockchain lock
for (Map.Entry transactionEntry : incomingTransactionsCopy.entrySet()) {
// Quick exit?
@@ -115,34 +150,59 @@ public class TransactionImporter extends Thread {
TransactionData transactionData = transactionEntry.getKey();
Transaction transaction = Transaction.fromData(repository, transactionData);
+ String signature58 = Base58.encode(transactionData.getSignature());
+
+ Long now = NTP.getTime();
+ if (now == null) {
+ return;
+ }
+
+ // Drop expired transactions before they are considered "sig valid"
+ if (latestBlock != null && transaction.getDeadline() <= latestBlock.getTimestamp()) {
+ LOGGER.debug("Removing expired {} transaction {} from import queue", transactionData.getType().name(), signature58);
+ removeIncomingTransaction(transactionData.getSignature());
+ invalidUnconfirmedTransactions.put(signature58, (now + EXPIRED_TRANSACTION_RECHECK_INTERVAL));
+ continue;
+ }
// Only validate signature if we haven't already done so
Boolean isSigValid = transactionEntry.getValue();
if (!Boolean.TRUE.equals(isSigValid)) {
- if (!transaction.isSignatureValid()) {
- String signature58 = Base58.encode(transactionData.getSignature());
+ if (isLiteNode) {
+ // Lite nodes can't easily validate transactions, so for now we will have to assume that everything is valid
+ sigValidTransactions.add(transaction);
+ newlyValidSignatures.add(transactionData.getSignature());
+ // Add mark signature as valid if transaction still exists in import queue
+ incomingTransactions.computeIfPresent(transactionData, (k, v) -> Boolean.TRUE);
+ continue;
+ }
- LOGGER.trace("Ignoring {} transaction {} with invalid signature", transactionData.getType().name(), signature58);
+ if (!transaction.isSignatureValid()) {
+ LOGGER.debug("Ignoring {} transaction {} with invalid signature", transactionData.getType().name(), signature58);
removeIncomingTransaction(transactionData.getSignature());
// Also add to invalidIncomingTransactions map
- Long now = NTP.getTime();
+ now = NTP.getTime();
if (now != null) {
Long expiry = now + INVALID_TRANSACTION_RECHECK_INTERVAL;
- LOGGER.trace("Adding stale invalid transaction {} to invalidUnconfirmedTransactions...", signature58);
+ LOGGER.trace("Adding invalid transaction {} to invalidUnconfirmedTransactions...", signature58);
// Add to invalidUnconfirmedTransactions so that we don't keep requesting it
invalidUnconfirmedTransactions.put(signature58, expiry);
}
+ // We're done with this transaction
continue;
}
- else {
- // Count the number that were validated in this round, for logging purposes
- validatedCount++;
- }
+
+ // Count the number that were validated in this round, for logging purposes
+ validatedCount++;
// Add mark signature as valid if transaction still exists in import queue
incomingTransactions.computeIfPresent(transactionData, (k, v) -> Boolean.TRUE);
+
+ // Signature validated in this round
+ newlyValidSignatures.add(transactionData.getSignature());
+
} else {
LOGGER.trace(() -> String.format("Transaction %s known to have valid signature", Base58.encode(transactionData.getSignature())));
}
@@ -155,30 +215,44 @@ public class TransactionImporter extends Thread {
LOGGER.debug("Finished validating signatures in incoming transactions queue (valid this round: {}, total pending import: {})...", validatedCount, sigValidTransactions.size());
}
- if (sigValidTransactions.isEmpty()) {
- // Don't bother locking if there are no new transactions to process
- return;
+ if (!newlyValidSignatures.isEmpty()) {
+ LOGGER.debug("Broadcasting {} newly valid signatures ahead of import", newlyValidSignatures.size());
+ Message newTransactionSignatureMessage = new TransactionSignaturesMessage(newlyValidSignatures);
+ Network.getInstance().broadcast(broadcastPeer -> newTransactionSignatureMessage);
}
- if (Synchronizer.getInstance().isSyncRequested() || Synchronizer.getInstance().isSynchronizing()) {
- // Prioritize syncing, and don't attempt to lock
- // Signature validity is retained in the incomingTransactions map, to avoid the above work being wasted
- return;
- }
+ } catch (DataException e) {
+ LOGGER.error("Repository issue while processing incoming transactions", e);
+ }
+ }
- try {
- ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
- if (!blockchainLock.tryLock(2, TimeUnit.SECONDS)) {
- // Signature validity is retained in the incomingTransactions map, to avoid the above work being wasted
- LOGGER.debug("Too busy to process incoming transactions queue");
- return;
- }
- } catch (InterruptedException e) {
- LOGGER.debug("Interrupted when trying to acquire blockchain lock");
- return;
- }
+ /**
+ * Import any transactions in the queue that have valid signatures.
+ *
+ * A database lock is required.
+ */
+ private void importTransactionsInQueue() {
+ List sigValidTransactions = this.getCachedSigValidTransactions();
+ if (sigValidTransactions.isEmpty()) {
+ // Don't bother locking if there are no new transactions to process
+ return;
+ }
- LOGGER.debug("Processing incoming transactions queue (size {})...", sigValidTransactions.size());
+ if (Synchronizer.getInstance().isSyncRequested() || Synchronizer.getInstance().isSynchronizing()) {
+ // Prioritize syncing, and don't attempt to lock
+ return;
+ }
+
+ ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
+ if (!blockchainLock.tryLock()) {
+ LOGGER.debug("Too busy to import incoming transactions queue");
+ return;
+ }
+
+ LOGGER.debug("Importing incoming transactions queue (size {})...", sigValidTransactions.size());
+
+ int processedCount = 0;
+ try (final Repository repository = RepositoryManager.getRepository()) {
// Import transactions with valid signatures
try {
@@ -188,14 +262,15 @@ public class TransactionImporter extends Thread {
}
if (Synchronizer.getInstance().isSyncRequestPending()) {
- LOGGER.debug("Breaking out of transaction processing with {} remaining, because a sync request is pending", sigValidTransactions.size() - i);
+ LOGGER.debug("Breaking out of transaction importing with {} remaining, because a sync request is pending", sigValidTransactions.size() - i);
return;
}
- Transaction transaction = sigValidTransactions.get(i);
- TransactionData transactionData = transaction.getTransactionData();
+ TransactionData transactionData = sigValidTransactions.get(i);
+ Transaction transaction = Transaction.fromData(repository, transactionData);
Transaction.ValidationResult validationResult = transaction.importAsUnconfirmed();
+ processedCount++;
switch (validationResult) {
case TRANSACTION_ALREADY_EXISTS: {
@@ -217,7 +292,7 @@ public class TransactionImporter extends Thread {
// All other invalid cases:
default: {
final String signature58 = Base58.encode(transactionData.getSignature());
- LOGGER.trace(() -> String.format("Ignoring invalid (%s) %s transaction %s", validationResult.name(), transactionData.getType().name(), signature58));
+ LOGGER.debug(() -> String.format("Ignoring invalid (%s) %s transaction %s", validationResult.name(), transactionData.getType().name(), signature58));
Long now = NTP.getTime();
if (now != null && now - transactionData.getTimestamp() > INVALID_TRANSACTION_STALE_TIMEOUT) {
@@ -240,12 +315,11 @@ public class TransactionImporter extends Thread {
removeIncomingTransaction(transactionData.getSignature());
}
} finally {
- LOGGER.debug("Finished processing incoming transactions queue");
- ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
+ LOGGER.debug("Finished importing {} incoming transaction{}", processedCount, (processedCount == 1 ? "" : "s"));
blockchainLock.unlock();
}
} catch (DataException e) {
- LOGGER.error("Repository issue while processing incoming transactions", e);
+ LOGGER.error("Repository issue while importing incoming transactions", e);
}
}
@@ -278,8 +352,18 @@ public class TransactionImporter extends Thread {
byte[] signature = getTransactionMessage.getSignature();
try (final Repository repository = RepositoryManager.getRepository()) {
- TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature);
+ // Firstly check the sig-valid transactions that are currently queued for import
+ TransactionData transactionData = this.getCachedSigValidTransactions().stream()
+ .filter(t -> Arrays.equals(signature, t.getSignature()))
+ .findFirst().orElse(null);
+
if (transactionData == null) {
+ // Not found in import queue, so try the database
+ transactionData = repository.getTransactionRepository().fromSignature(signature);
+ }
+
+ if (transactionData == null) {
+ // Still not found - so we don't have this transaction
LOGGER.debug(() -> String.format("Ignoring GET_TRANSACTION request from peer %s for unknown transaction %s", peer, Base58.encode(signature)));
// Send no response at all???
return;
diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java
index 05a45425..a0b4886b 100644
--- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java
+++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java
@@ -283,8 +283,8 @@ public class ArbitraryDataFileListManager {
LOGGER.debug(String.format("Sending data file list request for signature %s with %d hashes to %d peers...", signature58, hashCount, handshakedPeers.size()));
- // FUTURE: send our address as requestingPeer once enough peers have switched to the new protocol
- String requestingPeer = null; // Network.getInstance().getOurExternalIpAddressAndPort();
+ // Send our address as requestingPeer, to allow for potential direct connections with seeds/peers
+ String requestingPeer = Network.getInstance().getOurExternalIpAddressAndPort();
// Build request
Message getArbitraryDataFileListMessage = new GetArbitraryDataFileListMessage(signature, missingHashes, now, 0, requestingPeer);
@@ -636,6 +636,9 @@ public class ArbitraryDataFileListManager {
// We should only respond if we have at least one hash
if (hashes.size() > 0) {
+ // Firstly we should keep track of the requesting peer, to allow for potential direct connections later
+ ArbitraryDataFileManager.getInstance().addRecentDataRequest(requestingPeer);
+
// We have all the chunks, so update requests map to reflect that we've sent it
// There is no need to keep track of the request, as we can serve all the chunks
if (allChunksExist) {
diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java
index 11e15414..22cf4144 100644
--- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java
+++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java
@@ -1,5 +1,6 @@
package org.qortal.controller.arbitrary;
+import com.google.common.net.InetAddresses;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.arbitrary.ArbitraryDataFile;
@@ -54,6 +55,13 @@ public class ArbitraryDataFileManager extends Thread {
*/
private List directConnectionInfo = Collections.synchronizedList(new ArrayList<>());
+ /**
+ * Map to keep track of peers requesting QDN data that we hold.
+ * Key = peer address string, value = time of last request.
+ * This allows for additional "burst" connections beyond existing limits.
+ */
+ private Map recentDataRequests = Collections.synchronizedMap(new HashMap<>());
+
public static int MAX_FILE_HASH_RESPONSES = 1000;
@@ -108,6 +116,9 @@ public class ArbitraryDataFileManager extends Thread {
final long directConnectionInfoMinimumTimestamp = now - ArbitraryDataManager.getInstance().ARBITRARY_DIRECT_CONNECTION_INFO_TIMEOUT;
directConnectionInfo.removeIf(entry -> entry.getTimestamp() < directConnectionInfoMinimumTimestamp);
+
+ final long recentDataRequestMinimumTimestamp = now - ArbitraryDataManager.getInstance().ARBITRARY_RECENT_DATA_REQUESTS_TIMEOUT;
+ recentDataRequests.entrySet().removeIf(entry -> entry.getValue() < recentDataRequestMinimumTimestamp);
}
@@ -490,6 +501,45 @@ public class ArbitraryDataFileManager extends Thread {
}
+ // Peers requesting QDN data from us
+
+ /**
+ * Add an address string of a peer that is trying to request data from us.
+ * @param peerAddress
+ */
+ public void addRecentDataRequest(String peerAddress) {
+ if (peerAddress == null) {
+ return;
+ }
+
+ Long now = NTP.getTime();
+ if (now == null) {
+ return;
+ }
+
+ // Make sure to remove the port, since it isn't guaranteed to match next time
+ String[] parts = peerAddress.split(":");
+ if (parts.length == 0) {
+ return;
+ }
+ String host = parts[0];
+ if (!InetAddresses.isInetAddress(host)) {
+ // Invalid host
+ return;
+ }
+
+ this.recentDataRequests.put(host, now);
+ }
+
+ public boolean isPeerRequestingData(String peerAddressWithoutPort) {
+ return this.recentDataRequests.containsKey(peerAddressWithoutPort);
+ }
+
+ public boolean hasPendingDataRequest() {
+ return !this.recentDataRequests.isEmpty();
+ }
+
+
// Network handlers
public void onNetworkGetArbitraryDataFileMessage(Peer peer, Message message) {
diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java
index 4b6d3a28..6b3f0160 100644
--- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java
+++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java
@@ -47,6 +47,9 @@ public class ArbitraryDataManager extends Thread {
/** Maximum time to hold direct peer connection information */
public static final long ARBITRARY_DIRECT_CONNECTION_INFO_TIMEOUT = 2 * 60 * 1000L; // ms
+ /** Maximum time to hold information about recent data requests that we can fulfil */
+ public static final long ARBITRARY_RECENT_DATA_REQUESTS_TIMEOUT = 2 * 60 * 1000L; // ms
+
/** Maximum number of hops that an arbitrary signatures request is allowed to make */
private static int ARBITRARY_SIGNATURES_REQUEST_MAX_HOPS = 3;
diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java
index 54fba699..bd12f784 100644
--- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java
+++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java
@@ -19,6 +19,11 @@ public class AtStatesPruner implements Runnable {
public void run() {
Thread.currentThread().setName("AT States pruner");
+ if (Settings.getInstance().isLite()) {
+ // Nothing to prune in lite mode
+ return;
+ }
+
boolean archiveMode = false;
if (!Settings.getInstance().isTopOnly()) {
// Top-only mode isn't enabled, but we might want to prune for the purposes of archiving
diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java
index d3bdc345..69fa347c 100644
--- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java
+++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java
@@ -19,6 +19,11 @@ public class AtStatesTrimmer implements Runnable {
public void run() {
Thread.currentThread().setName("AT States trimmer");
+ if (Settings.getInstance().isLite()) {
+ // Nothing to trim in lite mode
+ return;
+ }
+
try (final Repository repository = RepositoryManager.getRepository()) {
int trimStartHeight = repository.getATRepository().getAtTrimHeight();
diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java
index ef26610c..8757bf32 100644
--- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java
+++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java
@@ -21,7 +21,7 @@ public class BlockArchiver implements Runnable {
public void run() {
Thread.currentThread().setName("Block archiver");
- if (!Settings.getInstance().isArchiveEnabled()) {
+ if (!Settings.getInstance().isArchiveEnabled() || Settings.getInstance().isLite()) {
return;
}
diff --git a/src/main/java/org/qortal/controller/repository/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java
index 03fb38b9..23e3a45a 100644
--- a/src/main/java/org/qortal/controller/repository/BlockPruner.java
+++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java
@@ -19,6 +19,11 @@ public class BlockPruner implements Runnable {
public void run() {
Thread.currentThread().setName("Block pruner");
+ if (Settings.getInstance().isLite()) {
+ // Nothing to prune in lite mode
+ return;
+ }
+
boolean archiveMode = false;
if (!Settings.getInstance().isTopOnly()) {
// Top-only mode isn't enabled, but we might want to prune for the purposes of archiving
diff --git a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java
index 79178f5d..e69d1a35 100644
--- a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java
+++ b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java
@@ -107,7 +107,7 @@ public class NamesDatabaseIntegrityCheck {
BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) currentTransaction;
Name nameObj = new Name(repository, buyNameTransactionData.getName());
if (nameObj != null && nameObj.getNameData() != null) {
- nameObj.buy(buyNameTransactionData);
+ nameObj.buy(buyNameTransactionData, false);
modificationCount++;
LOGGER.trace("Processed BUY_NAME transaction for name {}", name);
}
diff --git a/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java
index dfd9d45e..d74df4b5 100644
--- a/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java
+++ b/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java
@@ -21,6 +21,11 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable {
public void run() {
Thread.currentThread().setName("Online Accounts trimmer");
+ if (Settings.getInstance().isLite()) {
+ // Nothing to trim in lite mode
+ return;
+ }
+
try (final Repository repository = RepositoryManager.getRepository()) {
// Don't even start trimming until initial rush has ended
Thread.sleep(INITIAL_SLEEP_PERIOD);
diff --git a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java
new file mode 100644
index 00000000..9033e717
--- /dev/null
+++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java
@@ -0,0 +1,885 @@
+package org.qortal.controller.tradebot;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.bitcoinj.core.*;
+import org.bitcoinj.script.Script.ScriptType;
+import org.qortal.account.PrivateKeyAccount;
+import org.qortal.account.PublicKeyAccount;
+import org.qortal.api.model.crosschain.TradeBotCreateRequest;
+import org.qortal.asset.Asset;
+import org.qortal.crosschain.*;
+import org.qortal.crypto.Crypto;
+import org.qortal.data.at.ATData;
+import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.data.crosschain.TradeBotData;
+import org.qortal.data.transaction.BaseTransactionData;
+import org.qortal.data.transaction.DeployAtTransactionData;
+import org.qortal.data.transaction.MessageTransactionData;
+import org.qortal.group.Group;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+import org.qortal.transaction.DeployAtTransaction;
+import org.qortal.transaction.MessageTransaction;
+import org.qortal.transaction.Transaction.ValidationResult;
+import org.qortal.transform.TransformationException;
+import org.qortal.transform.transaction.DeployAtTransactionTransformer;
+import org.qortal.utils.Base58;
+import org.qortal.utils.NTP;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.toMap;
+
+/**
+ * Performing cross-chain trading steps on behalf of user.
+ *
+ * We deal with three different independent state-spaces here:
+ *
+ *
Qortal blockchain
+ *
Foreign blockchain
+ *
Trade-bot entries
+ *
+ */
+public class BitcoinACCTv3TradeBot implements AcctTradeBot {
+
+ private static final Logger LOGGER = LogManager.getLogger(BitcoinACCTv3TradeBot.class);
+
+ public enum State implements TradeBot.StateNameAndValueSupplier {
+ BOB_WAITING_FOR_AT_CONFIRM(10, false, false),
+ BOB_WAITING_FOR_MESSAGE(15, true, true),
+ BOB_WAITING_FOR_AT_REDEEM(25, true, true),
+ BOB_DONE(30, false, false),
+ BOB_REFUNDED(35, false, false),
+
+ ALICE_WAITING_FOR_AT_LOCK(85, true, true),
+ ALICE_DONE(95, false, false),
+ ALICE_REFUNDING_A(105, true, true),
+ ALICE_REFUNDED(110, false, false);
+
+ private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
+
+ public final int value;
+ public final boolean requiresAtData;
+ public final boolean requiresTradeData;
+
+ State(int value, boolean requiresAtData, boolean requiresTradeData) {
+ this.value = value;
+ this.requiresAtData = requiresAtData;
+ this.requiresTradeData = requiresTradeData;
+ }
+
+ public static State valueOf(int value) {
+ return map.get(value);
+ }
+
+ @Override
+ public String getState() {
+ return this.name();
+ }
+
+ @Override
+ public int getStateValue() {
+ return this.value;
+ }
+ }
+
+ /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */
+ private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms
+
+ private static BitcoinACCTv3TradeBot instance;
+
+ private final List endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDED).stream()
+ .map(State::name)
+ .collect(Collectors.toUnmodifiableList());
+
+ private BitcoinACCTv3TradeBot() {
+ }
+
+ public static synchronized BitcoinACCTv3TradeBot getInstance() {
+ if (instance == null)
+ instance = new BitcoinACCTv3TradeBot();
+
+ return instance;
+ }
+
+ @Override
+ public List getEndStates() {
+ return this.endStates;
+ }
+
+ /**
+ * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for BTC.
+ *
+ * Generates:
+ *
+ *
new 'trade' private key
+ *
+ * Derives:
+ *
+ *
'native' (as in Qortal) public key, public key hash, address (starting with Q)
+ *
'foreign' (as in Bitcoin) public key, public key hash
+ *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment':
+ *
+ *
'native'/Qortal 'trade' address - used as a MESSAGE contact
+ *
'foreign'/Bitcoin public key hash - used by Alice's P2SH scripts to allow redeem
+ *
QORT amount on offer by Bob
+ *
BTC amount expected in return by Bob (from Alice)
+ *
trading timeout, in case things go wrong and everyone needs to refund
+ *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network.
+ *
+ * Trade-bot will wait for Bob's AT to be deployed before taking next step.
+ *
+ * @param repository
+ * @param tradeBotCreateRequest
+ * @return raw, unsigned DEPLOY_AT transaction
+ * @throws DataException
+ */
+ public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
+ byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
+
+ byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
+ byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
+ String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
+
+ byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
+ byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
+
+ // Convert Bitcoin receiving address into public key hash (we only support P2PKH at this time)
+ Address bitcoinReceivingAddress;
+ try {
+ bitcoinReceivingAddress = Address.fromString(Bitcoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress);
+ } catch (AddressFormatException e) {
+ throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress);
+ }
+ if (bitcoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH)
+ throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress);
+
+ byte[] bitcoinReceivingAccountInfo = bitcoinReceivingAddress.getHash();
+
+ PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey);
+
+ // Deploy AT
+ long timestamp = NTP.getTime();
+ byte[] reference = creator.getLastReference();
+ long fee = 0L;
+ byte[] signature = null;
+ BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, creator.getPublicKey(), fee, signature);
+
+ String name = "QORT/BTC ACCT";
+ String description = "QORT/BTC cross-chain trade";
+ String aTType = "ACCT";
+ String tags = "ACCT QORT BTC";
+ byte[] creationBytes = BitcoinACCTv3.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, tradeBotCreateRequest.qortAmount,
+ tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout);
+ long amount = tradeBotCreateRequest.fundingQortAmount;
+
+ DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT);
+
+ DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
+ fee = deployAtTransaction.calcRecommendedFee();
+ deployAtTransactionData.setFee(fee);
+
+ DeployAtTransaction.ensureATAddress(deployAtTransactionData);
+ String atAddress = deployAtTransactionData.getAtAddress();
+
+ TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv3.NAME,
+ State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value,
+ creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ null, null,
+ SupportedBlockchain.BITCOIN.name(),
+ tradeForeignPublicKey, tradeForeignPublicKeyHash,
+ tradeBotCreateRequest.foreignAmount, null, null, null, bitcoinReceivingAccountInfo);
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress));
+
+ // Attempt to backup the trade bot data
+ TradeBot.backupTradeBotData(repository, null);
+
+ // Return to user for signing and broadcast as we don't have their Qortal private key
+ try {
+ return DeployAtTransactionTransformer.toBytes(deployAtTransactionData);
+ } catch (TransformationException e) {
+ throw new DataException("Failed to transform DEPLOY_AT transaction?", e);
+ }
+ }
+
+ /**
+ * Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching BTC to an existing offer.
+ *
+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData
+ * and access to a Bitcoin wallet via xprv58.
+ *
+ * The crossChainTradeData contains the current trade offer state
+ * as extracted from the AT's data segment.
+ *
+ * Access to a funded wallet is via a Bitcoin BIP32 hierarchical deterministic key,
+ * passed via xprv58.
+ * This key will be stored in your node's database
+ * to allow trade-bot to create/fund the necessary P2SH transactions!
+ * However, due to the nature of BIP32 keys, it is possible to give the trade-bot
+ * only a subset of wallet access (see BIP32 for more details).
+ *
+ * As an example, the xprv58 can be extract from a legacy, password-less
+ * Electrum wallet by going to the console tab and entering:
+ * wallet.keystore.xprv
+ * which should result in a base58 string starting with either 'xprv' (for Bitcoin main-net)
+ * or 'tprv' for (Bitcoin test-net).
+ *
+ * It is envisaged that the value in xprv58 will actually come from a Qortal-UI-managed wallet.
+ *
+ * If sufficient funds are available, this method will actually fund the P2SH-A
+ * with the Bitcoin amount expected by 'Bob'.
+ *
+ * If the Bitcoin transaction is successfully broadcast to the network then
+ * we also send a MESSAGE to Bob's trade-bot to let them know.
+ *
+ * The trade-bot entry is saved to the repository and the cross-chain trading process commences.
+ *
+ * @param repository
+ * @param crossChainTradeData chosen trade OFFER that Alice wants to match
+ * @param xprv58 funded wallet xprv in base58
+ * @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise
+ * @throws DataException
+ */
+ public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException {
+ byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
+ byte[] secretA = TradeBot.generateSecret();
+ byte[] hashOfSecretA = Crypto.hash160(secretA);
+
+ byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
+ byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
+ String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
+
+ byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
+ byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
+ byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH
+
+ // We need to generate lockTime-A: add tradeTimeout to now
+ long now = NTP.getTime();
+ int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L);
+
+ TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv3.NAME,
+ State.ALICE_WAITING_FOR_AT_LOCK.name(), State.ALICE_WAITING_FOR_AT_LOCK.value,
+ receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ secretA, hashOfSecretA,
+ SupportedBlockchain.BITCOIN.name(),
+ tradeForeignPublicKey, tradeForeignPublicKeyHash,
+ crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash);
+
+ // Attempt to backup the trade bot data
+ // Include tradeBotData as an additional parameter, since it's not in the repository yet
+ TradeBot.backupTradeBotData(repository, Arrays.asList(tradeBotData));
+
+ // Check we have enough funds via xprv58 to fund P2SH to cover expectedForeignAmount
+ long p2shFee;
+ try {
+ p2shFee = Bitcoin.getInstance().getP2shFee(now);
+ } catch (ForeignBlockchainException e) {
+ LOGGER.debug("Couldn't estimate Bitcoin fees?");
+ return ResponseResult.NETWORK_ISSUE;
+ }
+
+ // Fee for redeem/refund is subtracted from P2SH-A balance.
+ // Do not include fee for funding transaction as this is covered by buildSpend()
+ long amountA = crossChainTradeData.expectedForeignAmount + p2shFee /*redeeming/refunding P2SH-A*/;
+
+ // P2SH-A to be funded
+ byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA);
+ String p2shAddress = Bitcoin.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ // Build transaction for funding P2SH-A
+ Transaction p2shFundingTransaction = Bitcoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA);
+ if (p2shFundingTransaction == null) {
+ LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?");
+ return ResponseResult.BALANCE_ISSUE;
+ }
+
+ try {
+ Bitcoin.getInstance().broadcastTransaction(p2shFundingTransaction);
+ } catch (ForeignBlockchainException e) {
+ // We couldn't fund P2SH-A at this time
+ LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?");
+ return ResponseResult.NETWORK_ISSUE;
+ }
+
+ // Attempt to send MESSAGE to Bob's Qortal trade address
+ byte[] messageData = BitcoinACCTv3.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA());
+ String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress;
+
+ boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
+ if (!isMessageAlreadySent) {
+ PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false);
+
+ messageTransaction.computeNonce();
+ messageTransaction.sign(sender);
+
+ // reset repository state to prevent deadlock
+ repository.discardChanges();
+ ValidationResult result = messageTransaction.importAsUnconfirmed();
+
+ if (result != ValidationResult.OK) {
+ LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name()));
+ return ResponseResult.NETWORK_ISSUE;
+ }
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress));
+
+ return ResponseResult.OK;
+ }
+
+ @Override
+ public boolean canDelete(Repository repository, TradeBotData tradeBotData) throws DataException {
+ State tradeBotState = State.valueOf(tradeBotData.getStateValue());
+ if (tradeBotState == null)
+ return true;
+
+ // If the AT doesn't exist then we might as well let the user tidy up
+ if (!repository.getATRepository().exists(tradeBotData.getAtAddress()))
+ return true;
+
+ switch (tradeBotState) {
+ case BOB_WAITING_FOR_AT_CONFIRM:
+ case ALICE_DONE:
+ case BOB_DONE:
+ case ALICE_REFUNDED:
+ case BOB_REFUNDED:
+ case ALICE_REFUNDING_A:
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException {
+ State tradeBotState = State.valueOf(tradeBotData.getStateValue());
+ if (tradeBotState == null) {
+ LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress()));
+ return;
+ }
+
+ ATData atData = null;
+ CrossChainTradeData tradeData = null;
+
+ if (tradeBotState.requiresAtData) {
+ // Attempt to fetch AT data
+ atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.debug(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+
+ if (tradeBotState.requiresTradeData) {
+ tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData);
+ if (tradeData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ }
+ }
+
+ switch (tradeBotState) {
+ case BOB_WAITING_FOR_AT_CONFIRM:
+ handleBobWaitingForAtConfirm(repository, tradeBotData);
+ break;
+
+ case BOB_WAITING_FOR_MESSAGE:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_WAITING_FOR_AT_LOCK:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case BOB_WAITING_FOR_AT_REDEEM:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_DONE:
+ case BOB_DONE:
+ break;
+
+ case ALICE_REFUNDING_A:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_REFUNDED:
+ case BOB_REFUNDED:
+ break;
+ }
+ }
+
+ /**
+ * Trade-bot is waiting for Bob's AT to deploy.
+ *
+ * If AT is deployed, then trade-bot's next step is to wait for MESSAGE from Alice.
+ */
+ private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException {
+ if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) {
+ if (NTP.getTime() - tradeBotData.getTimestamp() <= MAX_AT_CONFIRMATION_PERIOD)
+ return;
+
+ // We've waited ages for AT to be confirmed into a block but something has gone awry.
+ // After this long we assume transaction loss so give up with trade-bot entry too.
+ tradeBotData.setState(State.BOB_REFUNDED.name());
+ tradeBotData.setStateValue(State.BOB_REFUNDED.value);
+ tradeBotData.setTimestamp(NTP.getTime());
+ // We delete trade-bot entry here instead of saving, hence not using updateTradeBotState()
+ repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress()));
+ TradeBot.notifyStateChange(tradeBotData);
+ return;
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE,
+ () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress()));
+ }
+
+ /**
+ * Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info.
+ *
+ * It's possible Bob has cancelling his trade offer, receiving an automatic QORT refund,
+ * in which case trade-bot is done with this specific trade and finalizes on refunded state.
+ *
+ * Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot.
+ *
+ * Details from Alice are used to derive P2SH-A address and this is checked for funding balance.
+ *
+ * Assuming P2SH-A has at least expected Bitcoin balance,
+ * Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details.
+ *
+ * On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice.
+ *
+ * Trade-bot's next step is to wait for Alice to redeem the AT, which will allow Bob to
+ * extract secret-A needed to redeem Alice's P2SH.
+ * @throws ForeignBlockchainException
+ */
+ private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ // If AT has finished then Bob likely cancelled his trade offer
+ if (atData.getIsFinished()) {
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
+ () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress()));
+ return;
+ }
+
+ Bitcoin bitcoin = Bitcoin.getInstance();
+
+ String address = tradeBotData.getTradeNativeAddress();
+ List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null);
+
+ for (MessageTransactionData messageTransactionData : messageTransactionsData) {
+ if (messageTransactionData.isText())
+ continue;
+
+ // We're expecting: HASH160(secret-A), Alice's Bitcoin pubkeyhash and lockTime-A
+ byte[] messageData = messageTransactionData.getData();
+ BitcoinACCTv3.OfferMessageData offerMessageData = BitcoinACCTv3.extractOfferMessageData(messageData);
+ if (offerMessageData == null)
+ continue;
+
+ byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH;
+ byte[] hashOfSecretA = offerMessageData.hashOfSecretA;
+ int lockTimeA = (int) offerMessageData.lockTimeA;
+ long messageTimestamp = messageTransactionData.getTimestamp();
+ int refundTimeout = BitcoinACCTv3.calcRefundTimeout(messageTimestamp, lockTimeA);
+
+ // Determine P2SH-A address and confirm funded
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA);
+ String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
+
+ long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFee = Bitcoin.getInstance().getP2shFee(feeTimestamp);
+ final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee;
+
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+
+ switch (htlcStatusA) {
+ case UNFUNDED:
+ case FUNDING_IN_PROGRESS:
+ // There might be another MESSAGE from someone else with an actually funded P2SH-A...
+ continue;
+
+ case REDEEM_IN_PROGRESS:
+ case REDEEMED:
+ // We've already redeemed this?
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE,
+ () -> String.format("P2SH-A %s already spent? Assuming trade complete", p2shAddressA));
+ return;
+
+ case REFUND_IN_PROGRESS:
+ case REFUNDED:
+ // This P2SH-A is burnt, but there might be another MESSAGE from someone else with an actually funded P2SH-A...
+ continue;
+
+ case FUNDED:
+ // Fall-through out of switch...
+ break;
+ }
+
+ // Good to go - send MESSAGE to AT
+
+ String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey());
+
+ // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume
+ byte[] outgoingMessageData = BitcoinACCTv3.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
+ String messageRecipient = tradeBotData.getAtAddress();
+
+ boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData);
+ if (!isMessageAlreadySent) {
+ PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false);
+
+ outgoingMessageTransaction.computeNonce();
+ outgoingMessageTransaction.sign(sender);
+
+ // reset repository state to prevent deadlock
+ repository.discardChanges();
+ ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed();
+
+ if (result != ValidationResult.OK) {
+ LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name()));
+ return;
+ }
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM,
+ () -> String.format("Locked AT %s to %s. Waiting for AT redeem", tradeBotData.getAtAddress(), aliceNativeAddress));
+
+ return;
+ }
+ }
+
+ /**
+ * Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only.
+ *
+ * It's possible that Bob has cancelled his trade offer in the mean time, or that somehow
+ * this process has taken so long that we've reached P2SH-A's locktime, or that someone else
+ * has managed to trade with Bob. In any of these cases, trade-bot switches to begin the refunding process.
+ *
+ * Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct.
+ *
+ * If all is well, trade-bot then redeems AT using Alice's secret-A, releasing Bob's QORT to Alice.
+ *
+ * In revealing a valid secret-A, Bob can then redeem the BTC funds from P2SH-A.
+ *
+ * @throws ForeignBlockchainException
+ */
+ private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
+ return;
+
+ Bitcoin bitcoin = Bitcoin.getInstance();
+ int lockTimeA = tradeBotData.getLockTimeA();
+
+ // Refund P2SH-A if we've passed lockTime-A
+ if (NTP.getTime() >= lockTimeA * 1000L) {
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
+ String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
+
+ long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFee = Bitcoin.getInstance().getP2shFee(feeTimestamp);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
+
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+
+ switch (htlcStatusA) {
+ case UNFUNDED:
+ case FUNDING_IN_PROGRESS:
+ case FUNDED:
+ break;
+
+ case REDEEM_IN_PROGRESS:
+ case REDEEMED:
+ // Already redeemed?
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
+ () -> String.format("P2SH-A %s already spent? Assuming trade completed", p2shAddressA));
+ return;
+
+ case REFUND_IN_PROGRESS:
+ case REFUNDED:
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
+ () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA));
+ return;
+
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
+ () -> atData.getIsFinished()
+ ? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA)
+ : String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA));
+
+ return;
+ }
+
+ // We're waiting for AT to be in TRADE mode
+ if (crossChainTradeData.mode != AcctMode.TRADING)
+ return;
+
+ // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above
+
+ // Find our MESSAGE to AT from previous state
+ List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(tradeBotData.getTradeNativePublicKey(),
+ crossChainTradeData.qortalCreatorTradeAddress, null, null, null);
+ if (messageTransactionsData == null || messageTransactionsData.isEmpty()) {
+ LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress));
+ return;
+ }
+
+ long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp();
+ int refundTimeout = BitcoinACCTv3.calcRefundTimeout(recipientMessageTimestamp, lockTimeA);
+
+ // Our calculated refundTimeout should match AT's refundTimeout
+ if (refundTimeout != crossChainTradeData.refundTimeout) {
+ LOGGER.debug(() -> String.format("Trade AT refundTimeout '%d' doesn't match our refundTimeout '%d'", crossChainTradeData.refundTimeout, refundTimeout));
+ // We'll eventually refund
+ return;
+ }
+
+ // We're good to redeem AT
+
+ // Send 'redeem' MESSAGE to AT using both secret
+ byte[] secretA = tradeBotData.getSecret();
+ String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH
+ byte[] messageData = BitcoinACCTv3.buildRedeemMessage(secretA, qortalReceivingAddress);
+ String messageRecipient = tradeBotData.getAtAddress();
+
+ boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
+ if (!isMessageAlreadySent) {
+ PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false);
+
+ messageTransaction.computeNonce();
+ messageTransaction.sign(sender);
+
+ // Reset repository state to prevent deadlock
+ repository.discardChanges();
+ ValidationResult result = messageTransaction.importAsUnconfirmed();
+
+ if (result != ValidationResult.OK) {
+ LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name()));
+ return;
+ }
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
+ () -> String.format("Redeeming AT %s. Funds should arrive at %s",
+ tradeBotData.getAtAddress(), qortalReceivingAddress));
+ }
+
+ /**
+ * Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the BTC funds from P2SH-A.
+ *
+ * It's possible that Bob's AT has reached its trading timeout and automatically refunded QORT back to Bob. In which case,
+ * trade-bot is done with this specific trade and finalizes in refunded state.
+ *
+ * Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the BTC funds from P2SH-A
+ * to Bob's 'foreign'/Bitcoin trade legacy-format address, as derived from trade private key.
+ *
+ * (This could potentially be 'improved' to send BTC to any address of Bob's choosing by changing the transaction output).
+ *
+ * If trade-bot successfully broadcasts the transaction, then this specific trade is done.
+ * @throws ForeignBlockchainException
+ */
+ private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ // AT should be 'finished' once Alice has redeemed QORT funds
+ if (!atData.getIsFinished())
+ // Not finished yet
+ return;
+
+ // If AT is REFUNDED or CANCELLED then something has gone wrong
+ if (crossChainTradeData.mode == AcctMode.REFUNDED || crossChainTradeData.mode == AcctMode.CANCELLED) {
+ // Alice hasn't redeemed the QORT, so there is no point in trying to redeem the BTC
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
+ () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
+
+ return;
+ }
+
+ byte[] secretA = BitcoinACCTv3.getInstance().findSecretA(repository, crossChainTradeData);
+ if (secretA == null) {
+ LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress()));
+ return;
+ }
+
+ // Use secret-A to redeem P2SH-A
+
+ Bitcoin bitcoin = Bitcoin.getInstance();
+
+ byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo();
+ int lockTimeA = crossChainTradeData.lockTimeA;
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA);
+ String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
+
+ // Fee for redeem/refund is subtracted from P2SH-A balance.
+ long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFee = Bitcoin.getInstance().getP2shFee(feeTimestamp);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+
+ switch (htlcStatusA) {
+ case UNFUNDED:
+ case FUNDING_IN_PROGRESS:
+ // P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund
+ return;
+
+ case REDEEM_IN_PROGRESS:
+ case REDEEMED:
+ // Double-check that we have redeemed P2SH-A...
+ break;
+
+ case REFUND_IN_PROGRESS:
+ case REFUNDED:
+ // Wait for AT to auto-refund
+ return;
+
+ case FUNDED: {
+ Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
+ ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA);
+
+ Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey,
+ fundingOutputs, redeemScriptA, secretA, receivingAccountInfo);
+
+ bitcoin.broadcastTransaction(p2shRedeemTransaction);
+ break;
+ }
+ }
+
+ String receivingAddress = bitcoin.pkhToAddress(receivingAccountInfo);
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE,
+ () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress));
+ }
+
+ /**
+ * Trade-bot is attempting to refund P2SH-A.
+ * @throws ForeignBlockchainException
+ */
+ private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ int lockTimeA = tradeBotData.getLockTimeA();
+
+ // We can't refund P2SH-A until lockTime-A has passed
+ if (NTP.getTime() <= lockTimeA * 1000L)
+ return;
+
+ Bitcoin bitcoin = Bitcoin.getInstance();
+
+ // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
+ int medianBlockTime = bitcoin.getMedianBlockTime();
+ if (medianBlockTime <= lockTimeA)
+ return;
+
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
+ String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
+
+ // Fee for redeem/refund is subtracted from P2SH-A balance.
+ long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFee = Bitcoin.getInstance().getP2shFee(feeTimestamp);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+
+ switch (htlcStatusA) {
+ case UNFUNDED:
+ case FUNDING_IN_PROGRESS:
+ // Still waiting for P2SH-A to be funded...
+ return;
+
+ case REDEEM_IN_PROGRESS:
+ case REDEEMED:
+ // Too late!
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
+ () -> String.format("P2SH-A %s already spent!", p2shAddressA));
+ return;
+
+ case REFUND_IN_PROGRESS:
+ case REFUNDED:
+ break;
+
+ case FUNDED:{
+ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
+ ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA);
+
+ // Determine receive address for refund
+ String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey());
+ Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress);
+
+ Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey,
+ fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash());
+
+ bitcoin.broadcastTransaction(p2shRefundTransaction);
+ break;
+ }
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
+ () -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA));
+ }
+
+ /**
+ * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else.
+ *
+ * Will automatically update trade-bot state to ALICE_REFUNDING_A or ALICE_DONE as necessary.
+ *
+ * @throws DataException
+ * @throws ForeignBlockchainException
+ */
+ private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ // This is OK
+ if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING)
+ return false;
+
+ boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress);
+
+ if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING)
+ if (isAtLockedToUs) {
+ // AT is trading with us - OK
+ return false;
+ } else {
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
+ () -> String.format("AT %s trading with someone else: %s. Refunding & aborting trade", tradeBotData.getAtAddress(), crossChainTradeData.qortalPartnerAddress));
+
+ return true;
+ }
+
+ if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) {
+ // We've redeemed already?
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
+ () -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress()));
+ } else {
+ // Any other state is not good, so start defensive refund
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
+ () -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress()));
+ }
+
+ return true;
+ }
+
+ private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) {
+ return (lockTimeA - tradeTimeout * 60) * 1000L;
+ }
+
+}
diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java
index e1021f6c..938141e0 100644
--- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java
+++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java
@@ -94,6 +94,7 @@ public class TradeBot implements Listener {
private static final Map, Supplier> acctTradeBotSuppliers = new HashMap<>();
static {
acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance);
+ acctTradeBotSuppliers.put(BitcoinACCTv3.class, BitcoinACCTv3TradeBot::getInstance);
acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance);
acctTradeBotSuppliers.put(LitecoinACCTv2.class, LitecoinACCTv2TradeBot::getInstance);
acctTradeBotSuppliers.put(LitecoinACCTv3.class, LitecoinACCTv3TradeBot::getInstance);
diff --git a/src/main/java/org/qortal/crosschain/Bitcoin.java b/src/main/java/org/qortal/crosschain/Bitcoin.java
index 3276a24b..afd42590 100644
--- a/src/main/java/org/qortal/crosschain/Bitcoin.java
+++ b/src/main/java/org/qortal/crosschain/Bitcoin.java
@@ -7,6 +7,7 @@ import java.util.Map;
import org.bitcoinj.core.Context;
import org.bitcoinj.core.NetworkParameters;
+import org.bitcoinj.core.Transaction;
import org.bitcoinj.params.MainNetParams;
import org.bitcoinj.params.RegTestParams;
import org.bitcoinj.params.TestNet3Params;
@@ -18,10 +19,12 @@ public class Bitcoin extends Bitcoiny {
public static final String CURRENCY_CODE = "BTC";
+ private static final long MINIMUM_ORDER_AMOUNT = 100000; // 0.001 BTC minimum order, due to high fees
+
// Temporary values until a dynamic fee system is written.
private static final long OLD_FEE_AMOUNT = 4_000L; // Not 5000 so that existing P2SH-B can output 1000, avoiding dust issue, leaving 4000 for fees.
private static final long NEW_FEE_TIMESTAMP = 1598280000000L; // milliseconds since epoch
- private static final long NEW_FEE_AMOUNT = 10_000L;
+ private static final long NEW_FEE_AMOUNT = 6_000L;
private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST
@@ -182,6 +185,11 @@ public class Bitcoin extends Bitcoiny {
instance = null;
}
+ @Override
+ public long getMinimumOrderAmount() {
+ return MINIMUM_ORDER_AMOUNT;
+ }
+
// Actual useful methods for use by other classes
/**
@@ -195,4 +203,17 @@ public class Bitcoin extends Bitcoiny {
return this.bitcoinNet.getP2shFee(timestamp);
}
+ /**
+ * Returns bitcoinj transaction sending amount to recipient using 20 sat/byte fee.
+ *
+ * @param xprv58 BIP32 private key
+ * @param recipient P2PKH address
+ * @param amount unscaled amount
+ * @return transaction, or null if insufficient funds
+ */
+ @Override
+ public Transaction buildSpend(String xprv58, String recipient, long amount) {
+ return buildSpend(xprv58, recipient, amount, 20L);
+ }
+
}
diff --git a/src/main/java/org/qortal/crosschain/BitcoinACCTv3.java b/src/main/java/org/qortal/crosschain/BitcoinACCTv3.java
new file mode 100644
index 00000000..ad5984c1
--- /dev/null
+++ b/src/main/java/org/qortal/crosschain/BitcoinACCTv3.java
@@ -0,0 +1,858 @@
+package org.qortal.crosschain;
+
+import com.google.common.hash.HashCode;
+import com.google.common.primitives.Bytes;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.ciyam.at.*;
+import org.qortal.account.Account;
+import org.qortal.asset.Asset;
+import org.qortal.at.QortalFunctionCode;
+import org.qortal.crypto.Crypto;
+import org.qortal.data.at.ATData;
+import org.qortal.data.at.ATStateData;
+import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.data.transaction.MessageTransactionData;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+import org.qortal.utils.Base58;
+import org.qortal.utils.BitTwiddling;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.ciyam.at.OpCode.calcOffset;
+
+/**
+ * Cross-chain trade AT
+ *
+ *
+ *
+ *
Bob generates Bitcoin & Qortal 'trade' keys
+ *
+ *
private key required to sign P2SH redeem tx
+ *
private key could be used to create 'secret' (e.g. double-SHA256)
+ *
encrypted private key could be stored in Qortal AT for access by Bob from any node
+ *
+ *
+ *
Bob deploys Qortal AT
+ *
+ *
+ *
+ *
Alice finds Qortal AT and wants to trade
+ *
+ *
Alice generates Bitcoin & Qortal 'trade' keys
+ *
Alice funds Bitcoin P2SH-A
+ *
Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing:
+ *
+ *
hash-of-secret-A
+ *
her 'trade' Bitcoin PKH
+ *
+ *
+ *
+ *
+ *
Bob receives "offer" MESSAGE
+ *
+ *
Checks Alice's P2SH-A
+ *
Sends 'trade' MESSAGE to Qortal AT from his trade address, containing:
+ *
+ *
Alice's trade Qortal address
+ *
Alice's trade Bitcoin PKH
+ *
hash-of-secret-A
+ *
+ *
+ *
+ *
+ *
Alice checks Qortal AT to confirm it's locked to her
+ *
+ *
Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing:
+ *
+ *
secret-A
+ *
Qortal receiving address of her chosing
+ *
+ *
+ *
AT's QORT funds are sent to Qortal receiving address
+ *
+ *
+ *
Bob checks AT, extracts secret-A
+ *
+ *
Bob redeems P2SH-A using his Bitcoin trade key and secret-A
+ *
P2SH-A BTC funds end up at Bitcoin address determined by redeem transaction output(s)
+ *
+ *
+ *
+ */
+public class BitcoinACCTv3 implements ACCT {
+
+ private static final Logger LOGGER = LogManager.getLogger(BitcoinACCTv3.class);
+
+ public static final String NAME = BitcoinACCTv3.class.getSimpleName();
+ public static final byte[] CODE_BYTES_HASH = HashCode.fromString("676fb9350708dafa054eb0262d655039e393c1eb4918ec582f8d45524c9b4860").asBytes(); // SHA256 of AT code bytes
+
+ public static final int SECRET_LENGTH = 32;
+
+ /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */
+ private static final int MODE_VALUE_OFFSET = 61;
+ /** Byte offset into AT state data where 'mode' variable (long) is stored. */
+ public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE);
+
+ public static class OfferMessageData {
+ public byte[] partnerBitcoinPKH;
+ public byte[] hashOfSecretA;
+ public long lockTimeA;
+ }
+ public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerBitcoinPKH*/ + 20 /*hashOfSecretA*/ + 8 /*lockTimeA*/;
+ public static final int TRADE_MESSAGE_LENGTH = 32 /*partner's Qortal trade address (padded from 25 to 32)*/
+ + 24 /*partner's Bitcoin PKH (padded from 20 to 24)*/
+ + 8 /*AT trade timeout (minutes)*/
+ + 24 /*hash of secret-A (padded from 20 to 24)*/
+ + 8 /*lockTimeA*/;
+ public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret-A*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/;
+ public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/;
+
+ private static BitcoinACCTv3 instance;
+
+ private BitcoinACCTv3() {
+ }
+
+ public static synchronized BitcoinACCTv3 getInstance() {
+ if (instance == null)
+ instance = new BitcoinACCTv3();
+
+ return instance;
+ }
+
+ @Override
+ public byte[] getCodeBytesHash() {
+ return CODE_BYTES_HASH;
+ }
+
+ @Override
+ public int getModeByteOffset() {
+ return MODE_BYTE_OFFSET;
+ }
+
+ @Override
+ public ForeignBlockchain getBlockchain() {
+ return Bitcoin.getInstance();
+ }
+
+ /**
+ * Returns Qortal AT creation bytes for cross-chain trading AT.
+ *
+ * tradeTimeout (minutes) is the time window for the trade partner to send the
+ * 32-byte secret to the AT, before the AT automatically refunds the AT's creator.
+ *
+ * @param creatorTradeAddress AT creator's trade Qortal address
+ * @param bitcoinPublicKeyHash 20-byte HASH160 of creator's trade Bitcoin public key
+ * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT
+ * @param bitcoinAmount how much BTC the AT creator is expecting to trade
+ * @param tradeTimeout suggested timeout for entire trade
+ */
+ public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, long qortAmount, long bitcoinAmount, int tradeTimeout) {
+ if (bitcoinPublicKeyHash.length != 20)
+ throw new IllegalArgumentException("Bitcoin public key hash should be 20 bytes");
+
+ // Labels for data segment addresses
+ int addrCounter = 0;
+
+ // Constants (with corresponding dataByteBuffer.put*() calls below)
+
+ final int addrCreatorTradeAddress1 = addrCounter++;
+ final int addrCreatorTradeAddress2 = addrCounter++;
+ final int addrCreatorTradeAddress3 = addrCounter++;
+ final int addrCreatorTradeAddress4 = addrCounter++;
+
+ final int addrBitcoinPublicKeyHash = addrCounter;
+ addrCounter += 4;
+
+ final int addrQortAmount = addrCounter++;
+ final int addrBitcoinAmount = addrCounter++;
+ final int addrTradeTimeout = addrCounter++;
+
+ final int addrMessageTxnType = addrCounter++;
+ final int addrExpectedTradeMessageLength = addrCounter++;
+ final int addrExpectedRedeemMessageLength = addrCounter++;
+
+ final int addrCreatorAddressPointer = addrCounter++;
+ final int addrQortalPartnerAddressPointer = addrCounter++;
+ final int addrMessageSenderPointer = addrCounter++;
+
+ final int addrTradeMessagePartnerBitcoinPKHOffset = addrCounter++;
+ final int addrPartnerBitcoinPKHPointer = addrCounter++;
+ final int addrTradeMessageHashOfSecretAOffset = addrCounter++;
+ final int addrHashOfSecretAPointer = addrCounter++;
+
+ final int addrRedeemMessageReceivingAddressOffset = addrCounter++;
+
+ final int addrMessageDataPointer = addrCounter++;
+ final int addrMessageDataLength = addrCounter++;
+
+ final int addrPartnerReceivingAddressPointer = addrCounter++;
+
+ final int addrEndOfConstants = addrCounter;
+
+ // Variables
+
+ final int addrCreatorAddress1 = addrCounter++;
+ final int addrCreatorAddress2 = addrCounter++;
+ final int addrCreatorAddress3 = addrCounter++;
+ final int addrCreatorAddress4 = addrCounter++;
+
+ final int addrQortalPartnerAddress1 = addrCounter++;
+ final int addrQortalPartnerAddress2 = addrCounter++;
+ final int addrQortalPartnerAddress3 = addrCounter++;
+ final int addrQortalPartnerAddress4 = addrCounter++;
+
+ final int addrLockTimeA = addrCounter++;
+ final int addrRefundTimeout = addrCounter++;
+ final int addrRefundTimestamp = addrCounter++;
+ final int addrLastTxnTimestamp = addrCounter++;
+ final int addrBlockTimestamp = addrCounter++;
+ final int addrTxnType = addrCounter++;
+ final int addrResult = addrCounter++;
+
+ final int addrMessageSender1 = addrCounter++;
+ final int addrMessageSender2 = addrCounter++;
+ final int addrMessageSender3 = addrCounter++;
+ final int addrMessageSender4 = addrCounter++;
+
+ final int addrMessageLength = addrCounter++;
+
+ final int addrMessageData = addrCounter;
+ addrCounter += 4;
+
+ final int addrHashOfSecretA = addrCounter;
+ addrCounter += 4;
+
+ final int addrPartnerBitcoinPKH = addrCounter;
+ addrCounter += 4;
+
+ final int addrPartnerReceivingAddress = addrCounter;
+ addrCounter += 4;
+
+ final int addrMode = addrCounter++;
+ assert addrMode == MODE_VALUE_OFFSET : String.format("addrMode %d does not match MODE_VALUE_OFFSET %d", addrMode, MODE_VALUE_OFFSET);
+
+ // Data segment
+ ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE);
+
+ // AT creator's trade Qortal address, decoded from Base58
+ assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect";
+ byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress);
+ dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0));
+
+ // Bitcoin public key hash
+ assert dataByteBuffer.position() == addrBitcoinPublicKeyHash * MachineState.VALUE_SIZE : "addrBitcoinPublicKeyHash incorrect";
+ dataByteBuffer.put(Bytes.ensureCapacity(bitcoinPublicKeyHash, 32, 0));
+
+ // Redeem Qort amount
+ assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect";
+ dataByteBuffer.putLong(qortAmount);
+
+ // Expected Bitcoin amount
+ assert dataByteBuffer.position() == addrBitcoinAmount * MachineState.VALUE_SIZE : "addrBitcoinAmount incorrect";
+ dataByteBuffer.putLong(bitcoinAmount);
+
+ // Suggested trade timeout (minutes)
+ assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect";
+ dataByteBuffer.putLong(tradeTimeout);
+
+ // We're only interested in MESSAGE transactions
+ assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect";
+ dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value);
+
+ // Expected length of 'trade' MESSAGE data from AT creator
+ assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect";
+ dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH);
+
+ // Expected length of 'redeem' MESSAGE data from trade partner
+ assert dataByteBuffer.position() == addrExpectedRedeemMessageLength * MachineState.VALUE_SIZE : "addrExpectedRedeemMessageLength incorrect";
+ dataByteBuffer.putLong(REDEEM_MESSAGE_LENGTH);
+
+ // Index into data segment of AT creator's address, used by GET_B_IND
+ assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect";
+ dataByteBuffer.putLong(addrCreatorAddress1);
+
+ // Index into data segment of partner's Qortal address, used by SET_B_IND
+ assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect";
+ dataByteBuffer.putLong(addrQortalPartnerAddress1);
+
+ // Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND
+ assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect";
+ dataByteBuffer.putLong(addrMessageSender1);
+
+ // Offset into 'trade' MESSAGE data payload for extracting partner's Bitcoin PKH
+ assert dataByteBuffer.position() == addrTradeMessagePartnerBitcoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerBitcoinPKHOffset incorrect";
+ dataByteBuffer.putLong(32L);
+
+ // Index into data segment of partner's Bitcoin PKH, used by GET_B_IND
+ assert dataByteBuffer.position() == addrPartnerBitcoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerBitcoinPKHPointer incorrect";
+ dataByteBuffer.putLong(addrPartnerBitcoinPKH);
+
+ // Offset into 'trade' MESSAGE data payload for extracting hash-of-secret-A
+ assert dataByteBuffer.position() == addrTradeMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrTradeMessageHashOfSecretAOffset incorrect";
+ dataByteBuffer.putLong(64L);
+
+ // Index into data segment to hash of secret A, used by GET_B_IND
+ assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect";
+ dataByteBuffer.putLong(addrHashOfSecretA);
+
+ // Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address
+ assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect";
+ dataByteBuffer.putLong(32L);
+
+ // Source location and length for hashing any passed secret
+ assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect";
+ dataByteBuffer.putLong(addrMessageData);
+ assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect";
+ dataByteBuffer.putLong(32L);
+
+ // Pointer into data segment of where to save partner's receiving Qortal address, used by GET_B_IND
+ assert dataByteBuffer.position() == addrPartnerReceivingAddressPointer * MachineState.VALUE_SIZE : "addrPartnerReceivingAddressPointer incorrect";
+ dataByteBuffer.putLong(addrPartnerReceivingAddress);
+
+ assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants";
+
+ // Code labels
+ Integer labelRefund = null;
+
+ Integer labelTradeTxnLoop = null;
+ Integer labelCheckTradeTxn = null;
+ Integer labelCheckCancelTxn = null;
+ Integer labelNotTradeNorCancelTxn = null;
+ Integer labelCheckNonRefundTradeTxn = null;
+ Integer labelTradeTxnExtract = null;
+ Integer labelRedeemTxnLoop = null;
+ Integer labelCheckRedeemTxn = null;
+ Integer labelCheckRedeemTxnSender = null;
+ Integer labelPayout = null;
+
+ ByteBuffer codeByteBuffer = ByteBuffer.allocate(768);
+
+ // Two-pass version
+ for (int pass = 0; pass < 2; ++pass) {
+ codeByteBuffer.clear();
+
+ try {
+ /* Initialization */
+
+ // Use AT creation 'timestamp' as starting point for finding transactions sent to AT
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxnTimestamp));
+
+ // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4
+ codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B));
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer));
+
+ // Set restart position to after this opcode
+ codeByteBuffer.put(OpCode.SET_PCS.compile());
+
+ /* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */
+
+ /* Transaction processing loop */
+ labelTradeTxnLoop = codeByteBuffer.position();
+
+ /* Sleep until message arrives */
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.SLEEP_UNTIL_MESSAGE.value, addrLastTxnTimestamp));
+
+ // Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp));
+ // If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0.
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
+ // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
+ codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTxn)));
+ // Stop and wait for next block
+ codeByteBuffer.put(OpCode.STP_IMD.compile());
+
+ /* Check transaction */
+ labelCheckTradeTxn = codeByteBuffer.position();
+
+ // Update our 'last found transaction's timestamp' using 'timestamp' from transaction
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp));
+ // Extract transaction type (message/payment) from transaction and save type in addrTxnType
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType));
+ // If transaction type is not MESSAGE type then go look for another transaction
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop)));
+
+ /* Check transaction's sender. We're expecting AT creator's trade address for 'trade' message, or AT creator's own address for 'cancel' message. */
+
+ // Extract sender address from transaction into B register
+ codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
+ // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
+ // Compare each part of message sender's address with AT creator's trade address. If they don't match, check for cancel situation.
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
+ // Message sender's address matches AT creator's trade address so go process 'trade' message
+ codeByteBuffer.put(OpCode.JMP_ADR.compile(labelCheckNonRefundTradeTxn == null ? 0 : labelCheckNonRefundTradeTxn));
+
+ /* Checking message sender for possible cancel message */
+ labelCheckCancelTxn = codeByteBuffer.position();
+
+ // Compare each part of message sender's address with AT creator's address. If they don't match, look for another transaction.
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
+ // Partner address is AT creator's address, so cancel offer and finish.
+ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.CANCELLED.value));
+ // We're finished forever (finishing auto-refunds remaining balance to AT creator)
+ codeByteBuffer.put(OpCode.FIN_IMD.compile());
+
+ /* Not trade nor cancel message */
+ labelNotTradeNorCancelTxn = codeByteBuffer.position();
+
+ // Loop to find another transaction
+ codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop));
+
+ /* Possible switch-to-trade-mode message */
+ labelCheckNonRefundTradeTxn = codeByteBuffer.position();
+
+ // Check 'trade' message we received has expected number of message bytes
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength));
+ // If message length matches, branch to info extraction code
+ codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract)));
+ // Message length didn't match - go back to finding another 'trade' MESSAGE transaction
+ codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop));
+
+ /* Extracting info from 'trade' MESSAGE transaction */
+ labelTradeTxnExtract = codeByteBuffer.position();
+
+ // Extract message from transaction into B register
+ codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
+ // Save B register into data segment starting at addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer));
+
+ // Extract trade partner's Bitcoin public key hash (PKH) from message into B
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerBitcoinPKHOffset));
+ // Store partner's Bitcoin PKH (we only really use values from B1-B3)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerBitcoinPKHPointer));
+ // Extract AT trade timeout (minutes) (from B4)
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrRefundTimeout));
+
+ // Grab next 32 bytes
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset));
+
+ // Extract hash-of-secret-A (we only really use values from B1-B3)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer));
+ // Extract lockTime-A (from B4)
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA));
+
+ // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this transaction's 'timestamp', then save into addrRefundTimestamp
+ codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout));
+
+ /* We are in 'trade mode' */
+ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value));
+
+ // Set restart position to after this opcode
+ codeByteBuffer.put(OpCode.SET_PCS.compile());
+
+ /* Loop, waiting for trade timeout or 'redeem' MESSAGE from Qortal trade partner */
+
+ // Fetch current block 'timestamp'
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp));
+ // If we're not past refund 'timestamp' then look for next transaction
+ codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
+ // We're past refund 'timestamp' so go refund everything back to AT creator
+ codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund));
+
+ /* Transaction processing loop */
+ labelRedeemTxnLoop = codeByteBuffer.position();
+
+ // Find next transaction to this AT since the last one (if any)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp));
+ // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0.
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
+ // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
+ codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckRedeemTxn)));
+ // Stop and wait for next block
+ codeByteBuffer.put(OpCode.STP_IMD.compile());
+
+ /* Check transaction */
+ labelCheckRedeemTxn = codeByteBuffer.position();
+
+ // Update our 'last found transaction's timestamp' using 'timestamp' from transaction
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp));
+ // Extract transaction type (message/payment) from transaction and save type in addrTxnType
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType));
+ // If transaction type is not MESSAGE type then go look for another transaction
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
+
+ /* Check message payload length */
+ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength));
+ // If message length matches, branch to sender checking code
+ codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedRedeemMessageLength, calcOffset(codeByteBuffer, labelCheckRedeemTxnSender)));
+ // Message length didn't match - go back to finding another 'redeem' MESSAGE transaction
+ codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop));
+
+ /* Check transaction's sender */
+ labelCheckRedeemTxnSender = codeByteBuffer.position();
+
+ // Extract sender address from transaction into B register
+ codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
+ // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
+ // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction.
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalPartnerAddress1, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalPartnerAddress2, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalPartnerAddress3, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalPartnerAddress4, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
+
+ /* Check 'secret-A' in transaction's message */
+
+ // Extract secret-A from first 32 bytes of message from transaction into B register
+ codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
+ // Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer));
+ // Load B register with expected hash result (as pointed to by addrHashOfSecretAPointer)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretAPointer));
+ // Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength).
+ // Save the equality result (1 if they match, 0 otherwise) into addrResult.
+ codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength));
+ // If hashes don't match, addrResult will be zero so go find another transaction
+ codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout)));
+ codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop));
+
+ /* Success! Pay arranged amount to receiving address */
+ labelPayout = codeByteBuffer.position();
+
+ // Extract Qortal receiving address from next 32 bytes of message from transaction into B register
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset));
+ // Save B register into data segment starting at addrPartnerReceivingAddress (as pointed to by addrPartnerReceivingAddressPointer)
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerReceivingAddressPointer));
+ // Pay AT's balance to receiving address
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount));
+ // Set redeemed mode
+ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REDEEMED.value));
+ // We're finished forever (finishing auto-refunds remaining balance to AT creator)
+ codeByteBuffer.put(OpCode.FIN_IMD.compile());
+
+ // Fall-through to refunding any remaining balance back to AT creator
+
+ /* Refund balance back to AT creator */
+ labelRefund = codeByteBuffer.position();
+
+ /* NOP - to ensure BITCOIN ACCT is unique */
+ codeByteBuffer.put(OpCode.NOP.compile());
+
+ // Set refunded mode
+ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REFUNDED.value));
+ // We're finished forever (finishing auto-refunds remaining balance to AT creator)
+ codeByteBuffer.put(OpCode.FIN_IMD.compile());
+ } catch (CompilationException e) {
+ throw new IllegalStateException("Unable to compile BTC-QORT ACCT?", e);
+ }
+ }
+
+ codeByteBuffer.flip();
+
+ byte[] codeBytes = new byte[codeByteBuffer.limit()];
+ codeByteBuffer.get(codeBytes);
+
+ assert Arrays.equals(Crypto.digest(codeBytes), BitcoinACCTv3.CODE_BYTES_HASH)
+ : String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes)));
+
+ final short ciyamAtVersion = 2;
+ final short numCallStackPages = 0;
+ final short numUserStackPages = 0;
+ final long minActivationAmount = 0L;
+
+ return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount);
+ }
+
+ /**
+ * Returns CrossChainTradeData with useful info extracted from AT.
+ */
+ @Override
+ public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
+ ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
+ return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
+ }
+
+ /**
+ * Returns CrossChainTradeData with useful info extracted from AT.
+ */
+ @Override
+ public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
+ ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress());
+ return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
+ }
+
+ /**
+ * Returns CrossChainTradeData with useful info extracted from AT.
+ */
+ public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException {
+ byte[] addressBytes = new byte[25]; // for general use
+ String atAddress = atStateData.getATAddress();
+
+ CrossChainTradeData tradeData = new CrossChainTradeData();
+
+ tradeData.foreignBlockchain = SupportedBlockchain.BITCOIN.name();
+ tradeData.acctName = NAME;
+
+ tradeData.qortalAtAddress = atAddress;
+ tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey);
+ tradeData.creationTimestamp = creationTimestamp;
+
+ Account atAccount = new Account(repository, atAddress);
+ tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT);
+
+ byte[] stateData = atStateData.getStateData();
+ ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData);
+ dataByteBuffer.position(MachineState.HEADER_LENGTH);
+
+ /* Constants */
+
+ // Skip creator's trade address
+ dataByteBuffer.get(addressBytes);
+ tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes);
+ dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
+
+ // Creator's Bitcoin/foreign public key hash
+ tradeData.creatorForeignPKH = new byte[20];
+ dataByteBuffer.get(tradeData.creatorForeignPKH);
+ dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes
+
+ // We don't use secret-B
+ tradeData.hashOfSecretB = null;
+
+ // Redeem payout
+ tradeData.qortAmount = dataByteBuffer.getLong();
+
+ // Expected BTC amount
+ tradeData.expectedForeignAmount = dataByteBuffer.getLong();
+
+ // Trade timeout
+ tradeData.tradeTimeout = (int) dataByteBuffer.getLong();
+
+ // Skip MESSAGE transaction type
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip expected 'trade' message length
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip expected 'redeem' message length
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip pointer to creator's address
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip pointer to partner's Qortal trade address
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip pointer to message sender
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip 'trade' message data offset for partner's Bitcoin PKH
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip pointer to partner's Bitcoin PKH
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip 'trade' message data offset for hash-of-secret-A
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip pointer to hash-of-secret-A
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip 'redeem' message data offset for partner's Qortal receiving address
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip pointer to message data
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip message data length
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip pointer to partner's receiving address
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ /* End of constants / begin variables */
+
+ // Skip AT creator's address
+ dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
+
+ // Partner's trade address (if present)
+ dataByteBuffer.get(addressBytes);
+ String qortalRecipient = Base58.encode(addressBytes);
+ dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
+
+ // Potential lockTimeA (if in trade mode)
+ int lockTimeA = (int) dataByteBuffer.getLong();
+
+ // AT refund timeout (probably only useful for debugging)
+ int refundTimeout = (int) dataByteBuffer.getLong();
+
+ // Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height)
+ long tradeRefundTimestamp = dataByteBuffer.getLong();
+
+ // Skip last transaction timestamp
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip block timestamp
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip transaction type
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip temporary result
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip temporary message sender
+ dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
+
+ // Skip message length
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
+
+ // Skip temporary message data
+ dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
+
+ // Potential hash160 of secret A
+ byte[] hashOfSecretA = new byte[20];
+ dataByteBuffer.get(hashOfSecretA);
+ dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes
+
+ // Potential partner's Bitcoin PKH
+ byte[] partnerBitcoinPKH = new byte[20];
+ dataByteBuffer.get(partnerBitcoinPKH);
+ dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerBitcoinPKH.length); // skip to 32 bytes
+
+ // Partner's receiving address (if present)
+ byte[] partnerReceivingAddress = new byte[25];
+ dataByteBuffer.get(partnerReceivingAddress);
+ dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerReceivingAddress.length); // skip to 32 bytes
+
+ // Trade AT's 'mode'
+ long modeValue = dataByteBuffer.getLong();
+ AcctMode mode = AcctMode.valueOf((int) (modeValue & 0xffL));
+
+ /* End of variables */
+
+ if (mode != null && mode != AcctMode.OFFERING) {
+ tradeData.mode = mode;
+ tradeData.refundTimeout = refundTimeout;
+ tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
+ tradeData.qortalPartnerAddress = qortalRecipient;
+ tradeData.hashOfSecretA = hashOfSecretA;
+ tradeData.partnerForeignPKH = partnerBitcoinPKH;
+ tradeData.lockTimeA = lockTimeA;
+
+ if (mode == AcctMode.REDEEMED)
+ tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress);
+ } else {
+ tradeData.mode = AcctMode.OFFERING;
+ }
+
+ tradeData.duplicateDeprecated();
+
+ return tradeData;
+ }
+
+ /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */
+ public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) {
+ byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA);
+ return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes);
+ }
+
+ /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */
+ public static OfferMessageData extractOfferMessageData(byte[] messageData) {
+ if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH)
+ return null;
+
+ OfferMessageData offerMessageData = new OfferMessageData();
+ offerMessageData.partnerBitcoinPKH = Arrays.copyOfRange(messageData, 0, 20);
+ offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40);
+ offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40);
+
+ return offerMessageData;
+ }
+
+ /** Returns 'trade' MESSAGE payload for AT creator to send to AT. */
+ public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) {
+ byte[] data = new byte[TRADE_MESSAGE_LENGTH];
+ byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress);
+ byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA);
+ byte[] refundTimeoutBytes = BitTwiddling.toBEByteArray((long) refundTimeout);
+
+ System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length);
+ System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length);
+ System.arraycopy(refundTimeoutBytes, 0, data, 56, refundTimeoutBytes.length);
+ System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length);
+ System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length);
+
+ return data;
+ }
+
+ /** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */
+ @Override
+ public byte[] buildCancelMessage(String creatorQortalAddress) {
+ byte[] data = new byte[CANCEL_MESSAGE_LENGTH];
+ byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress);
+
+ System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length);
+
+ return data;
+ }
+
+ /** Returns 'redeem' MESSAGE payload for trade partner to send to AT. */
+ public static byte[] buildRedeemMessage(byte[] secretA, String qortalReceivingAddress) {
+ byte[] data = new byte[REDEEM_MESSAGE_LENGTH];
+ byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress);
+
+ System.arraycopy(secretA, 0, data, 0, secretA.length);
+ System.arraycopy(qortalReceivingAddressBytes, 0, data, 32, qortalReceivingAddressBytes.length);
+
+ return data;
+ }
+
+ /** Returns refund timeout (minutes) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */
+ public static int calcRefundTimeout(long offerMessageTimestamp, int lockTimeA) {
+ // refund should be triggered halfway between offerMessageTimestamp and lockTimeA
+ return (int) ((lockTimeA - (offerMessageTimestamp / 1000L)) / 2L / 60L);
+ }
+
+ @Override
+ public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
+ String atAddress = crossChainTradeData.qortalAtAddress;
+ String redeemerAddress = crossChainTradeData.qortalPartnerAddress;
+
+ // We don't have partner's public key so we check every message to AT
+ List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, atAddress, null, null, null);
+ if (messageTransactionsData == null)
+ return null;
+
+ // Find 'redeem' message
+ for (MessageTransactionData messageTransactionData : messageTransactionsData) {
+ // Check message payload type/encryption
+ if (messageTransactionData.isText() || messageTransactionData.isEncrypted())
+ continue;
+
+ // Check message payload size
+ byte[] messageData = messageTransactionData.getData();
+ if (messageData.length != REDEEM_MESSAGE_LENGTH)
+ // Wrong payload length
+ continue;
+
+ // Check sender
+ if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress))
+ // Wrong sender;
+ continue;
+
+ // Extract secretA
+ byte[] secretA = new byte[32];
+ System.arraycopy(messageData, 0, secretA, 0, secretA.length);
+
+ byte[] hashOfSecretA = Crypto.hash160(secretA);
+ if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA))
+ continue;
+
+ return secretA;
+ }
+
+ return null;
+ }
+
+}
diff --git a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java
index 5e3b4078..b249293c 100644
--- a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java
+++ b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java
@@ -13,8 +13,8 @@ import org.qortal.utils.Triple;
public enum SupportedBlockchain {
BITCOIN(Arrays.asList(
- Triple.valueOf(BitcoinACCTv1.NAME, BitcoinACCTv1.CODE_BYTES_HASH, BitcoinACCTv1::getInstance)
- // Could add improved BitcoinACCTv2 here in the future
+ Triple.valueOf(BitcoinACCTv1.NAME, BitcoinACCTv1.CODE_BYTES_HASH, BitcoinACCTv1::getInstance),
+ Triple.valueOf(BitcoinACCTv3.NAME, BitcoinACCTv3.CODE_BYTES_HASH, BitcoinACCTv3::getInstance)
)) {
@Override
public ForeignBlockchain getInstance() {
@@ -23,7 +23,7 @@ public enum SupportedBlockchain {
@Override
public ACCT getLatestAcct() {
- return BitcoinACCTv1.getInstance();
+ return BitcoinACCTv3.getInstance();
}
},
diff --git a/src/main/java/org/qortal/gui/SysTray.java b/src/main/java/org/qortal/gui/SysTray.java
index 7a24f825..4d02658d 100644
--- a/src/main/java/org/qortal/gui/SysTray.java
+++ b/src/main/java/org/qortal/gui/SysTray.java
@@ -23,6 +23,7 @@ import java.util.List;
import javax.swing.JDialog;
import javax.swing.JMenuItem;
+import javax.swing.JOptionPane;
import javax.swing.JPopupMenu;
import javax.swing.SwingWorker;
import javax.swing.event.PopupMenuEvent;
@@ -178,6 +179,14 @@ public class SysTray {
menu.add(syncTime);
}
+ JMenuItem about = new JMenuItem(Translator.INSTANCE.translate("SysTray", "BUILD_VERSION"));
+ about.addActionListener(actionEvent -> {
+ destroyHiddenDialog();
+
+ JOptionPane.showMessageDialog(null,"Qortal Core\n" + Translator.INSTANCE.translate("SysTray", "BUILD_VERSION") + ":\n" + Controller.getInstance().getVersionStringWithoutPrefix(),"Qortal Core",1);
+ });
+ menu.add(about);
+
JMenuItem exit = new JMenuItem(Translator.INSTANCE.translate("SysTray", "EXIT"));
exit.addActionListener(actionEvent -> {
destroyHiddenDialog();
diff --git a/src/main/java/org/qortal/naming/Name.java b/src/main/java/org/qortal/naming/Name.java
index b27e9454..97fe8bbb 100644
--- a/src/main/java/org/qortal/naming/Name.java
+++ b/src/main/java/org/qortal/naming/Name.java
@@ -195,7 +195,7 @@ public class Name {
this.repository.getNameRepository().save(this.nameData);
}
- public void buy(BuyNameTransactionData buyNameTransactionData) throws DataException {
+ public void buy(BuyNameTransactionData buyNameTransactionData, boolean modifyBalances) throws DataException {
// Save previous name-changing reference in this transaction's data
// Caller is expected to save
buyNameTransactionData.setNameReference(this.nameData.getReference());
@@ -203,15 +203,20 @@ public class Name {
// Mark not for-sale but leave price in case we want to orphan
this.nameData.setIsForSale(false);
- // Update seller's balance
- Account seller = new Account(this.repository, this.nameData.getOwner());
- seller.modifyAssetBalance(Asset.QORT, buyNameTransactionData.getAmount());
+ if (modifyBalances) {
+ // Update seller's balance
+ Account seller = new Account(this.repository, this.nameData.getOwner());
+ seller.modifyAssetBalance(Asset.QORT, buyNameTransactionData.getAmount());
+ }
// Set new owner
Account buyer = new PublicKeyAccount(this.repository, buyNameTransactionData.getBuyerPublicKey());
this.nameData.setOwner(buyer.getAddress());
- // Update buyer's balance
- buyer.modifyAssetBalance(Asset.QORT, - buyNameTransactionData.getAmount());
+
+ if (modifyBalances) {
+ // Update buyer's balance
+ buyer.modifyAssetBalance(Asset.QORT, -buyNameTransactionData.getAmount());
+ }
// Set name-changing reference to this transaction
this.nameData.setReference(buyNameTransactionData.getSignature());
diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java
index a04509f1..6bc58bb4 100644
--- a/src/main/java/org/qortal/network/Network.java
+++ b/src/main/java/org/qortal/network/Network.java
@@ -8,6 +8,7 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
import org.qortal.block.BlockChain;
import org.qortal.controller.Controller;
import org.qortal.controller.arbitrary.ArbitraryDataFileListManager;
+import org.qortal.controller.arbitrary.ArbitraryDataManager;
import org.qortal.crypto.Crypto;
import org.qortal.data.block.BlockData;
import org.qortal.data.network.PeerData;
@@ -259,6 +260,18 @@ public class Network {
return this.immutableConnectedPeers;
}
+ public List getImmutableConnectedDataPeers() {
+ return this.getImmutableConnectedPeers().stream()
+ .filter(p -> p.isDataPeer())
+ .collect(Collectors.toList());
+ }
+
+ public List getImmutableConnectedNonDataPeers() {
+ return this.getImmutableConnectedPeers().stream()
+ .filter(p -> !p.isDataPeer())
+ .collect(Collectors.toList());
+ }
+
public void addConnectedPeer(Peer peer) {
this.connectedPeers.add(peer); // thread safe thanks to synchronized list
this.immutableConnectedPeers = List.copyOf(this.connectedPeers); // also thread safe thanks to synchronized collection's toArray() being fed to List.of(array)
@@ -325,6 +338,7 @@ public class Network {
// Add this signature to the list of pending requests for this peer
LOGGER.info("Making connection to peer {} to request files for signature {}...", peerAddressString, Base58.encode(signature));
Peer peer = new Peer(peerData);
+ peer.setIsDataPeer(true);
peer.addPendingSignatureRequest(signature);
return this.connectPeer(peer);
// If connection (and handshake) is successful, data will automatically be requested
@@ -685,6 +699,7 @@ public class Network {
// Pick candidate
PeerData peerData = peers.get(peerIndex);
Peer newPeer = new Peer(peerData);
+ newPeer.setIsDataPeer(false);
// Update connection attempt info
peerData.setLastAttempted(now);
@@ -1069,11 +1084,13 @@ public class Network {
// (If inbound sent anything here, it's possible it could be processed out-of-order with handshake message).
if (peer.isOutbound()) {
- // Send our height
- Message heightMessage = buildHeightMessage(peer, Controller.getInstance().getChainTip());
- if (!peer.sendMessage(heightMessage)) {
- peer.disconnect("failed to send height/info");
- return;
+ if (!Settings.getInstance().isLite()) {
+ // Send our height
+ Message heightMessage = buildHeightMessage(peer, Controller.getInstance().getChainTip());
+ if (!peer.sendMessage(heightMessage)) {
+ peer.disconnect("failed to send height/info");
+ return;
+ }
}
// Send our peers list
diff --git a/src/main/java/org/qortal/network/Peer.java b/src/main/java/org/qortal/network/Peer.java
index dbb03fda..f99a94b1 100644
--- a/src/main/java/org/qortal/network/Peer.java
+++ b/src/main/java/org/qortal/network/Peer.java
@@ -12,6 +12,7 @@ import org.qortal.data.network.PeerData;
import org.qortal.network.message.ChallengeMessage;
import org.qortal.network.message.Message;
import org.qortal.network.message.MessageException;
+import org.qortal.network.message.MessageType;
import org.qortal.network.task.MessageTask;
import org.qortal.network.task.PingTask;
import org.qortal.settings.Settings;
@@ -64,6 +65,11 @@ public class Peer {
*/
private boolean isLocal;
+ /**
+ * True if connected for the purposes of transfering specific QDN data
+ */
+ private boolean isDataPeer;
+
private final UUID peerConnectionId = UUID.randomUUID();
private final Object byteBufferLock = new Object();
private ByteBuffer byteBuffer;
@@ -194,6 +200,14 @@ public class Peer {
return this.isOutbound;
}
+ public boolean isDataPeer() {
+ return isDataPeer;
+ }
+
+ public void setIsDataPeer(boolean isDataPeer) {
+ this.isDataPeer = isDataPeer;
+ }
+
public Handshake getHandshakeStatus() {
synchronized (this.handshakingLock) {
return this.handshakeStatus;
@@ -211,6 +225,11 @@ public class Peer {
}
private void generateRandomMaxConnectionAge() {
+ if (this.maxConnectionAge > 0L) {
+ // Already generated, so we don't want to overwrite the existing value
+ return;
+ }
+
// Retrieve the min and max connection time from the settings, and calculate the range
final int minPeerConnectionTime = Settings.getInstance().getMinPeerConnectionTime();
final int maxPeerConnectionTime = Settings.getInstance().getMaxPeerConnectionTime();
@@ -528,6 +547,10 @@ public class Peer {
// adjusting position accordingly, reset limit to capacity
this.byteBuffer.compact();
+ // Unsupported message type? Discard with no further processing
+ if (message.getType() == MessageType.UNSUPPORTED)
+ continue;
+
BlockingQueue queue = this.replyQueues.get(message.getId());
if (queue != null) {
// Adding message to queue will unblock thread waiting for response
@@ -893,6 +916,10 @@ public class Peer {
return maxConnectionAge;
}
+ public void setMaxConnectionAge(long maxConnectionAge) {
+ this.maxConnectionAge = maxConnectionAge;
+ }
+
public boolean hasReachedMaxConnectionAge() {
return this.getConnectionAge() > this.getMaxConnectionAge();
}
diff --git a/src/main/java/org/qortal/network/message/AccountBalanceMessage.java b/src/main/java/org/qortal/network/message/AccountBalanceMessage.java
new file mode 100644
index 00000000..7a9ad725
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/AccountBalanceMessage.java
@@ -0,0 +1,70 @@
+package org.qortal.network.message;
+
+import com.google.common.primitives.Longs;
+import org.qortal.data.account.AccountBalanceData;
+import org.qortal.transform.Transformer;
+import org.qortal.utils.Base58;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public class AccountBalanceMessage extends Message {
+
+ private static final int ADDRESS_LENGTH = Transformer.ADDRESS_LENGTH;
+
+ private AccountBalanceData accountBalanceData;
+
+ public AccountBalanceMessage(AccountBalanceData accountBalanceData) {
+ super(MessageType.ACCOUNT_BALANCE);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ try {
+ // Send raw address instead of base58 encoded
+ byte[] address = Base58.decode(accountBalanceData.getAddress());
+ bytes.write(address);
+
+ bytes.write(Longs.toByteArray(accountBalanceData.getAssetId()));
+
+ bytes.write(Longs.toByteArray(accountBalanceData.getBalance()));
+
+ } catch (IOException e) {
+ throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
+ }
+
+ this.dataBytes = bytes.toByteArray();
+ this.checksumBytes = Message.generateChecksum(this.dataBytes);
+ }
+
+ public AccountBalanceMessage(int id, AccountBalanceData accountBalanceData) {
+ super(id, MessageType.ACCOUNT_BALANCE);
+
+ this.accountBalanceData = accountBalanceData;
+ }
+
+ public AccountBalanceData getAccountBalanceData() {
+ return this.accountBalanceData;
+ }
+
+
+ public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) {
+ byte[] addressBytes = new byte[ADDRESS_LENGTH];
+ byteBuffer.get(addressBytes);
+ String address = Base58.encode(addressBytes);
+
+ long assetId = byteBuffer.getLong();
+
+ long balance = byteBuffer.getLong();
+
+ AccountBalanceData accountBalanceData = new AccountBalanceData(address, assetId, balance);
+ return new AccountBalanceMessage(id, accountBalanceData);
+ }
+
+ public AccountBalanceMessage cloneWithNewId(int newId) {
+ AccountBalanceMessage clone = new AccountBalanceMessage(this.accountBalanceData);
+ clone.setId(newId);
+ return clone;
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/message/AccountMessage.java b/src/main/java/org/qortal/network/message/AccountMessage.java
new file mode 100644
index 00000000..d22ef879
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/AccountMessage.java
@@ -0,0 +1,93 @@
+package org.qortal.network.message;
+
+import com.google.common.primitives.Ints;
+import org.qortal.data.account.AccountData;
+import org.qortal.transform.Transformer;
+import org.qortal.utils.Base58;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public class AccountMessage extends Message {
+
+ private static final int ADDRESS_LENGTH = Transformer.ADDRESS_LENGTH;
+ private static final int REFERENCE_LENGTH = Transformer.SIGNATURE_LENGTH;
+ private static final int PUBLIC_KEY_LENGTH = Transformer.PUBLIC_KEY_LENGTH;
+
+ private AccountData accountData;
+
+ public AccountMessage(AccountData accountData) {
+ super(MessageType.ACCOUNT);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ try {
+ // Send raw address instead of base58 encoded
+ byte[] address = Base58.decode(accountData.getAddress());
+ bytes.write(address);
+
+ bytes.write(accountData.getReference());
+
+ bytes.write(accountData.getPublicKey());
+
+ bytes.write(Ints.toByteArray(accountData.getDefaultGroupId()));
+
+ bytes.write(Ints.toByteArray(accountData.getFlags()));
+
+ bytes.write(Ints.toByteArray(accountData.getLevel()));
+
+ bytes.write(Ints.toByteArray(accountData.getBlocksMinted()));
+
+ bytes.write(Ints.toByteArray(accountData.getBlocksMintedAdjustment()));
+
+ } catch (IOException e) {
+ throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
+ }
+
+ this.dataBytes = bytes.toByteArray();
+ this.checksumBytes = Message.generateChecksum(this.dataBytes);
+ }
+
+ public AccountMessage(int id, AccountData accountData) {
+ super(id, MessageType.ACCOUNT);
+
+ this.accountData = accountData;
+ }
+
+ public AccountData getAccountData() {
+ return this.accountData;
+ }
+
+ public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) {
+ byte[] addressBytes = new byte[ADDRESS_LENGTH];
+ byteBuffer.get(addressBytes);
+ String address = Base58.encode(addressBytes);
+
+ byte[] reference = new byte[REFERENCE_LENGTH];
+ byteBuffer.get(reference);
+
+ byte[] publicKey = new byte[PUBLIC_KEY_LENGTH];
+ byteBuffer.get(publicKey);
+
+ int defaultGroupId = byteBuffer.getInt();
+
+ int flags = byteBuffer.getInt();
+
+ int level = byteBuffer.getInt();
+
+ int blocksMinted = byteBuffer.getInt();
+
+ int blocksMintedAdjustment = byteBuffer.getInt();
+
+ AccountData accountData = new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment);
+ return new AccountMessage(id, accountData);
+ }
+
+ public AccountMessage cloneWithNewId(int newId) {
+ AccountMessage clone = new AccountMessage(this.accountData);
+ clone.setId(newId);
+ return clone;
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/message/GetAccountBalanceMessage.java b/src/main/java/org/qortal/network/message/GetAccountBalanceMessage.java
new file mode 100644
index 00000000..43892b83
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/GetAccountBalanceMessage.java
@@ -0,0 +1,63 @@
+package org.qortal.network.message;
+
+import com.google.common.primitives.Longs;
+import org.qortal.transform.Transformer;
+import org.qortal.utils.Base58;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public class GetAccountBalanceMessage extends Message {
+
+ private static final int ADDRESS_LENGTH = Transformer.ADDRESS_LENGTH;
+
+ private String address;
+ private long assetId;
+
+ public GetAccountBalanceMessage(String address, long assetId) {
+ super(MessageType.GET_ACCOUNT_BALANCE);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ try {
+ // Send raw address instead of base58 encoded
+ byte[] addressBytes = Base58.decode(address);
+ bytes.write(addressBytes);
+
+ bytes.write(Longs.toByteArray(assetId));
+
+ } catch (IOException e) {
+ throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
+ }
+
+ this.dataBytes = bytes.toByteArray();
+ this.checksumBytes = Message.generateChecksum(this.dataBytes);
+ }
+
+ private GetAccountBalanceMessage(int id, String address, long assetId) {
+ super(id, MessageType.GET_ACCOUNT_BALANCE);
+
+ this.address = address;
+ this.assetId = assetId;
+ }
+
+ public String getAddress() {
+ return this.address;
+ }
+
+ public long getAssetId() {
+ return this.assetId;
+ }
+
+ public static Message fromByteBuffer(int id, ByteBuffer bytes) {
+ byte[] addressBytes = new byte[ADDRESS_LENGTH];
+ bytes.get(addressBytes);
+ String address = Base58.encode(addressBytes);
+
+ long assetId = bytes.getLong();
+
+ return new GetAccountBalanceMessage(id, address, assetId);
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/message/GetAccountMessage.java b/src/main/java/org/qortal/network/message/GetAccountMessage.java
new file mode 100644
index 00000000..4f2a6dec
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/GetAccountMessage.java
@@ -0,0 +1,56 @@
+package org.qortal.network.message;
+
+import org.qortal.transform.Transformer;
+import org.qortal.utils.Base58;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+
+public class GetAccountMessage extends Message {
+
+ private static final int ADDRESS_LENGTH = Transformer.ADDRESS_LENGTH;
+
+ private String address;
+
+ public GetAccountMessage(String address) {
+ super(MessageType.GET_ACCOUNT);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ try {
+ // Send raw address instead of base58 encoded
+ byte[] addressBytes = Base58.decode(address);
+ bytes.write(addressBytes);
+
+ } catch (IOException e) {
+ throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
+ }
+
+ this.dataBytes = bytes.toByteArray();
+ this.checksumBytes = Message.generateChecksum(this.dataBytes);
+ }
+
+ private GetAccountMessage(int id, String address) {
+ super(id, MessageType.GET_ACCOUNT);
+
+ this.address = address;
+ }
+
+ public String getAddress() {
+ return this.address;
+ }
+
+ public static Message fromByteBuffer(int id, ByteBuffer bytes) {
+ if (bytes.remaining() != ADDRESS_LENGTH)
+ throw new BufferUnderflowException();
+
+ byte[] addressBytes = new byte[ADDRESS_LENGTH];
+ bytes.get(addressBytes);
+ String address = Base58.encode(addressBytes);
+
+ return new GetAccountMessage(id, address);
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/message/GetAccountNamesMessage.java b/src/main/java/org/qortal/network/message/GetAccountNamesMessage.java
new file mode 100644
index 00000000..bde697c5
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/GetAccountNamesMessage.java
@@ -0,0 +1,53 @@
+package org.qortal.network.message;
+
+import org.qortal.transform.Transformer;
+import org.qortal.utils.Base58;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public class GetAccountNamesMessage extends Message {
+
+ private static final int ADDRESS_LENGTH = Transformer.ADDRESS_LENGTH;
+
+ private String address;
+
+ public GetAccountNamesMessage(String address) {
+ super(MessageType.GET_ACCOUNT_NAMES);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ try {
+ // Send raw address instead of base58 encoded
+ byte[] addressBytes = Base58.decode(address);
+ bytes.write(addressBytes);
+
+ } catch (IOException e) {
+ throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
+ }
+
+ this.dataBytes = bytes.toByteArray();
+ this.checksumBytes = Message.generateChecksum(this.dataBytes);
+ }
+
+ private GetAccountNamesMessage(int id, String address) {
+ super(id, MessageType.GET_ACCOUNT_NAMES);
+
+ this.address = address;
+ }
+
+ public String getAddress() {
+ return this.address;
+ }
+
+
+ public static Message fromByteBuffer(int id, ByteBuffer bytes) {
+ byte[] addressBytes = new byte[ADDRESS_LENGTH];
+ bytes.get(addressBytes);
+ String address = Base58.encode(addressBytes);
+
+ return new GetAccountNamesMessage(id, address);
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/message/GetAccountTransactionsMessage.java b/src/main/java/org/qortal/network/message/GetAccountTransactionsMessage.java
new file mode 100644
index 00000000..fe921cc9
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/GetAccountTransactionsMessage.java
@@ -0,0 +1,69 @@
+package org.qortal.network.message;
+
+import com.google.common.primitives.Ints;
+import org.qortal.transform.Transformer;
+import org.qortal.utils.Base58;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public class GetAccountTransactionsMessage extends Message {
+
+ private static final int ADDRESS_LENGTH = Transformer.ADDRESS_LENGTH;
+
+ private String address;
+ private int limit;
+ private int offset;
+
+ public GetAccountTransactionsMessage(String address, int limit, int offset) {
+ super(MessageType.GET_ACCOUNT_TRANSACTIONS);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ try {
+ // Send raw address instead of base58 encoded
+ byte[] addressBytes = Base58.decode(address);
+ bytes.write(addressBytes);
+
+ bytes.write(Ints.toByteArray(limit));
+
+ bytes.write(Ints.toByteArray(offset));
+
+ } catch (IOException e) {
+ throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
+ }
+
+ this.dataBytes = bytes.toByteArray();
+ this.checksumBytes = Message.generateChecksum(this.dataBytes);
+ }
+
+ private GetAccountTransactionsMessage(int id, String address, int limit, int offset) {
+ super(id, MessageType.GET_ACCOUNT_TRANSACTIONS);
+
+ this.address = address;
+ this.limit = limit;
+ this.offset = offset;
+ }
+
+ public String getAddress() {
+ return this.address;
+ }
+
+ public int getLimit() { return this.limit; }
+
+ public int getOffset() { return this.offset; }
+
+ public static Message fromByteBuffer(int id, ByteBuffer bytes) {
+ byte[] addressBytes = new byte[ADDRESS_LENGTH];
+ bytes.get(addressBytes);
+ String address = Base58.encode(addressBytes);
+
+ int limit = bytes.getInt();
+
+ int offset = bytes.getInt();
+
+ return new GetAccountTransactionsMessage(id, address, limit, offset);
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/message/GetNameMessage.java b/src/main/java/org/qortal/network/message/GetNameMessage.java
new file mode 100644
index 00000000..10fae08a
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/GetNameMessage.java
@@ -0,0 +1,53 @@
+package org.qortal.network.message;
+
+import org.qortal.naming.Name;
+import org.qortal.transform.TransformationException;
+import org.qortal.utils.Serialization;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public class GetNameMessage extends Message {
+
+ private String name;
+
+ public GetNameMessage(String address) {
+ super(MessageType.GET_NAME);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ try {
+ Serialization.serializeSizedStringV2(bytes, name);
+
+ } catch (IOException e) {
+ throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
+ }
+
+ this.dataBytes = bytes.toByteArray();
+ this.checksumBytes = Message.generateChecksum(this.dataBytes);
+ }
+
+ private GetNameMessage(int id, String name) {
+ super(id, MessageType.GET_NAME);
+
+ this.name = name;
+ }
+
+ public String getName() {
+ return this.name;
+ }
+
+
+ public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException {
+ try {
+ String name = Serialization.deserializeSizedStringV2(bytes, Name.MAX_NAME_SIZE);
+
+ return new GetNameMessage(id, name);
+
+ } catch (TransformationException e) {
+ throw new MessageException(e.getMessage(), e);
+ }
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/message/Message.java b/src/main/java/org/qortal/network/message/Message.java
index e92aca89..f752b5b9 100644
--- a/src/main/java/org/qortal/network/message/Message.java
+++ b/src/main/java/org/qortal/network/message/Message.java
@@ -103,8 +103,7 @@ public abstract class Message {
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));
+ messageType = MessageType.UNSUPPORTED;
// Optional message ID
byte hasId = readOnlyBuffer.get();
diff --git a/src/main/java/org/qortal/network/message/MessageType.java b/src/main/java/org/qortal/network/message/MessageType.java
index 48039a4d..a2637dfd 100644
--- a/src/main/java/org/qortal/network/message/MessageType.java
+++ b/src/main/java/org/qortal/network/message/MessageType.java
@@ -8,6 +8,9 @@ import static java.util.Arrays.stream;
import static java.util.stream.Collectors.toMap;
public enum MessageType {
+ // Pseudo-message, not sent over the wire
+ UNSUPPORTED(-1, UnsupportedMessage::fromByteBuffer),
+
// Handshaking
HELLO(0, HelloMessage::fromByteBuffer),
GOODBYE(1, GoodbyeMessage::fromByteBuffer),
@@ -61,7 +64,21 @@ public enum MessageType {
GET_TRADE_PRESENCES(141, GetTradePresencesMessage::fromByteBuffer),
ARBITRARY_METADATA(150, ArbitraryMetadataMessage::fromByteBuffer),
- GET_ARBITRARY_METADATA(151, GetArbitraryMetadataMessage::fromByteBuffer);
+ GET_ARBITRARY_METADATA(151, GetArbitraryMetadataMessage::fromByteBuffer),
+
+ // Lite node support
+ ACCOUNT(160, AccountMessage::fromByteBuffer),
+ GET_ACCOUNT(161, GetAccountMessage::fromByteBuffer),
+
+ ACCOUNT_BALANCE(170, AccountBalanceMessage::fromByteBuffer),
+ GET_ACCOUNT_BALANCE(171, GetAccountBalanceMessage::fromByteBuffer),
+
+ NAMES(180, NamesMessage::fromByteBuffer),
+ GET_ACCOUNT_NAMES(181, GetAccountNamesMessage::fromByteBuffer),
+ GET_NAME(182, GetNameMessage::fromByteBuffer),
+
+ TRANSACTIONS(190, TransactionsMessage::fromByteBuffer),
+ GET_ACCOUNT_TRANSACTIONS(191, GetAccountTransactionsMessage::fromByteBuffer);
public final int value;
public final MessageProducer fromByteBufferMethod;
diff --git a/src/main/java/org/qortal/network/message/NamesMessage.java b/src/main/java/org/qortal/network/message/NamesMessage.java
new file mode 100644
index 00000000..942818cc
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/NamesMessage.java
@@ -0,0 +1,142 @@
+package org.qortal.network.message;
+
+import com.google.common.primitives.Ints;
+import com.google.common.primitives.Longs;
+import org.qortal.data.naming.NameData;
+import org.qortal.naming.Name;
+import org.qortal.transform.TransformationException;
+import org.qortal.transform.Transformer;
+import org.qortal.utils.Serialization;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+public class NamesMessage extends Message {
+
+ private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH;
+
+ private List nameDataList;
+
+ public NamesMessage(List nameDataList) {
+ super(MessageType.NAMES);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ try {
+ bytes.write(Ints.toByteArray(nameDataList.size()));
+
+ for (int i = 0; i < nameDataList.size(); ++i) {
+ NameData nameData = nameDataList.get(i);
+
+ Serialization.serializeSizedStringV2(bytes, nameData.getName());
+
+ Serialization.serializeSizedStringV2(bytes, nameData.getReducedName());
+
+ Serialization.serializeAddress(bytes, nameData.getOwner());
+
+ Serialization.serializeSizedStringV2(bytes, nameData.getData());
+
+ bytes.write(Longs.toByteArray(nameData.getRegistered()));
+
+ Long updated = nameData.getUpdated();
+ int wasUpdated = (updated != null) ? 1 : 0;
+ bytes.write(Ints.toByteArray(wasUpdated));
+
+ if (updated != null) {
+ bytes.write(Longs.toByteArray(nameData.getUpdated()));
+ }
+
+ int isForSale = nameData.isForSale() ? 1 : 0;
+ bytes.write(Ints.toByteArray(isForSale));
+
+ if (nameData.isForSale()) {
+ bytes.write(Longs.toByteArray(nameData.getSalePrice()));
+ }
+
+ bytes.write(nameData.getReference());
+
+ bytes.write(Ints.toByteArray(nameData.getCreationGroupId()));
+ }
+
+ } catch (IOException e) {
+ throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
+ }
+
+ this.dataBytes = bytes.toByteArray();
+ this.checksumBytes = Message.generateChecksum(this.dataBytes);
+ }
+
+ public NamesMessage(int id, List nameDataList) {
+ super(id, MessageType.NAMES);
+
+ this.nameDataList = nameDataList;
+ }
+
+ public List getNameDataList() {
+ return this.nameDataList;
+ }
+
+
+ public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException {
+ try {
+ final int nameCount = bytes.getInt();
+
+ List nameDataList = new ArrayList<>(nameCount);
+
+ for (int i = 0; i < nameCount; ++i) {
+ String name = Serialization.deserializeSizedStringV2(bytes, Name.MAX_NAME_SIZE);
+
+ String reducedName = Serialization.deserializeSizedStringV2(bytes, Name.MAX_NAME_SIZE);
+
+ String owner = Serialization.deserializeAddress(bytes);
+
+ String data = Serialization.deserializeSizedStringV2(bytes, Name.MAX_DATA_SIZE);
+
+ long registered = bytes.getLong();
+
+ int wasUpdated = bytes.getInt();
+
+ Long updated = null;
+ if (wasUpdated == 1) {
+ updated = bytes.getLong();
+ }
+
+ boolean isForSale = (bytes.getInt() == 1);
+
+ Long salePrice = null;
+ if (isForSale) {
+ salePrice = bytes.getLong();
+ }
+
+ byte[] reference = new byte[SIGNATURE_LENGTH];
+ bytes.get(reference);
+
+ int creationGroupId = bytes.getInt();
+
+ NameData nameData = new NameData(name, reducedName, owner, data, registered, updated,
+ isForSale, salePrice, reference, creationGroupId);
+ nameDataList.add(nameData);
+ }
+
+ if (bytes.hasRemaining()) {
+ throw new BufferUnderflowException();
+ }
+
+ return new NamesMessage(id, nameDataList);
+
+ } catch (TransformationException e) {
+ throw new MessageException(e.getMessage(), e);
+ }
+ }
+
+ public NamesMessage cloneWithNewId(int newId) {
+ NamesMessage clone = new NamesMessage(this.nameDataList);
+ clone.setId(newId);
+ return clone;
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/message/TransactionsMessage.java b/src/main/java/org/qortal/network/message/TransactionsMessage.java
new file mode 100644
index 00000000..d7d60331
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/TransactionsMessage.java
@@ -0,0 +1,76 @@
+package org.qortal.network.message;
+
+import com.google.common.primitives.Ints;
+import org.qortal.data.transaction.TransactionData;
+import org.qortal.transform.TransformationException;
+import org.qortal.transform.transaction.TransactionTransformer;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+public class TransactionsMessage extends Message {
+
+ private List transactions;
+
+ public TransactionsMessage(List transactions) throws MessageException {
+ super(MessageType.TRANSACTIONS);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ try {
+ bytes.write(Ints.toByteArray(transactions.size()));
+
+ for (int i = 0; i < transactions.size(); ++i) {
+ TransactionData transactionData = transactions.get(i);
+
+ byte[] serializedTransactionData = TransactionTransformer.toBytes(transactionData);
+ bytes.write(serializedTransactionData);
+ }
+
+ } catch (IOException e) {
+ throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
+ } catch (TransformationException e) {
+ throw new MessageException(e.getMessage(), e);
+ }
+
+ this.dataBytes = bytes.toByteArray();
+ this.checksumBytes = Message.generateChecksum(this.dataBytes);
+ }
+
+ private TransactionsMessage(int id, List transactions) {
+ super(id, MessageType.TRANSACTIONS);
+
+ this.transactions = transactions;
+ }
+
+ public List getTransactions() {
+ return this.transactions;
+ }
+
+ public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException {
+ try {
+ final int transactionCount = byteBuffer.getInt();
+
+ List transactions = new ArrayList<>();
+
+ for (int i = 0; i < transactionCount; ++i) {
+ TransactionData transactionData = TransactionTransformer.fromByteBuffer(byteBuffer);
+ transactions.add(transactionData);
+ }
+
+ if (byteBuffer.hasRemaining()) {
+ throw new BufferUnderflowException();
+ }
+
+ return new TransactionsMessage(id, transactions);
+
+ } catch (TransformationException e) {
+ throw new MessageException(e.getMessage(), e);
+ }
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/message/UnsupportedMessage.java b/src/main/java/org/qortal/network/message/UnsupportedMessage.java
new file mode 100644
index 00000000..649092f6
--- /dev/null
+++ b/src/main/java/org/qortal/network/message/UnsupportedMessage.java
@@ -0,0 +1,20 @@
+package org.qortal.network.message;
+
+import java.nio.ByteBuffer;
+
+public class UnsupportedMessage extends Message {
+
+ public UnsupportedMessage() {
+ super(MessageType.UNSUPPORTED);
+ throw new UnsupportedOperationException("Unsupported message is unsupported!");
+ }
+
+ private UnsupportedMessage(int id) {
+ super(id, MessageType.UNSUPPORTED);
+ }
+
+ public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException {
+ return new UnsupportedMessage(id);
+ }
+
+}
diff --git a/src/main/java/org/qortal/network/task/ChannelAcceptTask.java b/src/main/java/org/qortal/network/task/ChannelAcceptTask.java
index 3e2a3033..da04cf9a 100644
--- a/src/main/java/org/qortal/network/task/ChannelAcceptTask.java
+++ b/src/main/java/org/qortal/network/task/ChannelAcceptTask.java
@@ -2,6 +2,7 @@ package org.qortal.network.task;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.qortal.controller.arbitrary.ArbitraryDataFileManager;
import org.qortal.network.Network;
import org.qortal.network.Peer;
import org.qortal.network.PeerAddress;
@@ -65,6 +66,47 @@ public class ChannelAcceptTask implements Task {
return;
}
+ // We allow up to a maximum of maxPeers connected peers, of which...
+ // - maxDataPeers must be prearranged data connections (these are intentionally short-lived)
+ // - the remainder can be any regular peers
+
+ // Firstly, determine the maximum limits
+ int maxPeers = Settings.getInstance().getMaxPeers();
+ int maxDataPeers = Settings.getInstance().getMaxDataPeers();
+ int maxRegularPeers = maxPeers - maxDataPeers;
+
+ // Next, obtain the current state
+ int connectedDataPeerCount = Network.getInstance().getImmutableConnectedDataPeers().size();
+ int connectedRegularPeerCount = Network.getInstance().getImmutableConnectedNonDataPeers().size();
+
+ // Check if the incoming connection should be considered a data or regular peer
+ boolean isDataPeer = ArbitraryDataFileManager.getInstance().isPeerRequestingData(address.getHost());
+
+ // Finally, decide if we have any capacity for this incoming peer
+ boolean connectionLimitReached;
+ if (isDataPeer) {
+ connectionLimitReached = (connectedDataPeerCount >= maxDataPeers);
+ }
+ else {
+ connectionLimitReached = (connectedRegularPeerCount >= maxRegularPeers);
+ }
+
+ // Extra maxPeers check just to be safe
+ if (Network.getInstance().getImmutableConnectedPeers().size() >= maxPeers) {
+ connectionLimitReached = true;
+ }
+
+ if (connectionLimitReached) {
+ try {
+ // We have enough peers
+ LOGGER.debug("Connection discarded from peer {} because the server is full", address);
+ socketChannel.close();
+ } catch (IOException e) {
+ // IGNORE
+ }
+ return;
+ }
+
final Long now = NTP.getTime();
Peer newPeer;
@@ -78,6 +120,10 @@ public class ChannelAcceptTask implements Task {
LOGGER.debug("Connection accepted from peer {}", address);
newPeer = new Peer(socketChannel);
+ if (isDataPeer) {
+ newPeer.setMaxConnectionAge(Settings.getInstance().getMaxDataPeerConnectionTime() * 1000L);
+ }
+ newPeer.setIsDataPeer(isDataPeer);
network.addConnectedPeer(newPeer);
} catch (IOException e) {
diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java
index 714ada28..0d9325b9 100644
--- a/src/main/java/org/qortal/repository/RepositoryManager.java
+++ b/src/main/java/org/qortal/repository/RepositoryManager.java
@@ -62,6 +62,11 @@ public abstract class RepositoryManager {
}
public static boolean archive(Repository repository) {
+ if (Settings.getInstance().isLite()) {
+ // Lite nodes have no blockchain
+ return false;
+ }
+
// Bulk archive the database the first time we use archive mode
if (Settings.getInstance().isArchiveEnabled()) {
if (RepositoryManager.canArchiveOrPrune()) {
@@ -82,6 +87,11 @@ public abstract class RepositoryManager {
}
public static boolean prune(Repository repository) {
+ if (Settings.getInstance().isLite()) {
+ // Lite nodes have no blockchain
+ return false;
+ }
+
// Bulk prune the database the first time we use top-only or block archive mode
if (Settings.getInstance().isTopOnly() ||
Settings.getInstance().isArchiveEnabled()) {
diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java
index 20096eb8..4fb9bb12 100644
--- a/src/main/java/org/qortal/repository/TransactionRepository.java
+++ b/src/main/java/org/qortal/repository/TransactionRepository.java
@@ -257,7 +257,8 @@ public interface TransactionRepository {
* @return list of transactions, or empty if none.
* @throws DataException
*/
- public List getUnconfirmedTransactions(Integer limit, Integer offset, Boolean reverse) throws DataException;
+ public List getUnconfirmedTransactions(List txTypes, byte[] creatorPublicKey,
+ Integer limit, Integer offset, Boolean reverse) throws DataException;
/**
* Returns list of unconfirmed transactions in timestamp-else-signature order.
@@ -266,7 +267,7 @@ public interface TransactionRepository {
* @throws DataException
*/
public default List getUnconfirmedTransactions() throws DataException {
- return getUnconfirmedTransactions(null, null, null);
+ return getUnconfirmedTransactions(null, null, null, null, null);
}
/**
diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java
index f228944e..e3ef13be 100644
--- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java
+++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java
@@ -313,7 +313,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
@Override
public List getSignaturesInvolvingAddress(String address) throws DataException {
- String sql = "SELECT signature FROM TransactionRecipients WHERE participant = ?";
+ String sql = "SELECT signature FROM TransactionParticipants WHERE participant = ?";
List signatures = new ArrayList<>();
@@ -1213,11 +1213,56 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
}
@Override
- public List getUnconfirmedTransactions(Integer limit, Integer offset, Boolean reverse) throws DataException {
- StringBuilder sql = new StringBuilder(256);
- sql.append("SELECT signature FROM UnconfirmedTransactions ");
+ public List getUnconfirmedTransactions(List txTypes, byte[] creatorPublicKey,
+ Integer limit, Integer offset, Boolean reverse) throws DataException {
+ List whereClauses = new ArrayList<>();
+ List