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.0 org.qortal qortal - 3.2.5 + 3.3.2 jar true 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 bindParams = new ArrayList<>(); - sql.append("ORDER BY created_when"); + boolean hasCreatorPublicKey = creatorPublicKey != null; + boolean hasTxTypes = txTypes != null && !txTypes.isEmpty(); + + if (creatorPublicKey != null) { + whereClauses.add("Transactions.creator = ?"); + bindParams.add(creatorPublicKey); + } + + StringBuilder sql = new StringBuilder(256); + sql.append("SELECT signature FROM UnconfirmedTransactions"); + if (hasCreatorPublicKey || hasTxTypes) { + sql.append(" JOIN Transactions USING (signature) "); + } + + if (hasTxTypes) { + StringBuilder txTypesIn = new StringBuilder(256); + txTypesIn.append("Transactions.type IN ("); + + // ints are safe enough to use literally + final int txTypesSize = txTypes.size(); + for (int tti = 0; tti < txTypesSize; ++tti) { + if (tti != 0) + txTypesIn.append(", "); + + txTypesIn.append(txTypes.get(tti).value); + } + + txTypesIn.append(")"); + + whereClauses.add(txTypesIn.toString()); + } + + if (!whereClauses.isEmpty()) { + sql.append(" WHERE "); + + final int whereClausesSize = whereClauses.size(); + for (int wci = 0; wci < whereClausesSize; ++wci) { + if (wci != 0) + sql.append(" AND "); + + sql.append(whereClauses.get(wci)); + } + } + + sql.append(" ORDER BY created_when"); if (reverse != null && reverse) sql.append(" DESC"); @@ -1230,7 +1275,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository { List transactions = new ArrayList<>(); // Find transactions with no corresponding row in BlockTransactions - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) { + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { if (resultSet == null) return transactions; diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index c8a37784..a40df9b4 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -145,6 +145,8 @@ public class Settings { * This has a significant effect on execution time. */ private int onlineSignaturesTrimBatchSize = 100; // blocks + /** Lite nodes don't sync blocks, and instead request "derived data" from peers */ + private boolean lite = false; /** Whether we should prune old data to reduce database size * This prevents the node from being able to serve older blocks */ @@ -190,7 +192,9 @@ public class Settings { /** Target number of outbound connections to peers we should make. */ private int minOutboundPeers = 16; /** Maximum number of peer connections we allow. */ - private int maxPeers = 32; + private int maxPeers = 36; + /** Number of slots to reserve for short-lived QDN data transfers */ + private int maxDataPeers = 4; /** Maximum number of threads for network engine. */ private int maxNetworkThreadPoolSize = 32; /** Maximum number of threads for network proof-of-work compute, used during handshaking. */ @@ -209,6 +213,8 @@ public class Settings { private int minPeerConnectionTime = 5 * 60; // seconds /** Maximum time (in seconds) that we should attempt to remain connected to a peer for */ private int maxPeerConnectionTime = 60 * 60; // seconds + /** Maximum time (in seconds) that a peer should remain connected when requesting QDN data */ + private int maxDataPeerConnectionTime = 2 * 60; // seconds /** Whether to sync multiple blocks at once in normal operation */ private boolean fastSyncEnabled = true; @@ -650,6 +656,10 @@ public class Settings { return this.maxPeers; } + public int getMaxDataPeers() { + return this.maxDataPeers; + } + public int getMaxNetworkThreadPoolSize() { return this.maxNetworkThreadPoolSize; } @@ -668,6 +678,10 @@ public class Settings { public int getMaxPeerConnectionTime() { return this.maxPeerConnectionTime; } + public int getMaxDataPeerConnectionTime() { + return this.maxDataPeerConnectionTime; + } + public String getBlockchainConfig() { return this.blockchainConfig; } @@ -808,6 +822,10 @@ public class Settings { return this.onlineSignaturesTrimBatchSize; } + public boolean isLite() { + return this.lite; + } + public boolean isTopOnly() { return this.topOnly; } diff --git a/src/main/java/org/qortal/transaction/BuyNameTransaction.java b/src/main/java/org/qortal/transaction/BuyNameTransaction.java index c4e5f29c..fe6d8d34 100644 --- a/src/main/java/org/qortal/transaction/BuyNameTransaction.java +++ b/src/main/java/org/qortal/transaction/BuyNameTransaction.java @@ -114,7 +114,7 @@ public class BuyNameTransaction extends Transaction { public void process() throws DataException { // Buy Name Name name = new Name(this.repository, this.buyNameTransactionData.getName()); - name.buy(this.buyNameTransactionData); + name.buy(this.buyNameTransactionData, true); // Save transaction with updated "name reference" pointing to previous transaction that changed name this.repository.getTransactionRepository().save(this.buyNameTransactionData); diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index 79a6478b..a1fd6baa 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -393,7 +393,10 @@ public abstract class Transaction { * @return transaction version number */ public static int getVersionByTimestamp(long timestamp) { - if (timestamp >= BlockChain.getInstance().getTransactionV5Timestamp()) { + if (timestamp >= BlockChain.getInstance().getTransactionV6Timestamp()) { + return 6; + } + else if (timestamp >= BlockChain.getInstance().getTransactionV5Timestamp()) { return 5; } return 4; @@ -530,11 +533,6 @@ public abstract class Transaction { if (now >= this.getDeadline()) return ValidationResult.TIMESTAMP_TOO_OLD; - // Transactions with a expiry prior to latest block's timestamp are too old - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (this.getDeadline() <= latestBlock.getTimestamp()) - return ValidationResult.TIMESTAMP_TOO_OLD; - // Transactions with a timestamp too far into future are too new long maxTimestamp = now + Settings.getInstance().getMaxTransactionTimestampFuture(); if (this.transactionData.getTimestamp() > maxTimestamp) @@ -545,6 +543,15 @@ public abstract class Transaction { if (feeValidationResult != ValidationResult.OK) return feeValidationResult; + if (Settings.getInstance().isLite()) { + // Everything from this point is difficult to validate for a lite node, since it has no blocks. + // For now, we will assume it is valid, to allow it to move around the network easily. + // If it turns out to be invalid, other full/top-only nodes will reject it on receipt. + // Lite nodes would never mint a block, so there's not much risk of holding invalid transactions. + // TODO: implement lite-only validation for each transaction type + return ValidationResult.OK; + } + PublicKeyAccount creator = this.getCreator(); if (creator == null) return ValidationResult.MISSING_CREATOR; @@ -553,6 +560,12 @@ public abstract class Transaction { if (countUnconfirmedByCreator(creator) >= Settings.getInstance().getMaxUnconfirmedPerAccount()) return ValidationResult.TOO_MANY_UNCONFIRMED; + // Transactions with a expiry prior to latest block's timestamp are too old + // Not relevant for lite nodes, as they don't have any blocks + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (this.getDeadline() <= latestBlock.getTimestamp()) + return ValidationResult.TIMESTAMP_TOO_OLD; + // Check transaction's txGroupId if (!this.isValidTxGroupId()) return ValidationResult.INVALID_TX_GROUP_ID; diff --git a/src/main/java/org/qortal/transform/transaction/AtTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/AtTransactionTransformer.java index 6b3c93f0..bb1381b2 100644 --- a/src/main/java/org/qortal/transform/transaction/AtTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/AtTransactionTransformer.java @@ -4,8 +4,12 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; +import org.qortal.account.NullAccount; import org.qortal.data.transaction.ATTransactionData; +import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.transaction.Transaction; import org.qortal.transform.TransformationException; import org.qortal.utils.Serialization; @@ -17,12 +21,97 @@ public class AtTransactionTransformer extends TransactionTransformer { protected static final TransactionLayout layout = null; // Property lengths + + private static final int MESSAGE_SIZE_LENGTH = INT_LENGTH; + private static final int TYPE_LENGTH = INT_LENGTH; + + public static TransactionData fromByteBuffer(ByteBuffer byteBuffer) throws TransformationException { - throw new TransformationException("Serialized AT transactions should not exist!"); + long timestamp = byteBuffer.getLong(); + + int version = Transaction.getVersionByTimestamp(timestamp); + + byte[] reference = new byte[REFERENCE_LENGTH]; + byteBuffer.get(reference); + + String atAddress = Serialization.deserializeAddress(byteBuffer); + + String recipient = Serialization.deserializeAddress(byteBuffer); + + // Default to PAYMENT-type, as there were no MESSAGE-type transactions before transaction v6 + boolean isMessageType = false; + + if (version >= 6) { + // Version 6 supports both PAYMENT-type and MESSAGE-type, specified using an integer. + // This could be extended to support additional types at a later date, simply by adding + // additional integer values. + int type = byteBuffer.getInt(); + isMessageType = (type == 1); + } + + int messageLength = 0; + byte[] message = null; + long assetId = 0L; + long amount = 0L; + + if (isMessageType) { + messageLength = byteBuffer.getInt(); + + message = new byte[messageLength]; + byteBuffer.get(message); + } + else { + // Assume PAYMENT-type, as there were no MESSAGE-type transactions until this time + assetId = byteBuffer.getLong(); + + amount = byteBuffer.getLong(); + } + + long fee = byteBuffer.getLong(); + + byte[] signature = new byte[SIGNATURE_LENGTH]; + byteBuffer.get(signature); + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, NullAccount.PUBLIC_KEY, fee, signature); + + if (isMessageType) { + // MESSAGE-type + return new ATTransactionData(baseTransactionData, atAddress, recipient, message); + } + else { + // PAYMENT-type + return new ATTransactionData(baseTransactionData, atAddress, recipient, amount, assetId); + } + } public static int getDataLength(TransactionData transactionData) throws TransformationException { - throw new TransformationException("Serialized AT transactions should not exist!"); + ATTransactionData atTransactionData = (ATTransactionData) transactionData; + int version = Transaction.getVersionByTimestamp(transactionData.getTimestamp()); + + final int baseLength = TYPE_LENGTH + TIMESTAMP_LENGTH + REFERENCE_LENGTH + ADDRESS_LENGTH + ADDRESS_LENGTH + + FEE_LENGTH + SIGNATURE_LENGTH; + + int typeSpecificLength = 0; + + byte[] message = atTransactionData.getMessage(); + boolean isMessageType = (message != null); + + // MESSAGE-type and PAYMENT-type transactions will have differing lengths + if (isMessageType) { + typeSpecificLength = MESSAGE_SIZE_LENGTH + message.length; + } + else { + typeSpecificLength = ASSET_ID_LENGTH + AMOUNT_LENGTH; + } + + // V6 transactions include an extra integer to denote the type + int versionSpecificLength = 0; + if (version >= 6) { + versionSpecificLength = TYPE_LENGTH; + } + + return baseLength + typeSpecificLength + versionSpecificLength; } // Used for generating fake transaction signatures @@ -30,6 +119,8 @@ public class AtTransactionTransformer extends TransactionTransformer { try { ATTransactionData atTransactionData = (ATTransactionData) transactionData; + int version = Transaction.getVersionByTimestamp(atTransactionData.getTimestamp()); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); bytes.write(Ints.toByteArray(atTransactionData.getType().value)); @@ -42,7 +133,17 @@ public class AtTransactionTransformer extends TransactionTransformer { byte[] message = atTransactionData.getMessage(); - if (message != null) { + boolean isMessageType = (message != null); + int type = isMessageType ? 1 : 0; + + if (version >= 6) { + // Version 6 supports both PAYMENT-type and MESSAGE-type, specified using an integer. + // This could be extended to support additional types at a later date, simply by adding + // additional integer values. + bytes.write(Ints.toByteArray(type)); + } + + if (isMessageType) { // MESSAGE-type bytes.write(Ints.toByteArray(message.length)); bytes.write(message); diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 5a59df91..b514653d 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -59,7 +59,8 @@ "newBlockSigHeight": 320000, "shareBinFix": 399000, "calcChainWeightTimestamp": 1620579600000, - "transactionV5Timestamp": 1642176000000 + "transactionV5Timestamp": 1642176000000, + "transactionV6Timestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/main/resources/i18n/SysTray_de.properties b/src/main/resources/i18n/SysTray_de.properties index 1880aa27..b949ca8c 100644 --- a/src/main/resources/i18n/SysTray_de.properties +++ b/src/main/resources/i18n/SysTray_de.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = Datenbank Instandhaltung EXIT = Verlassen +LITE_NODE = Lite node + MINTING_DISABLED = NOT minting MINTING_ENABLED = \u2714 Minting diff --git a/src/main/resources/i18n/SysTray_en.properties b/src/main/resources/i18n/SysTray_en.properties index 296f9760..204f0df2 100644 --- a/src/main/resources/i18n/SysTray_en.properties +++ b/src/main/resources/i18n/SysTray_en.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = Database Maintenance EXIT = Exit +LITE_NODE = Lite node + MINTING_DISABLED = NOT minting MINTING_ENABLED = \u2714 Minting diff --git a/src/main/resources/i18n/SysTray_fi.properties b/src/main/resources/i18n/SysTray_fi.properties index 07289551..bc787715 100644 --- a/src/main/resources/i18n/SysTray_fi.properties +++ b/src/main/resources/i18n/SysTray_fi.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = Tietokannan ylläpito EXIT = Pois +LITE_NODE = Lite node + MINTING_DISABLED = EI lyö rahaa MINTING_ENABLED = \u2714 Lyö rahaa diff --git a/src/main/resources/i18n/SysTray_fr.properties b/src/main/resources/i18n/SysTray_fr.properties index 61e8002a..6e60713c 100644 --- a/src/main/resources/i18n/SysTray_fr.properties +++ b/src/main/resources/i18n/SysTray_fr.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = Maintenance de la base de données EXIT = Quitter +LITE_NODE = Lite node + MINTING_DISABLED = NE mint PAS MINTING_ENABLED = \u2714 Minting diff --git a/src/main/resources/i18n/SysTray_hu.properties b/src/main/resources/i18n/SysTray_hu.properties index 5a082b6d..9bc51ff5 100644 --- a/src/main/resources/i18n/SysTray_hu.properties +++ b/src/main/resources/i18n/SysTray_hu.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = Adatbázis karbantartás EXIT = Kilépés +LITE_NODE = Lite node + MINTING_DISABLED = QORT-érmeverés jelenleg nincs folyamatban MINTING_ENABLED = \u2714 QORT-érmeverés folyamatban diff --git a/src/main/resources/i18n/SysTray_it.properties b/src/main/resources/i18n/SysTray_it.properties index dd02aefa..bf61cc46 100644 --- a/src/main/resources/i18n/SysTray_it.properties +++ b/src/main/resources/i18n/SysTray_it.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = Manutenzione del database EXIT = Uscita +LITE_NODE = Lite node + MINTING_DISABLED = Conio disabilitato MINTING_ENABLED = \u2714 Conio abilitato diff --git a/src/main/resources/i18n/SysTray_nl.properties b/src/main/resources/i18n/SysTray_nl.properties index 996c946f..8a4f112b 100644 --- a/src/main/resources/i18n/SysTray_nl.properties +++ b/src/main/resources/i18n/SysTray_nl.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = Database Onderhoud EXIT = Verlaten +LITE_NODE = Lite node + MINTING_DISABLED = Minten is uitgeschakeld MINTING_ENABLED = \u2714 Minten is ingeschakeld diff --git a/src/main/resources/i18n/SysTray_ru.properties b/src/main/resources/i18n/SysTray_ru.properties index efcc723c..fc3d8648 100644 --- a/src/main/resources/i18n/SysTray_ru.properties +++ b/src/main/resources/i18n/SysTray_ru.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = Обслуживание базы данных EXIT = Выход +LITE_NODE = Lite node + MINTING_DISABLED = Чеканка отключена MINTING_ENABLED = \u2714 Чеканка активна diff --git a/src/main/resources/i18n/SysTray_zh_CN.properties b/src/main/resources/i18n/SysTray_zh_CN.properties index 1216368b..c103d24b 100644 --- a/src/main/resources/i18n/SysTray_zh_CN.properties +++ b/src/main/resources/i18n/SysTray_zh_CN.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = 数据库维护 EXIT = 退出核心 +LITE_NODE = Lite node + MINTING_DISABLED = 没有铸币 MINTING_ENABLED = \u2714 铸币 diff --git a/src/main/resources/i18n/SysTray_zh_TW.properties b/src/main/resources/i18n/SysTray_zh_TW.properties index 1b2d0d90..5e6ccc3e 100644 --- a/src/main/resources/i18n/SysTray_zh_TW.properties +++ b/src/main/resources/i18n/SysTray_zh_TW.properties @@ -27,6 +27,8 @@ DB_MAINTENANCE = 數據庫維護 EXIT = 退出核心 +LITE_NODE = Lite node + MINTING_DISABLED = 沒有鑄幣 MINTING_ENABLED = \u2714 鑄幣 diff --git a/src/test/java/org/qortal/test/SerializationTests.java b/src/test/java/org/qortal/test/SerializationTests.java index 5a9fffa8..d9fe978c 100644 --- a/src/test/java/org/qortal/test/SerializationTests.java +++ b/src/test/java/org/qortal/test/SerializationTests.java @@ -47,7 +47,6 @@ public class SerializationTests extends Common { switch (txType) { case GENESIS: case ACCOUNT_FLAGS: - case AT: case CHAT: case PUBLICIZE: case AIRDROP: diff --git a/src/test/java/org/qortal/test/api/TransactionsApiTests.java b/src/test/java/org/qortal/test/api/TransactionsApiTests.java index 2cdd746b..102cac34 100644 --- a/src/test/java/org/qortal/test/api/TransactionsApiTests.java +++ b/src/test/java/org/qortal/test/api/TransactionsApiTests.java @@ -36,8 +36,8 @@ public class TransactionsApiTests extends ApiCommon { @Test public void testGetUnconfirmedTransactions() { - assertNotNull(this.transactionsResource.getUnconfirmedTransactions(null, null, null)); - assertNotNull(this.transactionsResource.getUnconfirmedTransactions(1, 1, true)); + assertNotNull(this.transactionsResource.getUnconfirmedTransactions(null, null, null, null, null)); + assertNotNull(this.transactionsResource.getUnconfirmedTransactions(null, null, 1, 1, true)); } @Test diff --git a/src/test/java/org/qortal/test/apps/CheckTranslations.java b/src/test/java/org/qortal/test/apps/CheckTranslations.java index 2b59ce84..b8008c78 100644 --- a/src/test/java/org/qortal/test/apps/CheckTranslations.java +++ b/src/test/java/org/qortal/test/apps/CheckTranslations.java @@ -15,7 +15,7 @@ public class CheckTranslations { private static final String[] SUPPORTED_LANGS = new String[] { "en", "de", "zh", "ru" }; private static final Set SYSTRAY_KEYS = Set.of("AUTO_UPDATE", "APPLYING_UPDATE_AND_RESTARTING", "BLOCK_HEIGHT", "BUILD_VERSION", "CHECK_TIME_ACCURACY", "CONNECTING", "CONNECTION", "CONNECTIONS", "CREATING_BACKUP_OF_DB_FILES", - "DB_BACKUP", "DB_CHECKPOINT", "EXIT", "MINTING_DISABLED", "MINTING_ENABLED", "OPEN_UI", "PERFORMING_DB_CHECKPOINT", + "DB_BACKUP", "DB_CHECKPOINT", "EXIT", "LITE_NODE", "MINTING_DISABLED", "MINTING_ENABLED", "OPEN_UI", "PERFORMING_DB_CHECKPOINT", "SYNCHRONIZE_CLOCK", "SYNCHRONIZING_BLOCKCHAIN", "SYNCHRONIZING_CLOCK"); private static String failurePrefix; diff --git a/src/test/java/org/qortal/test/at/AtSerializationTests.java b/src/test/java/org/qortal/test/at/AtSerializationTests.java new file mode 100644 index 00000000..3953bcdf --- /dev/null +++ b/src/test/java/org/qortal/test/at/AtSerializationTests.java @@ -0,0 +1,96 @@ +package org.qortal.test.at; + +import com.google.common.hash.HashCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.transaction.ATTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.Common; +import org.qortal.test.common.transaction.AtTestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Base58; + +import static org.junit.Assert.assertEquals; + +public class AtSerializationTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @After + public void afterTest() throws DataException { + Common.orphanCheck(); + } + + + @Test + public void testPaymentTypeAtSerialization() throws DataException, TransformationException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Build PAYMENT-type AT transaction + PrivateKeyAccount signingAccount = Common.getTestAccount(repository, "alice"); + ATTransactionData transactionData = (ATTransactionData) AtTestTransaction.paymentType(repository, signingAccount, true); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + final int claimedLength = TransactionTransformer.getDataLength(transactionData); + byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); + assertEquals("Serialized PAYMENT-type AT transaction length differs from declared length", claimedLength, serializedTransaction.length); + + TransactionData deserializedTransactionData = TransactionTransformer.fromBytes(serializedTransaction); + // Re-sign + Transaction deserializedTransaction = Transaction.fromData(repository, deserializedTransactionData); + deserializedTransaction.sign(signingAccount); + assertEquals("Deserialized PAYMENT-type AT transaction signature differs", Base58.encode(transactionData.getSignature()), Base58.encode(deserializedTransactionData.getSignature())); + + // Re-serialize to check new length and bytes + final int reclaimedLength = TransactionTransformer.getDataLength(deserializedTransactionData); + assertEquals("Reserialized PAYMENT-type AT transaction declared length differs", claimedLength, reclaimedLength); + + byte[] reserializedTransaction = TransactionTransformer.toBytes(deserializedTransactionData); + assertEquals("Reserialized PAYMENT-type AT transaction bytes differ", HashCode.fromBytes(serializedTransaction).toString(), HashCode.fromBytes(reserializedTransaction).toString()); + } + } + + @Test + public void testMessageTypeAtSerialization() throws DataException, TransformationException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Build MESSAGE-type AT transaction + PrivateKeyAccount signingAccount = Common.getTestAccount(repository, "alice"); + ATTransactionData transactionData = (ATTransactionData) AtTestTransaction.messageType(repository, signingAccount, true); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + // MESSAGE-type AT transactions are only fully supported since transaction V6 + assertEquals(6, Transaction.getVersionByTimestamp(transactionData.getTimestamp())); + + final int claimedLength = TransactionTransformer.getDataLength(transactionData); + byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); + assertEquals("Serialized MESSAGE-type AT transaction length differs from declared length", claimedLength, serializedTransaction.length); + + TransactionData deserializedTransactionData = TransactionTransformer.fromBytes(serializedTransaction); + // Re-sign + Transaction deserializedTransaction = Transaction.fromData(repository, deserializedTransactionData); + deserializedTransaction.sign(signingAccount); + assertEquals("Deserialized MESSAGE-type AT transaction signature differs", Base58.encode(transactionData.getSignature()), Base58.encode(deserializedTransactionData.getSignature())); + + // Re-serialize to check new length and bytes + final int reclaimedLength = TransactionTransformer.getDataLength(deserializedTransactionData); + assertEquals("Reserialized MESSAGE-type AT transaction declared length differs", claimedLength, reclaimedLength); + + byte[] reserializedTransaction = TransactionTransformer.toBytes(deserializedTransactionData); + assertEquals("Reserialized MESSAGE-type AT transaction bytes differ", HashCode.fromBytes(serializedTransaction).toString(), HashCode.fromBytes(reserializedTransaction).toString()); + } + } + +} diff --git a/src/test/java/org/qortal/test/common/transaction/AtTestTransaction.java b/src/test/java/org/qortal/test/common/transaction/AtTestTransaction.java index 74216f2f..f827c396 100644 --- a/src/test/java/org/qortal/test/common/transaction/AtTestTransaction.java +++ b/src/test/java/org/qortal/test/common/transaction/AtTestTransaction.java @@ -12,16 +12,33 @@ import org.qortal.utils.Amounts; public class AtTestTransaction extends TestTransaction { public static TransactionData randomTransaction(Repository repository, PrivateKeyAccount account, boolean wantValid) throws DataException { + return AtTestTransaction.paymentType(repository, account, wantValid); + } + + public static TransactionData paymentType(Repository repository, PrivateKeyAccount account, boolean wantValid) throws DataException { byte[] signature = new byte[64]; random.nextBytes(signature); String atAddress = Crypto.toATAddress(signature); String recipient = account.getAddress(); + + // Use PAYMENT-type long amount = 123L * Amounts.MULTIPLIER; final long assetId = Asset.QORT; + + return new ATTransactionData(generateBase(account), atAddress, recipient, amount, assetId); + } + + public static TransactionData messageType(Repository repository, PrivateKeyAccount account, boolean wantValid) throws DataException { + byte[] signature = new byte[64]; + random.nextBytes(signature); + String atAddress = Crypto.toATAddress(signature); + String recipient = account.getAddress(); + + // Use MESSAGE-type byte[] message = new byte[32]; random.nextBytes(message); - return new ATTransactionData(generateBase(account), atAddress, recipient, amount, assetId, message); + return new ATTransactionData(generateBase(account), atAddress, recipient, message); } } diff --git a/src/test/java/org/qortal/test/crosschain/DigibyteTests.java b/src/test/java/org/qortal/test/crosschain/DigibyteTests.java index 38dde242..1810d7ed 100644 --- a/src/test/java/org/qortal/test/crosschain/DigibyteTests.java +++ b/src/test/java/org/qortal/test/crosschain/DigibyteTests.java @@ -82,7 +82,7 @@ public class DigibyteTests extends Common { @Test public void testGetWalletBalance() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String xprv58 = "xpub661MyMwAqRbcEnabTLX5uebYcsE3uG5y7ve9jn1VK8iY1MaU3YLoLJEe8sTu2YVav5Zka5qf2dmMssfxmXJTqZnazZL2kL7M2tNKwEoC34R"; Long balance = digibyte.getWalletBalance(xprv58); @@ -103,7 +103,7 @@ public class DigibyteTests extends Common { @Test public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String xprv58 = "xpub661MyMwAqRbcEnabTLX5uebYcsE3uG5y7ve9jn1VK8iY1MaU3YLoLJEe8sTu2YVav5Zka5qf2dmMssfxmXJTqZnazZL2kL7M2tNKwEoC34R"; String address = digibyte.getUnusedReceiveAddress(xprv58); diff --git a/src/test/java/org/qortal/test/crosschain/RavencoinTests.java b/src/test/java/org/qortal/test/crosschain/RavencoinTests.java index 16d811dc..afcbfe95 100644 --- a/src/test/java/org/qortal/test/crosschain/RavencoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/RavencoinTests.java @@ -82,7 +82,7 @@ public class RavencoinTests extends Common { @Test public void testGetWalletBalance() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String xprv58 = "xpub661MyMwAqRbcEt3Ge1wNmkagyb1J7yTQu4Kquvy77Ycg2iPoh7Urg8s9Jdwp7YmrqGkDKJpUVjsZXSSsQgmAVUC17ZVQQeoWMzm7vDTt1y7"; Long balance = ravencoin.getWalletBalance(xprv58); @@ -103,7 +103,7 @@ public class RavencoinTests extends Common { @Test public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String xprv58 = "xpub661MyMwAqRbcEt3Ge1wNmkagyb1J7yTQu4Kquvy77Ycg2iPoh7Urg8s9Jdwp7YmrqGkDKJpUVjsZXSSsQgmAVUC17ZVQQeoWMzm7vDTt1y7"; String address = ravencoin.getUnusedReceiveAddress(xprv58); diff --git a/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java new file mode 100644 index 00000000..01345727 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java @@ -0,0 +1,769 @@ +package org.qortal.test.crosschain.bitcoinv3; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.block.Block; +import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.BitcoinACCTv3; +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.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.utils.Amounts; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.function.Function; + +import static org.junit.Assert.*; + +public class BitcoinACCTv3Tests extends Common { + + public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); + public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a + public static final byte[] bitcoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); + public static final int tradeTimeout = 20; // blocks + public static final long redeemAmount = 80_40200000L; + public static final long fundingAmount = 123_45600000L; + public static final long bitcoinAmount = 864200L; // 0.00864200 BTC + + private static final Random RANDOM = new Random(); + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testCompile() { + PrivateKeyAccount tradeAccount = createTradeAccount(null); + + byte[] creationBytes = BitcoinACCTv3.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, redeemAmount, bitcoinAmount, tradeTimeout); + assertNotNull(creationBytes); + + System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + } + + @Test + public void testDeploy() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = fundingAmount; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + expectedBalance = deployersInitialBalance; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = 0; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancel() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Send creator's address to AT, instead of typical partner's address + byte[] messageData = BitcoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + + // Check balances + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance - messageFee; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancelInvalidLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Instead of sending creator's address to AT, send too-short/invalid message + byte[] messageData = new byte[7]; + RANDOM.nextBytes(messageData); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testTradingInfoProcessing() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + + // AT should be in TRADE mode + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check hashOfSecretA was extracted correctly + assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); + + // Check trade partner Qortal address was extracted correctly + assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); + + // Check trade partner's Bitcoin PKH was extracted correctly + assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerForeignPKH)); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) + @SuppressWarnings("unused") + @Test + public void testIncorrectTradeSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); + + BlockUtils.mintBlock(repository); + + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + + // AT should still be in OFFER mode + assertEquals(AcctMode.OFFERING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testAutomaticTradeRefund() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + // Check refund + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in REFUNDED mode + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REFUNDED, tradeData.mode); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, from correct account + messageData = BitcoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in REDEEMED mode + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REDEEMED, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); + + // Orphan redeem + BlockUtils.orphanLastBlock(repository); + + // Check balances + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); + + // Check AT state + ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); + + assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretIncorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, but from wrong account + messageData = BitcoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); + messageTransaction = sendMessage(repository, bystander, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testIncorrectSecretCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send incorrect secret to AT, from correct account + byte[] wrongSecret = new byte[32]; + RANDOM.nextBytes(wrongSecret); + messageData = BitcoinACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length + messageData = Bytes.concat(secretA); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should be in TRADING mode + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testDescribeDeployed() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + List executableAts = repository.getATRepository().getAllExecutableATs(); + + for (ATData atData : executableAts) { + String atAddress = atData.getATAddress(); + byte[] codeBytes = atData.getCodeBytes(); + byte[] codeHash = Crypto.digest(codeBytes); + + System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", + atAddress, + codeBytes.length, + (codeBytes.length != 1 ? "s": ""), + HashCode.fromBytes(codeHash))); + + // Not one of ours? + if (!Arrays.equals(codeHash, BitcoinACCTv3.CODE_BYTES_HASH)) + continue; + + describeAt(repository, atAddress); + } + } + } + + private int calcTestLockTimeA(long messageTimestamp) { + return (int) (messageTimestamp / 1000L + tradeTimeout * 60); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = BitcoinACCTv3.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, redeemAmount, bitcoinAmount, tradeTimeout); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-BTC cross-chain trade"; + String description = String.format("Qortal-Bitcoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-BTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } + + private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + int refundTimeout = tradeTimeout / 2 + 1; // close enough + + // AT should automatically refund deployer after 'refundTimeout' blocks + for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) + BlockUtils.mintBlock(repository); + + // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + } + + private void describeAt(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); + + Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); + int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); + + System.out.print(String.format("%s:\n" + + "\tmode: %s\n" + + "\tcreator: %s,\n" + + "\tcreation timestamp: %s,\n" + + "\tcurrent balance: %s QORT,\n" + + "\tis finished: %b,\n" + + "\tredeem payout: %s QORT,\n" + + "\texpected Bitcoin: %s BTC,\n" + + "\tcurrent block height: %d,\n", + tradeData.qortalAtAddress, + tradeData.mode, + tradeData.qortalCreator, + epochMilliFormatter.apply(tradeData.creationTimestamp), + Amounts.prettyAmount(tradeData.qortBalance), + atData.getIsFinished(), + Amounts.prettyAmount(tradeData.qortAmount), + Amounts.prettyAmount(tradeData.expectedForeignAmount), + currentBlockHeight)); + + if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { + System.out.println(String.format("\trefund timeout: %d minutes,\n" + + "\trefund height: block %d,\n" + + "\tHASH160 of secret-A: %s,\n" + + "\tBitcoin P2SH-A nLockTime: %d (%s),\n" + + "\ttrade partner: %s\n" + + "\tpartner's receiving address: %s", + tradeData.refundTimeout, + tradeData.tradeRefundHeight, + HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), + tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), + tradeData.qortalPartnerAddress, + tradeData.qortalPartnerReceivingAddress)); + } + } + + private PrivateKeyAccount createTradeAccount(Repository repository) { + // We actually use a known test account with funds to avoid PoW compute + return Common.getTestAccount(repository, "alice"); + } + +} diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index b2e4018f..2f8c2832 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -53,7 +53,8 @@ "newBlockSigHeight": 999999, "shareBinFix": 999999, "calcChainWeightTimestamp": 0, - "transactionV5Timestamp": 0 + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index 4fac00b5..91c16b22 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -53,7 +53,8 @@ "newBlockSigHeight": 999999, "shareBinFix": 999999, "calcChainWeightTimestamp": 0, - "transactionV5Timestamp": 0 + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index 1b85a948..565763f9 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -53,7 +53,8 @@ "newBlockSigHeight": 999999, "shareBinFix": 999999, "calcChainWeightTimestamp": 0, - "transactionV5Timestamp": 0 + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index daee01a0..3f694399 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -53,7 +53,8 @@ "newBlockSigHeight": 999999, "shareBinFix": 999999, "calcChainWeightTimestamp": 0, - "transactionV5Timestamp": 0 + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 9146d715..f92171c8 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -53,7 +53,8 @@ "newBlockSigHeight": 999999, "shareBinFix": 999999, "calcChainWeightTimestamp": 0, - "transactionV5Timestamp": 0 + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index dced66c3..958e689b 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -53,7 +53,8 @@ "newBlockSigHeight": 999999, "shareBinFix": 6, "calcChainWeightTimestamp": 0, - "transactionV5Timestamp": 0 + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 1e0f7c6c..ab031a17 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -53,7 +53,8 @@ "newBlockSigHeight": 999999, "shareBinFix": 999999, "calcChainWeightTimestamp": 0, - "transactionV5Timestamp": 0 + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index bf534ce4..d466936e 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -53,7 +53,8 @@ "newBlockSigHeight": 999999, "shareBinFix": 999999, "calcChainWeightTimestamp": 0, - "transactionV5Timestamp": 0 + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0 }, "genesisInfo": { "version": 4,