diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 16b86fe2..722d881e 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 4054ce71..7e293708 100644 --- a/pom.xml +++ b/pom.xml @@ -3,11 +3,11 @@ 4.0.0 org.qortal qortal - 3.2.3 + 3.2.5 jar true - bf9fb80 + 6628cfd 0.15.10 1.64 ${maven.build.timestamp} @@ -444,7 +444,7 @@ - com.github.jjos2372 + com.github.qortal altcoinj ${altcoinj.version} diff --git a/src/main/java/org/qortal/api/model/crosschain/DigibyteSendRequest.java b/src/main/java/org/qortal/api/model/crosschain/DigibyteSendRequest.java new file mode 100644 index 00000000..a09c14a3 --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/DigibyteSendRequest.java @@ -0,0 +1,29 @@ +package org.qortal.api.model.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class DigibyteSendRequest { + + @Schema(description = "Digibyte BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________") + public String xprv58; + + @Schema(description = "Recipient's Digibyte address ('legacy' P2PKH only)", example = "1DigByteEaterAddressDontSendf59kuE") + public String receivingAddress; + + @Schema(description = "Amount of DGB to send", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long digibyteAmount; + + @Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 DGB (100 sats) per byte", example = "0.00000100", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long feePerByte; + + public DigibyteSendRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/crosschain/RavencoinSendRequest.java b/src/main/java/org/qortal/api/model/crosschain/RavencoinSendRequest.java new file mode 100644 index 00000000..0165b91d --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/RavencoinSendRequest.java @@ -0,0 +1,29 @@ +package org.qortal.api.model.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class RavencoinSendRequest { + + @Schema(description = "Ravencoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________") + public String xprv58; + + @Schema(description = "Recipient's Ravencoin address ('legacy' P2PKH only)", example = "1RvnCoinEaterAddressDontSendf59kuE") + public String receivingAddress; + + @Schema(description = "Amount of RVN to send", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long ravencoinAmount; + + @Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 RVN (100 sats) per byte", example = "0.00000100", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long feePerByte; + + public RavencoinSendRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 277b5f00..efb47acf 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -381,6 +381,10 @@ public class AdminResource { ) @QueryParam("limit") Integer limit, @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, @Parameter( + name = "tail", + description = "Fetch most recent log lines", + schema = @Schema(type = "boolean") + ) @QueryParam("tail") Boolean tail, @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { LoggerContext loggerContext = (LoggerContext) LogManager.getContext(); @@ -396,6 +400,13 @@ public class AdminResource { if (reverse != null && reverse) logLines = Lists.reverse(logLines); + // Tail mode - return the last X lines (where X = limit) + if (tail != null && tail) { + if (limit != null && limit > 0) { + offset = logLines.size() - limit; + } + } + // offset out of bounds? if (offset != null && (offset < 0 || offset >= logLines.size())) return ""; @@ -416,7 +427,7 @@ public class AdminResource { limit = Math.min(limit, logLines.size()); - logLines.subList(limit - 1, logLines.size()).clear(); + logLines.subList(limit, logLines.size()).clear(); return String.join("\n", logLines); } catch (IOException e) { diff --git a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java new file mode 100644 index 00000000..57049639 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java @@ -0,0 +1,177 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.bitcoinj.core.Transaction; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.crosschain.DigibyteSendRequest; +import org.qortal.crosschain.Digibyte; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.SimpleTransaction; + +@Path("/crosschain/dgb") +@Tag(name = "Cross-Chain (Digibyte)") +public class CrossChainDigibyteResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/walletbalance") + @Operation( + summary = "Returns DGB balance for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String getDigibyteWalletBalance(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Digibyte digibyte = Digibyte.getInstance(); + + if (!digibyte.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + Long balance = digibyte.getWalletBalanceFromTransactions(key58); + if (balance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return balance.toString(); + + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + + @POST + @Path("/wallettransactions") + @Operation( + summary = "Returns transactions for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public List getDigibyteWalletTransactions(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Digibyte digibyte = Digibyte.getInstance(); + + if (!digibyte.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return digibyte.getWalletTransactions(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + + @POST + @Path("/send") + @Operation( + summary = "Sends DGB from hierarchical, deterministic BIP32 wallet to specific address", + description = "Currently supports 'legacy' P2PKH Digibyte addresses and Native SegWit (P2WPKH) addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = DigibyteSendRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String sendBitcoin(@HeaderParam(Security.API_KEY_HEADER) String apiKey, DigibyteSendRequest digibyteSendRequest) { + Security.checkApiCallAllowed(request); + + if (digibyteSendRequest.digibyteAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (digibyteSendRequest.feePerByte != null && digibyteSendRequest.feePerByte <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Digibyte digibyte = Digibyte.getInstance(); + + if (!digibyte.isValidAddress(digibyteSendRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (!digibyte.isValidDeterministicKey(digibyteSendRequest.xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Transaction spendTransaction = digibyte.buildSpend(digibyteSendRequest.xprv58, + digibyteSendRequest.receivingAddress, + digibyteSendRequest.digibyteAmount, + digibyteSendRequest.feePerByte); + + if (spendTransaction == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + try { + digibyte.broadcastTransaction(spendTransaction); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + + return spendTransaction.getTxId().toString(); + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java new file mode 100644 index 00000000..756b0bb5 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java @@ -0,0 +1,177 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.bitcoinj.core.Transaction; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.crosschain.RavencoinSendRequest; +import org.qortal.crosschain.Ravencoin; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.SimpleTransaction; + +@Path("/crosschain/rvn") +@Tag(name = "Cross-Chain (Ravencoin)") +public class CrossChainRavencoinResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/walletbalance") + @Operation( + summary = "Returns RVN balance for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String getRavencoinWalletBalance(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Ravencoin ravencoin = Ravencoin.getInstance(); + + if (!ravencoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + Long balance = ravencoin.getWalletBalanceFromTransactions(key58); + if (balance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return balance.toString(); + + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + + @POST + @Path("/wallettransactions") + @Operation( + summary = "Returns transactions for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public List getRavencoinWalletTransactions(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Ravencoin ravencoin = Ravencoin.getInstance(); + + if (!ravencoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return ravencoin.getWalletTransactions(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + + @POST + @Path("/send") + @Operation( + summary = "Sends RVN from hierarchical, deterministic BIP32 wallet to specific address", + description = "Currently only supports 'legacy' P2PKH Ravencoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = RavencoinSendRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String sendBitcoin(@HeaderParam(Security.API_KEY_HEADER) String apiKey, RavencoinSendRequest ravencoinSendRequest) { + Security.checkApiCallAllowed(request); + + if (ravencoinSendRequest.ravencoinAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (ravencoinSendRequest.feePerByte != null && ravencoinSendRequest.feePerByte <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Ravencoin ravencoin = Ravencoin.getInstance(); + + if (!ravencoin.isValidAddress(ravencoinSendRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (!ravencoin.isValidDeterministicKey(ravencoinSendRequest.xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Transaction spendTransaction = ravencoin.buildSpend(ravencoinSendRequest.xprv58, + ravencoinSendRequest.receivingAddress, + ravencoinSendRequest.ravencoinAmount, + ravencoinSendRequest.feePerByte); + + if (spendTransaction == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + try { + ravencoin.broadcastTransaction(spendTransaction); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + + return spendTransaction.getTxId().toString(); + } + +} diff --git a/src/main/java/org/qortal/api/resource/PeersResource.java b/src/main/java/org/qortal/api/resource/PeersResource.java index 1c7947c6..d89f99c4 100644 --- a/src/main/java/org/qortal/api/resource/PeersResource.java +++ b/src/main/java/org/qortal/api/resource/PeersResource.java @@ -20,6 +20,11 @@ import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.apache.logging.log4j.core.LoggerContext; import org.qortal.api.*; import org.qortal.api.model.ConnectedPeer; import org.qortal.api.model.PeersSummary; @@ -127,9 +132,29 @@ public class PeersResource { } ) @SecurityRequirement(name = "apiKey") - public ExecuteProduceConsume.StatsSnapshot getEngineStats(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { + public ExecuteProduceConsume.StatsSnapshot getEngineStats(@HeaderParam(Security.API_KEY_HEADER) String apiKey, @QueryParam("newLoggingLevel") Level newLoggingLevel) { Security.checkApiCallAllowed(request); + if (newLoggingLevel != null) { + final LoggerContext ctx = (LoggerContext) LogManager.getContext(false); + final Configuration config = ctx.getConfiguration(); + + String epcClassName = "org.qortal.network.Network.NetworkProcessor"; + LoggerConfig loggerConfig = config.getLoggerConfig(epcClassName); + LoggerConfig specificConfig = loggerConfig; + + // We need a specific configuration for this logger, + // otherwise we would change the level of all other loggers + // having the original configuration as parent as well + if (!loggerConfig.getName().equals(epcClassName)) { + specificConfig = new LoggerConfig(epcClassName, newLoggingLevel, true); + specificConfig.setParent(loggerConfig); + config.addLogger(epcClassName, specificConfig); + } + specificConfig.setLevel(newLoggingLevel); + ctx.updateLoggers(); + } + return Network.getInstance().getStatsSnapshot(); } diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java index b974298b..9be4f145 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java @@ -93,10 +93,12 @@ public class ArbitraryDataFile { File outputFile = outputFilePath.toFile(); try (FileOutputStream outputStream = new FileOutputStream(outputFile)) { outputStream.write(fileContent); + outputStream.close(); this.filePath = outputFilePath; // Verify hash - if (!this.hash58.equals(this.digest58())) { - LOGGER.error("Hash {} does not match file digest {}", this.hash58, this.digest58()); + 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"); } diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index ab94a80d..847f2aa8 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -120,7 +120,7 @@ public class ArbitraryDataRenderer { byte[] data = Files.readAllBytes(Paths.get(filePath)); // TODO: limit file size that can be read into memory HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data); htmlParser.addAdditionalHeaderTags(); - response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob:"); + response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob:; img-src 'self' data: blob:;"); response.setContentType(context.getMimeType(filename)); response.setContentLength(htmlParser.getData().length); response.getOutputStream().write(htmlParser.getData()); diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index f6b6500d..3772e5b1 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -442,9 +442,8 @@ public class BlockChain { } public long getNameRegistrationUnitFeeAtTimestamp(long ourTimestamp) { - // Scan through for reward at our height - for (int i = 0; i < nameRegistrationUnitFees.size(); ++i) - if (ourTimestamp >= nameRegistrationUnitFees.get(i).timestamp) + for (int i = nameRegistrationUnitFees.size() - 1; i >= 0; --i) + if (nameRegistrationUnitFees.get(i).timestamp <= ourTimestamp) return nameRegistrationUnitFees.get(i).fee; // Default to system-wide unit fee diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index de73adbe..04797314 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -219,7 +219,7 @@ public class BlockMinter extends Thread { // The last iteration found a higher weight block in the network, so sleep for a while // to allow is to sync the higher weight chain. We are sleeping here rather than when // detected as we don't want to hold the blockchain lock open. - LOGGER.info("Sleeping for 10 seconds..."); + LOGGER.debug("Sleeping for 10 seconds..."); Thread.sleep(10 * 1000L); } @@ -328,13 +328,13 @@ public class BlockMinter extends Thread { // If less than 30 seconds has passed since first detection the higher weight chain, // we should skip our block submission to give us the opportunity to sync to the better chain if (NTP.getTime() - timeOfLastLowWeightBlock < 30*1000L) { - LOGGER.info("Higher weight chain found in peers, so not signing a block this round"); - LOGGER.info("Time since detected: {}", NTP.getTime() - timeOfLastLowWeightBlock); + LOGGER.debug("Higher weight chain found in peers, so not signing a block this round"); + LOGGER.debug("Time since detected: {}ms", NTP.getTime() - timeOfLastLowWeightBlock); continue; } else { // More than 30 seconds have passed, so we should submit our block candidate anyway. - LOGGER.info("More than 30 seconds passed, so proceeding to submit block candidate..."); + LOGGER.debug("More than 30 seconds passed, so proceeding to submit block candidate..."); } } else { diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 0c6af489..f239b234 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -58,6 +58,7 @@ import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; import org.qortal.settings.Settings; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.TransactionType; +import org.qortal.transform.TransformationException; import org.qortal.utils.*; public class Controller extends Thread { @@ -573,15 +574,20 @@ public class Controller extends Thread { MessageType.INFO); LOGGER.info("Starting scheduled repository maintenance. This can take a while..."); - try (final Repository repository = RepositoryManager.getRepository()) { + int attempts = 0; + while (attempts <= 5) { + try (final Repository repository = RepositoryManager.getRepository()) { + attempts++; - // Timeout if the database isn't ready for maintenance after 60 seconds - long timeout = 60 * 1000L; - repository.performPeriodicMaintenance(timeout); + // Timeout if the database isn't ready for maintenance after 60 seconds + long timeout = 60 * 1000L; + repository.performPeriodicMaintenance(timeout); - LOGGER.info("Scheduled repository maintenance completed"); - } catch (DataException | TimeoutException e) { - LOGGER.error("Scheduled repository maintenance failed", e); + LOGGER.info("Scheduled repository maintenance completed"); + break; + } catch (DataException | TimeoutException e) { + LOGGER.info("Scheduled repository maintenance failed. Retrying up to 5 times...", e); + } } // Get a new random interval @@ -655,29 +661,6 @@ public class Controller extends Thread { return lastMisbehaved != null && lastMisbehaved > NTP.getTime() - MISBEHAVIOUR_COOLOFF; }; - /** True if peer has unknown height, lower height or same height and same block signature (unless we don't have their block signature). */ - public static Predicate hasShorterBlockchain = peer -> { - BlockData highestBlockData = getInstance().getChainTip(); - int ourHeight = highestBlockData.getHeight(); - final PeerChainTipData peerChainTipData = peer.getChainTipData(); - - // Ensure we have chain tip data for this peer - if (peerChainTipData == null) - return true; - - // Remove if peer is at a lower height than us - Integer peerHeight = peerChainTipData.getLastHeight(); - if (peerHeight == null || peerHeight < ourHeight) - return true; - - // Don't remove if peer is on a greater height chain than us, or if we don't have their block signature - if (peerHeight > ourHeight || peerChainTipData.getLastBlockSignature() == null) - return false; - - // Remove if signatures match - return Arrays.equals(peerChainTipData.getLastBlockSignature(), highestBlockData.getSignature()); - }; - public static final Predicate hasNoRecentBlock = peer -> { final Long minLatestBlockTimestamp = getMinimumLatestBlockTimestamp(); final PeerChainTipData peerChainTipData = peer.getChainTipData(); @@ -1232,7 +1215,7 @@ public class Controller extends Thread { this.stats.getBlockMessageStats.cacheHits.incrementAndGet(); // We need to duplicate it to prevent multiple threads setting ID on the same message - CachedBlockMessage clonedBlockMessage = cachedBlockMessage.cloneWithNewId(message.getId()); + CachedBlockMessage clonedBlockMessage = Message.cloneWithNewId(cachedBlockMessage, message.getId()); if (!peer.sendMessage(clonedBlockMessage)) peer.disconnect("failed to send block"); @@ -1291,7 +1274,6 @@ public class Controller extends Thread { CachedBlockMessage blockMessage = new CachedBlockMessage(block); blockMessage.setId(message.getId()); - // This call also causes the other needed data to be pulled in from repository if (!peer.sendMessage(blockMessage)) { peer.disconnect("failed to send block"); // Don't fall-through to caching because failure to send might be from failure to build message @@ -1305,7 +1287,9 @@ public class Controller extends Thread { this.blockMessageCache.put(ByteArray.wrap(blockData.getSignature()), blockMessage); } } catch (DataException e) { - LOGGER.error(String.format("Repository issue while send block %s to peer %s", Base58.encode(signature), peer), e); + LOGGER.error(String.format("Repository issue while sending block %s to peer %s", Base58.encode(signature), peer), e); + } catch (TransformationException e) { + LOGGER.error(String.format("Serialization issue while sending block %s to peer %s", Base58.encode(signature), peer), e); } } diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index d574ef87..55aeae04 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -33,7 +33,7 @@ import org.qortal.network.message.GetBlockSummariesMessage; import org.qortal.network.message.GetSignaturesV2Message; import org.qortal.network.message.Message; import org.qortal.network.message.SignaturesMessage; -import org.qortal.network.message.Message.MessageType; +import org.qortal.network.message.MessageType; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -235,9 +235,6 @@ public class Synchronizer extends Thread { // Disregard peers that are on the same block as last sync attempt and we didn't like their chain peers.removeIf(Controller.hasInferiorChainTip); - // Remove peers with unknown height, lower height or same height and same block signature (unless we don't have their block signature) - peers.removeIf(Controller.hasShorterBlockchain); - final int peersBeforeComparison = peers.size(); // Request recent block summaries from the remaining peers, and locate our common block with each diff --git a/src/main/java/org/qortal/controller/TransactionImporter.java b/src/main/java/org/qortal/controller/TransactionImporter.java index 3514ea47..16fd3a59 100644 --- a/src/main/java/org/qortal/controller/TransactionImporter.java +++ b/src/main/java/org/qortal/controller/TransactionImporter.java @@ -12,6 +12,7 @@ import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; import org.qortal.utils.Base58; import org.qortal.utils.NTP; @@ -289,7 +290,9 @@ public class TransactionImporter extends Thread { if (!peer.sendMessage(transactionMessage)) peer.disconnect("failed to send transaction"); } catch (DataException e) { - LOGGER.error(String.format("Repository issue while send transaction %s to peer %s", Base58.encode(signature), peer), e); + LOGGER.error(String.format("Repository issue while sending transaction %s to peer %s", Base58.encode(signature), peer), e); + } catch (TransformationException e) { + LOGGER.error(String.format("Serialization issue while sending transaction %s to peer %s", Base58.encode(signature), peer), e); } } diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java index e855171d..05a45425 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java @@ -511,18 +511,23 @@ public class ArbitraryDataFileListManager { // Bump requestHops if it exists if (requestHops != null) { - arbitraryDataFileListMessage.setRequestHops(++requestHops); + requestHops++; } + ArbitraryDataFileListMessage forwardArbitraryDataFileListMessage; + // Remove optional parameters if the requesting peer doesn't support it yet // A message with less statistical data is better than no message at all if (!requestingPeer.isAtLeastVersion(MIN_PEER_VERSION_FOR_FILE_LIST_STATS)) { - arbitraryDataFileListMessage.removeOptionalStats(); + forwardArbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, hashes); + } else { + forwardArbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, hashes, requestTime, requestHops, + arbitraryDataFileListMessage.getPeerAddress(), arbitraryDataFileListMessage.isRelayPossible()); } // Forward to requesting peer LOGGER.debug("Forwarding file list with {} hashes to requesting peer: {}", hashes.size(), requestingPeer); - if (!requestingPeer.sendMessage(arbitraryDataFileListMessage)) { + if (!requestingPeer.sendMessage(forwardArbitraryDataFileListMessage)) { requestingPeer.disconnect("failed to forward arbitrary data file list"); } } @@ -639,16 +644,19 @@ public class ArbitraryDataFileListManager { } String ourAddress = Network.getInstance().getOurExternalIpAddressAndPort(); - ArbitraryDataFileListMessage arbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, - hashes, NTP.getTime(), 0, ourAddress, true); - arbitraryDataFileListMessage.setId(message.getId()); + ArbitraryDataFileListMessage arbitraryDataFileListMessage; // Remove optional parameters if the requesting peer doesn't support it yet // A message with less statistical data is better than no message at all if (!peer.isAtLeastVersion(MIN_PEER_VERSION_FOR_FILE_LIST_STATS)) { - arbitraryDataFileListMessage.removeOptionalStats(); + arbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, hashes); + } else { + arbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, + hashes, NTP.getTime(), 0, ourAddress, true); } + arbitraryDataFileListMessage.setId(message.getId()); + if (!peer.sendMessage(arbitraryDataFileListMessage)) { LOGGER.debug("Couldn't send list of hashes"); peer.disconnect("failed to send list of hashes"); @@ -670,8 +678,7 @@ public class ArbitraryDataFileListManager { // In relay mode - so ask our other peers if they have it long requestTime = getArbitraryDataFileListMessage.getRequestTime(); - int requestHops = getArbitraryDataFileListMessage.getRequestHops(); - getArbitraryDataFileListMessage.setRequestHops(++requestHops); + int requestHops = getArbitraryDataFileListMessage.getRequestHops() + 1; long totalRequestTime = now - requestTime; if (totalRequestTime < RELAY_REQUEST_MAX_DURATION) { @@ -679,11 +686,13 @@ public class ArbitraryDataFileListManager { if (requestHops < RELAY_REQUEST_MAX_HOPS) { // Relay request hasn't reached the maximum number of hops yet, so can be rebroadcast + Message relayGetArbitraryDataFileListMessage = new GetArbitraryDataFileListMessage(signature, hashes, requestTime, requestHops, requestingPeer); + LOGGER.debug("Rebroadcasting hash list request from peer {} for signature {} to our other peers... totalRequestTime: {}, requestHops: {}", peer, Base58.encode(signature), totalRequestTime, requestHops); Network.getInstance().broadcast( broadcastPeer -> broadcastPeer == peer || Objects.equals(broadcastPeer.getPeerData().getAddress().getHost(), peer.getPeerData().getAddress().getHost()) - ? null : getArbitraryDataFileListMessage); + ? null : relayGetArbitraryDataFileListMessage); } else { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java index e8b161a2..11e15414 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java @@ -7,7 +7,6 @@ import org.qortal.controller.Controller; import org.qortal.data.arbitrary.ArbitraryDirectConnectionInfo; import org.qortal.data.arbitrary.ArbitraryFileListResponseInfo; import org.qortal.data.arbitrary.ArbitraryRelayInfo; -import org.qortal.data.network.ArbitraryPeerData; import org.qortal.data.network.PeerData; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.network.Network; @@ -140,7 +139,7 @@ public class ArbitraryDataFileManager extends Thread { Long startTime = NTP.getTime(); ArbitraryDataFileMessage receivedArbitraryDataFileMessage = fetchArbitraryDataFile(peer, null, signature, hash, null); Long endTime = NTP.getTime(); - if (receivedArbitraryDataFileMessage != null) { + if (receivedArbitraryDataFileMessage != null && receivedArbitraryDataFileMessage.getArbitraryDataFile() != null) { LOGGER.debug("Received data file {} from peer {}. Time taken: {} ms", receivedArbitraryDataFileMessage.getArbitraryDataFile().getHash58(), peer, (endTime-startTime)); receivedAtLeastOneFile = true; @@ -187,7 +186,7 @@ public class ArbitraryDataFileManager extends Thread { ArbitraryDataFile existingFile = ArbitraryDataFile.fromHash(hash, signature); boolean fileAlreadyExists = existingFile.exists(); String hash58 = Base58.encode(hash); - Message message = null; + ArbitraryDataFileMessage arbitraryDataFileMessage; // Fetch the file if it doesn't exist locally if (!fileAlreadyExists) { @@ -195,10 +194,11 @@ public class ArbitraryDataFileManager extends Thread { arbitraryDataFileRequests.put(hash58, NTP.getTime()); Message getArbitraryDataFileMessage = new GetArbitraryDataFileMessage(signature, hash); + Message response = null; try { - message = peer.getResponseWithTimeout(getArbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT); + response = peer.getResponseWithTimeout(getArbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT); } catch (InterruptedException e) { - // Will return below due to null message + // Will return below due to null response } arbitraryDataFileRequests.remove(hash58); LOGGER.trace(String.format("Removed hash %.8s from arbitraryDataFileRequests", hash58)); @@ -206,22 +206,24 @@ public class ArbitraryDataFileManager extends Thread { // We may need to remove the file list request, if we have all the files for this transaction this.handleFileListRequests(signature); - if (message == null) { - LOGGER.debug("Received null message from peer {}", peer); + if (response == null) { + LOGGER.debug("Received null response from peer {}", peer); return null; } - if (message.getType() != Message.MessageType.ARBITRARY_DATA_FILE) { - LOGGER.debug("Received message with invalid type: {} from peer {}", message.getType(), peer); + if (response.getType() != MessageType.ARBITRARY_DATA_FILE) { + LOGGER.debug("Received response with invalid type: {} from peer {}", response.getType(), peer); return null; } - } - else { + + ArbitraryDataFileMessage peersArbitraryDataFileMessage = (ArbitraryDataFileMessage) response; + arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, peersArbitraryDataFileMessage.getArbitraryDataFile()); + } else { LOGGER.debug(String.format("File hash %s already exists, so skipping the request", hash58)); + arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, existingFile); } - ArbitraryDataFileMessage arbitraryDataFileMessage = (ArbitraryDataFileMessage) message; // We might want to forward the request to the peer that originally requested it - this.handleArbitraryDataFileForwarding(requestingPeer, message, originalMessage); + this.handleArbitraryDataFileForwarding(requestingPeer, arbitraryDataFileMessage, originalMessage); boolean isRelayRequest = (requestingPeer != null); if (isRelayRequest) { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java index acc97f35..0903de60 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java @@ -338,9 +338,11 @@ public class ArbitraryMetadataManager { Peer requestingPeer = request.getB(); if (requestingPeer != null) { + ArbitraryMetadataMessage forwardArbitraryMetadataMessage = new ArbitraryMetadataMessage(signature, arbitraryMetadataMessage.getArbitraryMetadataFile()); + // Forward to requesting peer LOGGER.debug("Forwarding metadata to requesting peer: {}", requestingPeer); - if (!requestingPeer.sendMessage(arbitraryMetadataMessage)) { + if (!requestingPeer.sendMessage(forwardArbitraryMetadataMessage)) { requestingPeer.disconnect("failed to forward arbitrary metadata"); } } @@ -423,8 +425,7 @@ public class ArbitraryMetadataManager { // In relay mode - so ask our other peers if they have it long requestTime = getArbitraryMetadataMessage.getRequestTime(); - int requestHops = getArbitraryMetadataMessage.getRequestHops(); - getArbitraryMetadataMessage.setRequestHops(++requestHops); + int requestHops = getArbitraryMetadataMessage.getRequestHops() + 1; long totalRequestTime = now - requestTime; if (totalRequestTime < RELAY_REQUEST_MAX_DURATION) { @@ -432,11 +433,13 @@ public class ArbitraryMetadataManager { if (requestHops < RELAY_REQUEST_MAX_HOPS) { // Relay request hasn't reached the maximum number of hops yet, so can be rebroadcast + Message relayGetArbitraryMetadataMessage = new GetArbitraryMetadataMessage(signature, requestTime, requestHops); + LOGGER.debug("Rebroadcasting metadata request from peer {} for signature {} to our other peers... totalRequestTime: {}, requestHops: {}", peer, Base58.encode(signature), totalRequestTime, requestHops); Network.getInstance().broadcast( broadcastPeer -> broadcastPeer == peer || Objects.equals(broadcastPeer.getPeerData().getAddress().getHost(), peer.getPeerData().getAddress().getHost()) - ? null : getArbitraryMetadataMessage); + ? null : relayGetArbitraryMetadataMessage); } else { diff --git a/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java new file mode 100644 index 00000000..171e818b --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.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 DigibyteACCTv3TradeBot implements AcctTradeBot { + + private static final Logger LOGGER = LogManager.getLogger(DigibyteACCTv3TradeBot.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 DigibyteACCTv3TradeBot 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 DigibyteACCTv3TradeBot() { + } + + public static synchronized DigibyteACCTv3TradeBot getInstance() { + if (instance == null) + instance = new DigibyteACCTv3TradeBot(); + + 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 DGB. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' (as in Digibyte) 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'/Digibyte public key hash - used by Alice's P2SH scripts to allow redeem
  • + *
  • QORT amount on offer by Bob
  • + *
  • DGB 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 Digibyte receiving address into public key hash (we only support P2PKH at this time) + Address digibyteReceivingAddress; + try { + digibyteReceivingAddress = Address.fromString(Digibyte.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); + } catch (AddressFormatException e) { + throw new DataException("Unsupported Digibyte receiving address: " + tradeBotCreateRequest.receivingAddress); + } + if (digibyteReceivingAddress.getOutputScriptType() != ScriptType.P2PKH) + throw new DataException("Unsupported Digibyte receiving address: " + tradeBotCreateRequest.receivingAddress); + + byte[] digibyteReceivingAccountInfo = digibyteReceivingAddress.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/DGB ACCT"; + String description = "QORT/DGB cross-chain trade"; + String aTType = "ACCT"; + String tags = "ACCT QORT DGB"; + byte[] creationBytes = DigibyteACCTv3.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, DigibyteACCTv3.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.DIGIBYTE.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + tradeBotCreateRequest.foreignAmount, null, null, null, digibyteReceivingAccountInfo); + + 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 DGB to an existing offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a Digibyte 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 Digibyte 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 Digibyte main-net) + * or 'tprv' for (Digibyte 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 Digibyte amount expected by 'Bob'. + *

+ * If the Digibyte 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 Digibyte 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, DigibyteACCTv3.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.DIGIBYTE.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 = Digibyte.getInstance().getP2shFee(now); + } catch (ForeignBlockchainException e) { + LOGGER.debug("Couldn't estimate Digibyte 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 = Digibyte.getInstance().deriveP2shAddress(redeemScriptBytes); + + // Build transaction for funding P2SH-A + Transaction p2shFundingTransaction = Digibyte.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 { + Digibyte.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 = DigibyteACCTv3.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 = DigibyteACCTv3.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 Digibyte 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; + } + + Digibyte digibyte = Digibyte.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 Digibyte pubkeyhash and lockTime-A + byte[] messageData = messageTransactionData.getData(); + DigibyteACCTv3.OfferMessageData offerMessageData = DigibyteACCTv3.extractOfferMessageData(messageData); + if (offerMessageData == null) + continue; + + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerDigibytePKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + long messageTimestamp = messageTransactionData.getTimestamp(); + int refundTimeout = DigibyteACCTv3.calcRefundTimeout(messageTimestamp, lockTimeA); + + // Determine P2SH-A address and confirm funded + byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); + String p2shAddressA = digibyte.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Digibyte.getInstance().getP2shFee(feeTimestamp); + final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(digibyte.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 = DigibyteACCTv3.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 DGB 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; + + Digibyte digibyte = Digibyte.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 = digibyte.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Digibyte.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(digibyte.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 = DigibyteACCTv3.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 = DigibyteACCTv3.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 DGB 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 DGB funds from P2SH-A + * to Bob's 'foreign'/Digibyte trade legacy-format address, as derived from trade private key. + *

+ * (This could potentially be 'improved' to send DGB 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 DGB + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + + return; + } + + byte[] secretA = DigibyteACCTv3.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 + + Digibyte digibyte = Digibyte.getInstance(); + + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + int lockTimeA = crossChainTradeData.lockTimeA; + byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = digibyte.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Digibyte.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(digibyte.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 = digibyte.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(digibyte.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); + + digibyte.broadcastTransaction(p2shRedeemTransaction); + break; + } + } + + String receivingAddress = digibyte.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; + + Digibyte digibyte = Digibyte.getInstance(); + + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = digibyte.getMedianBlockTime(); + if (medianBlockTime <= lockTimeA) + return; + + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = digibyte.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Digibyte.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(digibyte.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 = digibyte.getUnspentOutputs(p2shAddressA); + + // Determine receive address for refund + String receiveAddress = digibyte.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(digibyte.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(digibyte.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash()); + + digibyte.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/RavencoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java new file mode 100644 index 00000000..80fe7932 --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.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 RavencoinACCTv3TradeBot implements AcctTradeBot { + + private static final Logger LOGGER = LogManager.getLogger(RavencoinACCTv3TradeBot.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 RavencoinACCTv3TradeBot 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 RavencoinACCTv3TradeBot() { + } + + public static synchronized RavencoinACCTv3TradeBot getInstance() { + if (instance == null) + instance = new RavencoinACCTv3TradeBot(); + + 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 RVN. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' (as in Ravencoin) 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'/Ravencoin public key hash - used by Alice's P2SH scripts to allow redeem
  • + *
  • QORT amount on offer by Bob
  • + *
  • RVN 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 Ravencoin receiving address into public key hash (we only support P2PKH at this time) + Address ravencoinReceivingAddress; + try { + ravencoinReceivingAddress = Address.fromString(Ravencoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); + } catch (AddressFormatException e) { + throw new DataException("Unsupported Ravencoin receiving address: " + tradeBotCreateRequest.receivingAddress); + } + if (ravencoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH) + throw new DataException("Unsupported Ravencoin receiving address: " + tradeBotCreateRequest.receivingAddress); + + byte[] ravencoinReceivingAccountInfo = ravencoinReceivingAddress.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/RVN ACCT"; + String description = "QORT/RVN cross-chain trade"; + String aTType = "ACCT"; + String tags = "ACCT QORT RVN"; + byte[] creationBytes = RavencoinACCTv3.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, RavencoinACCTv3.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.RAVENCOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + tradeBotCreateRequest.foreignAmount, null, null, null, ravencoinReceivingAccountInfo); + + 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 RVN to an existing offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a Ravencoin 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 Ravencoin 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 Ravencoin main-net) + * or 'tprv' for (Ravencoin 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 Ravencoin amount expected by 'Bob'. + *

+ * If the Ravencoin 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 Ravencoin 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, RavencoinACCTv3.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.RAVENCOIN.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 = Ravencoin.getInstance().getP2shFee(now); + } catch (ForeignBlockchainException e) { + LOGGER.debug("Couldn't estimate Ravencoin 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 = Ravencoin.getInstance().deriveP2shAddress(redeemScriptBytes); + + // Build transaction for funding P2SH-A + Transaction p2shFundingTransaction = Ravencoin.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 { + Ravencoin.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 = RavencoinACCTv3.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 = RavencoinACCTv3.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 Ravencoin 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; + } + + Ravencoin ravencoin = Ravencoin.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 Ravencoin pubkeyhash and lockTime-A + byte[] messageData = messageTransactionData.getData(); + RavencoinACCTv3.OfferMessageData offerMessageData = RavencoinACCTv3.extractOfferMessageData(messageData); + if (offerMessageData == null) + continue; + + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerRavencoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + long messageTimestamp = messageTransactionData.getTimestamp(); + int refundTimeout = RavencoinACCTv3.calcRefundTimeout(messageTimestamp, lockTimeA); + + // Determine P2SH-A address and confirm funded + byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); + String p2shAddressA = ravencoin.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Ravencoin.getInstance().getP2shFee(feeTimestamp); + final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(ravencoin.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 = RavencoinACCTv3.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 RVN 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; + + Ravencoin ravencoin = Ravencoin.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 = ravencoin.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Ravencoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(ravencoin.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 = RavencoinACCTv3.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 = RavencoinACCTv3.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 RVN 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 RVN funds from P2SH-A + * to Bob's 'foreign'/Ravencoin trade legacy-format address, as derived from trade private key. + *

+ * (This could potentially be 'improved' to send RVN 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 RVN + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + + return; + } + + byte[] secretA = RavencoinACCTv3.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 + + Ravencoin ravencoin = Ravencoin.getInstance(); + + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + int lockTimeA = crossChainTradeData.lockTimeA; + byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = ravencoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Ravencoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(ravencoin.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 = ravencoin.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(ravencoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); + + ravencoin.broadcastTransaction(p2shRedeemTransaction); + break; + } + } + + String receivingAddress = ravencoin.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; + + Ravencoin ravencoin = Ravencoin.getInstance(); + + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = ravencoin.getMedianBlockTime(); + if (medianBlockTime <= lockTimeA) + return; + + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = ravencoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Ravencoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(ravencoin.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 = ravencoin.getUnspentOutputs(p2shAddressA); + + // Determine receive address for refund + String receiveAddress = ravencoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(ravencoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(ravencoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash()); + + ravencoin.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 6d7ac942..e1021f6c 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -100,6 +100,8 @@ public class TradeBot implements Listener { acctTradeBotSuppliers.put(DogecoinACCTv1.class, DogecoinACCTv1TradeBot::getInstance); acctTradeBotSuppliers.put(DogecoinACCTv2.class, DogecoinACCTv2TradeBot::getInstance); acctTradeBotSuppliers.put(DogecoinACCTv3.class, DogecoinACCTv3TradeBot::getInstance); + acctTradeBotSuppliers.put(DigibyteACCTv3.class, DigibyteACCTv3TradeBot::getInstance); + acctTradeBotSuppliers.put(RavencoinACCTv3.class, RavencoinACCTv3TradeBot::getInstance); } private static TradeBot instance; diff --git a/src/main/java/org/qortal/crosschain/Digibyte.java b/src/main/java/org/qortal/crosschain/Digibyte.java new file mode 100644 index 00000000..3ab5e78e --- /dev/null +++ b/src/main/java/org/qortal/crosschain/Digibyte.java @@ -0,0 +1,171 @@ +package org.qortal.crosschain; + +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Context; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.params.RegTestParams; +import org.bitcoinj.params.TestNet3Params; +import org.libdohj.params.DigibyteMainNetParams; +import org.qortal.crosschain.ElectrumX.Server; +import org.qortal.crosschain.ElectrumX.Server.ConnectionType; +import org.qortal.settings.Settings; + +public class Digibyte extends Bitcoiny { + + public static final String CURRENCY_CODE = "DGB"; + + private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(100000); // 0.001 DGB per 1000 bytes + + private static final long MINIMUM_ORDER_AMOUNT = 1000000; // 0.01 DGB minimum order, to avoid dust errors + + // Temporary values until a dynamic fee system is written. + private static final long MAINNET_FEE = 10000L; + private static final long NON_MAINNET_FEE = 10000L; // enough for TESTNET3 and should be OK for REGTEST + + private static final Map DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ConnectionType.class); + static { + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001); + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002); + } + + public enum DigibyteNet { + MAIN { + @Override + public NetworkParameters getParams() { + return DigibyteMainNetParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + // Servers chosen on NO BASIS WHATSOEVER from various sources! + // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=dgb + new Server("electrum1.cipig.net", ConnectionType.SSL, 20059), + new Server("electrum2.cipig.net", ConnectionType.SSL, 20059), + new Server("electrum3.cipig.net", ConnectionType.SSL, 20059)); + } + + @Override + public String getGenesisHash() { + return "7497ea1b465eb39f1c8f507bc877078fe016d6fcb6dfad3a64c98dcc6e1e8496"; + } + + @Override + public long getP2shFee(Long timestamp) { + // TODO: This will need to be replaced with something better in the near future! + return MAINNET_FEE; + } + }, + TEST3 { + @Override + public NetworkParameters getParams() { + return TestNet3Params.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList(); // TODO: find testnet servers + } + + @Override + public String getGenesisHash() { + return "308ea0711d5763be2995670dd9ca9872753561285a84da1d58be58acaa822252"; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }, + REGTEST { + @Override + public NetworkParameters getParams() { + return RegTestParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + new Server("localhost", ConnectionType.TCP, 50001), + new Server("localhost", ConnectionType.SSL, 50002)); + } + + @Override + public String getGenesisHash() { + // This is unique to each regtest instance + return null; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }; + + public abstract NetworkParameters getParams(); + public abstract Collection getServers(); + public abstract String getGenesisHash(); + public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException; + } + + private static Digibyte instance; + + private final DigibyteNet digibyteNet; + + // Constructors and instance + + private Digibyte(DigibyteNet digibyteNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) { + super(blockchain, bitcoinjContext, currencyCode); + this.digibyteNet = digibyteNet; + + LOGGER.info(() -> String.format("Starting Digibyte support using %s", this.digibyteNet.name())); + } + + public static synchronized Digibyte getInstance() { + if (instance == null) { + DigibyteNet digibyteNet = Settings.getInstance().getDigibyteNet(); + + BitcoinyBlockchainProvider electrumX = new ElectrumX("Digibyte-" + digibyteNet.name(), digibyteNet.getGenesisHash(), digibyteNet.getServers(), DEFAULT_ELECTRUMX_PORTS); + Context bitcoinjContext = new Context(digibyteNet.getParams()); + + instance = new Digibyte(digibyteNet, electrumX, bitcoinjContext, CURRENCY_CODE); + } + + return instance; + } + + // Getters & setters + + public static synchronized void resetForTesting() { + instance = null; + } + + // Actual useful methods for use by other classes + + @Override + public Coin getFeePerKb() { + return DEFAULT_FEE_PER_KB; + } + + @Override + public long getMinimumOrderAmount() { + return MINIMUM_ORDER_AMOUNT; + } + + /** + * Returns estimated DGB fee, in sats per 1000bytes, optionally for historic timestamp. + * + * @param timestamp optional milliseconds since epoch, or null for 'now' + * @return sats per 1000bytes, or throws ForeignBlockchainException if something went wrong + */ + @Override + public long getP2shFee(Long timestamp) throws ForeignBlockchainException { + return this.digibyteNet.getP2shFee(timestamp); + } + +} diff --git a/src/main/java/org/qortal/crosschain/DigibyteACCTv3.java b/src/main/java/org/qortal/crosschain/DigibyteACCTv3.java new file mode 100644 index 00000000..e1e33862 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/DigibyteACCTv3.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 Digibyte & 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 Digibyte & Qortal 'trade' keys
    • + *
    • Alice funds Digibyte P2SH-A
    • + *
    • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: + *
        + *
      • hash-of-secret-A
      • + *
      • her 'trade' Digibyte 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 Digibyte 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 Digibyte trade key and secret-A
    • + *
    • P2SH-A DGB funds end up at Digibyte address determined by redeem transaction output(s)
    • + *
    + *
  • + *
+ */ +public class DigibyteACCTv3 implements ACCT { + + private static final Logger LOGGER = LogManager.getLogger(DigibyteACCTv3.class); + + public static final String NAME = DigibyteACCTv3.class.getSimpleName(); + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("e6a7dcd87296fae3ce7d80183bf7660c8e2cb4f8746c6a0421a17148f87a0e1d").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[] partnerDigibytePKH; + public byte[] hashOfSecretA; + public long lockTimeA; + } + public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerDigibytePKH*/ + 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 Digibyte 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 DigibyteACCTv3 instance; + + private DigibyteACCTv3() { + } + + public static synchronized DigibyteACCTv3 getInstance() { + if (instance == null) + instance = new DigibyteACCTv3(); + + return instance; + } + + @Override + public byte[] getCodeBytesHash() { + return CODE_BYTES_HASH; + } + + @Override + public int getModeByteOffset() { + return MODE_BYTE_OFFSET; + } + + @Override + public ForeignBlockchain getBlockchain() { + return Digibyte.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 digibytePublicKeyHash 20-byte HASH160 of creator's trade Digibyte public key + * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT + * @param digibyteAmount how much DGB the AT creator is expecting to trade + * @param tradeTimeout suggested timeout for entire trade + */ + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] digibytePublicKeyHash, long qortAmount, long digibyteAmount, int tradeTimeout) { + if (digibytePublicKeyHash.length != 20) + throw new IllegalArgumentException("Digibyte 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 addrDigibytePublicKeyHash = addrCounter; + addrCounter += 4; + + final int addrQortAmount = addrCounter++; + final int addrDigibyteAmount = 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 addrTradeMessagePartnerDigibytePKHOffset = addrCounter++; + final int addrPartnerDigibytePKHPointer = 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 addrPartnerDigibytePKH = 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)); + + // Digibyte public key hash + assert dataByteBuffer.position() == addrDigibytePublicKeyHash * MachineState.VALUE_SIZE : "addrDigibytePublicKeyHash incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(digibytePublicKeyHash, 32, 0)); + + // Redeem Qort amount + assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; + dataByteBuffer.putLong(qortAmount); + + // Expected Digibyte amount + assert dataByteBuffer.position() == addrDigibyteAmount * MachineState.VALUE_SIZE : "addrDigibyteAmount incorrect"; + dataByteBuffer.putLong(digibyteAmount); + + // 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 Digibyte PKH + assert dataByteBuffer.position() == addrTradeMessagePartnerDigibytePKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerDigibytePKHOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Index into data segment of partner's Digibyte PKH, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerDigibytePKHPointer * MachineState.VALUE_SIZE : "addrPartnerDigibytePKHPointer incorrect"; + dataByteBuffer.putLong(addrPartnerDigibytePKH); + + // 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)); + + /* NOP - to ensure DIGIBYTE ACCT is unique */ + codeByteBuffer.put(OpCode.NOP.compile()); + + // 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 Digibyte 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, addrTradeMessagePartnerDigibytePKHOffset)); + // Store partner's Digibyte PKH (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerDigibytePKHPointer)); + // 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(); + + // 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 DGB-QORT ACCT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + assert Arrays.equals(Crypto.digest(codeBytes), DigibyteACCTv3.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.DIGIBYTE.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 Digibyte/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 DGB 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 Digibyte PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's Digibyte 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 Digibyte PKH + byte[] partnerDigibytePKH = new byte[20]; + dataByteBuffer.get(partnerDigibytePKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerDigibytePKH.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 = partnerDigibytePKH; + 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.partnerDigibytePKH = 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/Ravencoin.java b/src/main/java/org/qortal/crosschain/Ravencoin.java new file mode 100644 index 00000000..d65c0a13 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/Ravencoin.java @@ -0,0 +1,175 @@ +package org.qortal.crosschain; + +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Context; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.params.RegTestParams; +import org.bitcoinj.params.TestNet3Params; +import org.libdohj.params.RavencoinMainNetParams; +import org.qortal.crosschain.ElectrumX.Server; +import org.qortal.crosschain.ElectrumX.Server.ConnectionType; +import org.qortal.settings.Settings; + +public class Ravencoin extends Bitcoiny { + + public static final String CURRENCY_CODE = "RVN"; + + private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(1125000); // 0.01125 RVN per 1000 bytes + + private static final long MINIMUM_ORDER_AMOUNT = 1000000; // 0.01 RVN minimum order, to avoid dust errors + + // Temporary values until a dynamic fee system is written. + private static final long MAINNET_FEE = 1000000L; + private static final long NON_MAINNET_FEE = 1000000L; // enough for TESTNET3 and should be OK for REGTEST + + private static final Map DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ConnectionType.class); + static { + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001); + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002); + } + + public enum RavencoinNet { + MAIN { + @Override + public NetworkParameters getParams() { + return RavencoinMainNetParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + // Servers chosen on NO BASIS WHATSOEVER from various sources! + // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=rvn + new Server("aethyn.com", ConnectionType.SSL, 50002), + new Server("electrum2.rvn.rocks", ConnectionType.SSL, 50002), + new Server("rvn-dashboard.com", ConnectionType.SSL, 50002), + new Server("rvn4lyfe.com", ConnectionType.SSL, 50002), + new Server("electrum1.cipig.net", ConnectionType.SSL, 20051), + new Server("electrum2.cipig.net", ConnectionType.SSL, 20051), + new Server("electrum3.cipig.net", ConnectionType.SSL, 20051)); + } + + @Override + public String getGenesisHash() { + return "0000006b444bc2f2ffe627be9d9e7e7a0730000870ef6eb6da46c8eae389df90"; + } + + @Override + public long getP2shFee(Long timestamp) { + // TODO: This will need to be replaced with something better in the near future! + return MAINNET_FEE; + } + }, + TEST3 { + @Override + public NetworkParameters getParams() { + return TestNet3Params.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList(); // TODO: find testnet servers + } + + @Override + public String getGenesisHash() { + return "000000ecfc5e6324a079542221d00e10362bdc894d56500c414060eea8a3ad5a"; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }, + REGTEST { + @Override + public NetworkParameters getParams() { + return RegTestParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + new Server("localhost", ConnectionType.TCP, 50001), + new Server("localhost", ConnectionType.SSL, 50002)); + } + + @Override + public String getGenesisHash() { + // This is unique to each regtest instance + return null; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }; + + public abstract NetworkParameters getParams(); + public abstract Collection getServers(); + public abstract String getGenesisHash(); + public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException; + } + + private static Ravencoin instance; + + private final RavencoinNet ravencoinNet; + + // Constructors and instance + + private Ravencoin(RavencoinNet ravencoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) { + super(blockchain, bitcoinjContext, currencyCode); + this.ravencoinNet = ravencoinNet; + + LOGGER.info(() -> String.format("Starting Ravencoin support using %s", this.ravencoinNet.name())); + } + + public static synchronized Ravencoin getInstance() { + if (instance == null) { + RavencoinNet ravencoinNet = Settings.getInstance().getRavencoinNet(); + + BitcoinyBlockchainProvider electrumX = new ElectrumX("Ravencoin-" + ravencoinNet.name(), ravencoinNet.getGenesisHash(), ravencoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS); + Context bitcoinjContext = new Context(ravencoinNet.getParams()); + + instance = new Ravencoin(ravencoinNet, electrumX, bitcoinjContext, CURRENCY_CODE); + } + + return instance; + } + + // Getters & setters + + public static synchronized void resetForTesting() { + instance = null; + } + + // Actual useful methods for use by other classes + + @Override + public Coin getFeePerKb() { + return DEFAULT_FEE_PER_KB; + } + + @Override + public long getMinimumOrderAmount() { + return MINIMUM_ORDER_AMOUNT; + } + + /** + * Returns estimated RVN fee, in sats per 1000bytes, optionally for historic timestamp. + * + * @param timestamp optional milliseconds since epoch, or null for 'now' + * @return sats per 1000bytes, or throws ForeignBlockchainException if something went wrong + */ + @Override + public long getP2shFee(Long timestamp) throws ForeignBlockchainException { + return this.ravencoinNet.getP2shFee(timestamp); + } + +} diff --git a/src/main/java/org/qortal/crosschain/RavencoinACCTv3.java b/src/main/java/org/qortal/crosschain/RavencoinACCTv3.java new file mode 100644 index 00000000..866e2d6b --- /dev/null +++ b/src/main/java/org/qortal/crosschain/RavencoinACCTv3.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 Ravencoin & 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 Ravencoin & Qortal 'trade' keys
    • + *
    • Alice funds Ravencoin P2SH-A
    • + *
    • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: + *
        + *
      • hash-of-secret-A
      • + *
      • her 'trade' Ravencoin 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 Ravencoin 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 Ravencoin trade key and secret-A
    • + *
    • P2SH-A RVN funds end up at Ravencoin address determined by redeem transaction output(s)
    • + *
    + *
  • + *
+ */ +public class RavencoinACCTv3 implements ACCT { + + private static final Logger LOGGER = LogManager.getLogger(RavencoinACCTv3.class); + + public static final String NAME = RavencoinACCTv3.class.getSimpleName(); + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("91395fa1ec0dfa35beddb0a7f4cc0a1bede157c38787ddb0af0cf03dfdc10f77").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[] partnerRavencoinPKH; + public byte[] hashOfSecretA; + public long lockTimeA; + } + public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerRavencoinPKH*/ + 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 Ravencoin 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 RavencoinACCTv3 instance; + + private RavencoinACCTv3() { + } + + public static synchronized RavencoinACCTv3 getInstance() { + if (instance == null) + instance = new RavencoinACCTv3(); + + return instance; + } + + @Override + public byte[] getCodeBytesHash() { + return CODE_BYTES_HASH; + } + + @Override + public int getModeByteOffset() { + return MODE_BYTE_OFFSET; + } + + @Override + public ForeignBlockchain getBlockchain() { + return Ravencoin.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 ravencoinPublicKeyHash 20-byte HASH160 of creator's trade Ravencoin public key + * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT + * @param ravencoinAmount how much RVN the AT creator is expecting to trade + * @param tradeTimeout suggested timeout for entire trade + */ + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] ravencoinPublicKeyHash, long qortAmount, long ravencoinAmount, int tradeTimeout) { + if (ravencoinPublicKeyHash.length != 20) + throw new IllegalArgumentException("Ravencoin 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 addrRavencoinPublicKeyHash = addrCounter; + addrCounter += 4; + + final int addrQortAmount = addrCounter++; + final int addrRavencoinAmount = 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 addrTradeMessagePartnerRavencoinPKHOffset = addrCounter++; + final int addrPartnerRavencoinPKHPointer = 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 addrPartnerRavencoinPKH = 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)); + + // Ravencoin public key hash + assert dataByteBuffer.position() == addrRavencoinPublicKeyHash * MachineState.VALUE_SIZE : "addrRavencoinPublicKeyHash incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(ravencoinPublicKeyHash, 32, 0)); + + // Redeem Qort amount + assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; + dataByteBuffer.putLong(qortAmount); + + // Expected Ravencoin amount + assert dataByteBuffer.position() == addrRavencoinAmount * MachineState.VALUE_SIZE : "addrRavencoinAmount incorrect"; + dataByteBuffer.putLong(ravencoinAmount); + + // 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 Ravencoin PKH + assert dataByteBuffer.position() == addrTradeMessagePartnerRavencoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerRavencoinPKHOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Index into data segment of partner's Ravencoin PKH, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerRavencoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerRavencoinPKHPointer incorrect"; + dataByteBuffer.putLong(addrPartnerRavencoinPKH); + + // 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)); + + /* NOP - to ensure RAVENCOIN ACCT is unique */ + codeByteBuffer.put(OpCode.NOP.compile()); + + // 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 Ravencoin 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, addrTradeMessagePartnerRavencoinPKHOffset)); + // Store partner's Ravencoin PKH (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerRavencoinPKHPointer)); + // 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(); + + // 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 RVN-QORT ACCT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + assert Arrays.equals(Crypto.digest(codeBytes), RavencoinACCTv3.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.RAVENCOIN.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 Ravencoin/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 RVN 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 Ravencoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's Ravencoin 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 Ravencoin PKH + byte[] partnerRavencoinPKH = new byte[20]; + dataByteBuffer.get(partnerRavencoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerRavencoinPKH.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 = partnerRavencoinPKH; + 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.partnerRavencoinPKH = 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 a26e0e01..5e3b4078 100644 --- a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java +++ b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java @@ -57,6 +57,34 @@ public enum SupportedBlockchain { public ACCT getLatestAcct() { return DogecoinACCTv3.getInstance(); } + }, + + DIGIBYTE(Arrays.asList( + Triple.valueOf(DigibyteACCTv3.NAME, DigibyteACCTv3.CODE_BYTES_HASH, DigibyteACCTv3::getInstance) + )) { + @Override + public ForeignBlockchain getInstance() { + return Digibyte.getInstance(); + } + + @Override + public ACCT getLatestAcct() { + return DigibyteACCTv3.getInstance(); + } + }, + + RAVENCOIN(Arrays.asList( + Triple.valueOf(RavencoinACCTv3.NAME, RavencoinACCTv3.CODE_BYTES_HASH, RavencoinACCTv3::getInstance) + )) { + @Override + public ForeignBlockchain getInstance() { + return Ravencoin.getInstance(); + } + + @Override + public ACCT getLatestAcct() { + return RavencoinACCTv3.getInstance(); + } }; private static final Map> supportedAcctsByCodeHash = Arrays.stream(SupportedBlockchain.values()) diff --git a/src/main/java/org/qortal/gui/Gui.java b/src/main/java/org/qortal/gui/Gui.java index 87342f6a..4944db52 100644 --- a/src/main/java/org/qortal/gui/Gui.java +++ b/src/main/java/org/qortal/gui/Gui.java @@ -4,6 +4,7 @@ import java.awt.GraphicsEnvironment; import java.awt.image.BufferedImage; import java.io.IOException; import java.io.InputStream; +import java.util.ServiceConfigurationError; import javax.imageio.ImageIO; import javax.swing.JOptionPane; @@ -49,7 +50,7 @@ public class Gui { protected static BufferedImage loadImage(String resourceName) { try (InputStream in = Gui.class.getResourceAsStream("/images/" + resourceName)) { return ImageIO.read(in); - } catch (IllegalArgumentException | IOException e) { + } catch (IllegalArgumentException | IOException | ServiceConfigurationError e) { LOGGER.warn(String.format("Couldn't locate image resource \"images/%s\"", resourceName)); return null; } diff --git a/src/main/java/org/qortal/network/Handshake.java b/src/main/java/org/qortal/network/Handshake.java index cdcff1d7..22354cc4 100644 --- a/src/main/java/org/qortal/network/Handshake.java +++ b/src/main/java/org/qortal/network/Handshake.java @@ -13,7 +13,7 @@ import org.qortal.crypto.MemoryPoW; import org.qortal.network.message.ChallengeMessage; import org.qortal.network.message.HelloMessage; import org.qortal.network.message.Message; -import org.qortal.network.message.Message.MessageType; +import org.qortal.network.message.MessageType; import org.qortal.settings.Settings; import org.qortal.network.message.ResponseMessage; import org.qortal.utils.DaemonThreadFactory; diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index d4435ddb..a04509f1 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -13,6 +13,7 @@ import org.qortal.data.block.BlockData; import org.qortal.data.network.PeerData; import org.qortal.data.transaction.TransactionData; import org.qortal.network.message.*; +import org.qortal.network.task.*; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -32,6 +33,7 @@ import java.nio.channels.*; import java.security.SecureRandom; import java.util.*; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; @@ -41,9 +43,8 @@ import java.util.stream.Collectors; // For managing peers public class Network { private static final Logger LOGGER = LogManager.getLogger(Network.class); - private static Network instance; - private static final int LISTEN_BACKLOG = 10; + private static final int LISTEN_BACKLOG = 5; /** * How long before retrying after a connection failure, in milliseconds. */ @@ -122,14 +123,8 @@ public class Network { private final ExecuteProduceConsume networkEPC; private Selector channelSelector; private ServerSocketChannel serverChannel; - private Iterator channelIterator = null; - - // volatile because value is updated inside any one of the EPC threads - private volatile long nextConnectTaskTimestamp = 0L; // ms - try first connect once NTP syncs - - private final ExecutorService broadcastExecutor = Executors.newCachedThreadPool(); - // volatile because value is updated inside any one of the EPC threads - private volatile long nextBroadcastTimestamp = 0L; // ms - try first broadcast once NTP syncs + private SelectionKey serverSelectionKey; + private final Set channelsPendingWrite = ConcurrentHashMap.newKeySet(); private final Lock mergePeersLock = new ReentrantLock(); @@ -137,6 +132,8 @@ public class Network { private String ourExternalIpAddress = null; private int ourExternalPort = Settings.getInstance().getListenPort(); + private volatile boolean isShuttingDown = false; + // Constructors private Network() { @@ -170,7 +167,7 @@ public class Network { serverChannel.configureBlocking(false); serverChannel.setOption(StandardSocketOptions.SO_REUSEADDR, true); serverChannel.bind(endpoint, LISTEN_BACKLOG); - serverChannel.register(channelSelector, SelectionKey.OP_ACCEPT); + serverSelectionKey = serverChannel.register(channelSelector, SelectionKey.OP_ACCEPT); } catch (UnknownHostException e) { LOGGER.error("Can't bind listen socket to address {}", Settings.getInstance().getBindAddress()); throw new IOException("Can't bind listen socket to address", e); @@ -180,7 +177,8 @@ public class Network { } // Load all known peers from repository - synchronized (this.allKnownPeers) { List fixedNetwork = Settings.getInstance().getFixedNetwork(); + synchronized (this.allKnownPeers) { + List fixedNetwork = Settings.getInstance().getFixedNetwork(); if (fixedNetwork != null && !fixedNetwork.isEmpty()) { Long addedWhen = NTP.getTime(); String addedBy = "fixedNetwork"; @@ -214,12 +212,16 @@ public class Network { // Getters / setters - public static synchronized Network getInstance() { - if (instance == null) { - instance = new Network(); - } + private static class SingletonContainer { + private static final Network INSTANCE = new Network(); + } - return instance; + public static Network getInstance() { + return SingletonContainer.INSTANCE; + } + + public int getMaxPeers() { + return this.maxPeers; } public byte[] getMessageMagic() { @@ -453,6 +455,11 @@ public class Network { class NetworkProcessor extends ExecuteProduceConsume { + private final AtomicLong nextConnectTaskTimestamp = new AtomicLong(0L); // ms - try first connect once NTP syncs + private final AtomicLong nextBroadcastTimestamp = new AtomicLong(0L); // ms - try first broadcast once NTP syncs + + private Iterator channelIterator = null; + NetworkProcessor(ExecutorService executor) { super(executor); } @@ -494,43 +501,23 @@ public class Network { } private Task maybeProducePeerMessageTask() { - for (Peer peer : getImmutableConnectedPeers()) { - Task peerTask = peer.getMessageTask(); - if (peerTask != null) { - return peerTask; - } - } - - return null; + return getImmutableConnectedPeers().stream() + .map(Peer::getMessageTask) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); } private Task maybeProducePeerPingTask(Long now) { - // Ask connected peers whether they need a ping - for (Peer peer : getImmutableHandshakedPeers()) { - Task peerTask = peer.getPingTask(now); - if (peerTask != null) { - return peerTask; - } - } - - return null; - } - - class PeerConnectTask implements ExecuteProduceConsume.Task { - private final Peer peer; - - PeerConnectTask(Peer peer) { - this.peer = peer; - } - - @Override - public void perform() throws InterruptedException { - connectPeer(peer); - } + return getImmutableHandshakedPeers().stream() + .map(peer -> peer.getPingTask(now)) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); } private Task maybeProduceConnectPeerTask(Long now) throws InterruptedException { - if (now == null || now < nextConnectTaskTimestamp) { + if (now == null || now < nextConnectTaskTimestamp.get()) { return null; } @@ -538,7 +525,7 @@ public class Network { return null; } - nextConnectTaskTimestamp = now + 1000L; + nextConnectTaskTimestamp.set(now + 1000L); Peer targetPeer = getConnectablePeer(now); if (targetPeer == null) { @@ -550,66 +537,15 @@ public class Network { } private Task maybeProduceBroadcastTask(Long now) { - if (now == null || now < nextBroadcastTimestamp) { + if (now == null || now < nextBroadcastTimestamp.get()) { return null; } - nextBroadcastTimestamp = now + BROADCAST_INTERVAL; - return () -> Controller.getInstance().doNetworkBroadcast(); - } - - class ChannelTask implements ExecuteProduceConsume.Task { - private final SelectionKey selectionKey; - - ChannelTask(SelectionKey selectionKey) { - this.selectionKey = selectionKey; - } - - @Override - public void perform() throws InterruptedException { - try { - LOGGER.trace("Thread {} has pending channel: {}, with ops {}", - Thread.currentThread().getId(), selectionKey.channel(), selectionKey.readyOps()); - - // process pending channel task - if (selectionKey.isReadable()) { - connectionRead((SocketChannel) selectionKey.channel()); - } else if (selectionKey.isAcceptable()) { - acceptConnection((ServerSocketChannel) selectionKey.channel()); - } - - LOGGER.trace("Thread {} processed channel: {}", - Thread.currentThread().getId(), selectionKey.channel()); - } catch (CancelledKeyException e) { - LOGGER.trace("Thread {} encountered cancelled channel: {}", - Thread.currentThread().getId(), selectionKey.channel()); - } - } - - private void connectionRead(SocketChannel socketChannel) { - Peer peer = getPeerFromChannel(socketChannel); - if (peer == null) { - return; - } - - try { - peer.readChannel(); - } catch (IOException e) { - if (e.getMessage() != null && e.getMessage().toLowerCase().contains("connection reset")) { - peer.disconnect("Connection reset"); - return; - } - - LOGGER.trace("[{}] Network thread {} encountered I/O error: {}", peer.getPeerConnectionId(), - Thread.currentThread().getId(), e.getMessage(), e); - peer.disconnect("I/O error"); - } - } + nextBroadcastTimestamp.set(now + BROADCAST_INTERVAL); + return new BroadcastTask(); } private Task maybeProduceChannelTask(boolean canBlock) throws InterruptedException { - final SelectionKey nextSelectionKey; - // Synchronization here to enforce thread-safety on channelIterator synchronized (channelSelector) { // anything to do? @@ -630,91 +566,73 @@ public class Network { } channelIterator = channelSelector.selectedKeys().iterator(); + LOGGER.trace("Thread {}, after {} select, channelIterator now {}", + Thread.currentThread().getId(), + canBlock ? "blocking": "non-blocking", + channelIterator); } - if (channelIterator.hasNext()) { - nextSelectionKey = channelIterator.next(); - channelIterator.remove(); - } else { - nextSelectionKey = null; + if (!channelIterator.hasNext()) { channelIterator = null; // Nothing to do so reset iterator to cause new select + + LOGGER.trace("Thread {}, channelIterator now null", Thread.currentThread().getId()); + return null; } - LOGGER.trace("Thread {}, nextSelectionKey {}, channelIterator now {}", - Thread.currentThread().getId(), nextSelectionKey, channelIterator); - } + final SelectionKey nextSelectionKey = channelIterator.next(); + channelIterator.remove(); - if (nextSelectionKey == null) { - return null; - } + // Just in case underlying socket channel already closed elsewhere, etc. + if (!nextSelectionKey.isValid()) + return null; - return new ChannelTask(nextSelectionKey); - } - } + LOGGER.trace("Thread {}, nextSelectionKey {}", Thread.currentThread().getId(), nextSelectionKey); - private void acceptConnection(ServerSocketChannel serverSocketChannel) throws InterruptedException { - SocketChannel socketChannel; + SelectableChannel socketChannel = nextSelectionKey.channel(); - try { - socketChannel = serverSocketChannel.accept(); - } catch (IOException e) { - return; - } - - // No connection actually accepted? - if (socketChannel == null) { - return; - } - PeerAddress address = PeerAddress.fromSocket(socketChannel.socket()); - List fixedNetwork = Settings.getInstance().getFixedNetwork(); - if (fixedNetwork != null && !fixedNetwork.isEmpty() && ipNotInFixedList(address, fixedNetwork)) { - try { - LOGGER.debug("Connection discarded from peer {} as not in the fixed network list", address); - socketChannel.close(); - } catch (IOException e) { - // IGNORE - } - return; - } - - final Long now = NTP.getTime(); - Peer newPeer; - - try { - if (now == null) { - LOGGER.debug("Connection discarded from peer {} due to lack of NTP sync", address); - socketChannel.close(); - return; - } - - if (getImmutableConnectedPeers().size() >= maxPeers) { - // We have enough peers - LOGGER.debug("Connection discarded from peer {} because the server is full", address); - socketChannel.close(); - return; - } - - LOGGER.debug("Connection accepted from peer {}", address); - - newPeer = new Peer(socketChannel, channelSelector); - this.addConnectedPeer(newPeer); - - } catch (IOException e) { - if (socketChannel.isOpen()) { try { - LOGGER.debug("Connection failed from peer {} while connecting/closing", address); - socketChannel.close(); - } catch (IOException ce) { - // Couldn't close? + if (nextSelectionKey.isReadable()) { + clearInterestOps(nextSelectionKey, SelectionKey.OP_READ); + Peer peer = getPeerFromChannel((SocketChannel) socketChannel); + if (peer == null) + return null; + + return new ChannelReadTask((SocketChannel) socketChannel, peer); + } + + if (nextSelectionKey.isWritable()) { + clearInterestOps(nextSelectionKey, SelectionKey.OP_WRITE); + Peer peer = getPeerFromChannel((SocketChannel) socketChannel); + if (peer == null) + return null; + + // Any thread that queues a message to send can set OP_WRITE, + // but we only allow one pending/active ChannelWriteTask per Peer + if (!channelsPendingWrite.add(socketChannel)) + return null; + + return new ChannelWriteTask((SocketChannel) socketChannel, peer); + } + + if (nextSelectionKey.isAcceptable()) { + clearInterestOps(nextSelectionKey, SelectionKey.OP_ACCEPT); + return new ChannelAcceptTask((ServerSocketChannel) socketChannel); + } + } catch (CancelledKeyException e) { + /* + * Sometimes nextSelectionKey is cancelled / becomes invalid between the isValid() test at line 586 + * and later calls to isReadable() / isWritable() / isAcceptable() which themselves call isValid()! + * Those isXXXable() calls could throw CancelledKeyException, so we catch it here and return null. + */ + return null; } } - return; - } - this.onPeerReady(newPeer); + return null; + } } - private boolean ipNotInFixedList(PeerAddress address, List fixedNetwork) { + public boolean ipNotInFixedList(PeerAddress address, List fixedNetwork) { for (String ipAddress : fixedNetwork) { String[] bits = ipAddress.split(":"); if (bits.length >= 1 && bits.length <= 2 && address.getHost().equals(bits[0])) { @@ -750,8 +668,9 @@ public class Network { peers.removeIf(isConnectedPeer); // Don't consider already connected peers (resolved address match) - // XXX This might be too slow if we end up waiting a long time for hostnames to resolve via DNS - peers.removeIf(isResolvedAsConnectedPeer); + // Disabled because this might be too slow if we end up waiting a long time for hostnames to resolve via DNS + // Which is ok because duplicate connections to the same peer are handled during handshaking + // peers.removeIf(isResolvedAsConnectedPeer); this.checkLongestConnection(now); @@ -781,8 +700,12 @@ public class Network { } } - private boolean connectPeer(Peer newPeer) throws InterruptedException { - SocketChannel socketChannel = newPeer.connect(this.channelSelector); + public boolean connectPeer(Peer newPeer) throws InterruptedException { + // Also checked before creating PeerConnectTask + if (getImmutableOutboundHandshakedPeers().size() >= minOutboundPeers) + return false; + + SocketChannel socketChannel = newPeer.connect(); if (socketChannel == null) { return false; } @@ -797,7 +720,7 @@ public class Network { return true; } - private Peer getPeerFromChannel(SocketChannel socketChannel) { + public Peer getPeerFromChannel(SocketChannel socketChannel) { for (Peer peer : this.getImmutableConnectedPeers()) { if (peer.getSocketChannel() == socketChannel) { return peer; @@ -830,7 +753,74 @@ public class Network { nextDisconnectionCheck = now + DISCONNECTION_CHECK_INTERVAL; } - // Peer callbacks + // SocketChannel interest-ops manipulations + + private static final String[] OP_NAMES = new String[SelectionKey.OP_ACCEPT * 2]; + static { + for (int i = 0; i < OP_NAMES.length; i++) { + StringJoiner joiner = new StringJoiner(","); + + if ((i & SelectionKey.OP_READ) != 0) joiner.add("OP_READ"); + if ((i & SelectionKey.OP_WRITE) != 0) joiner.add("OP_WRITE"); + if ((i & SelectionKey.OP_CONNECT) != 0) joiner.add("OP_CONNECT"); + if ((i & SelectionKey.OP_ACCEPT) != 0) joiner.add("OP_ACCEPT"); + + OP_NAMES[i] = joiner.toString(); + } + } + + public void clearInterestOps(SelectableChannel socketChannel, int interestOps) { + SelectionKey selectionKey = socketChannel.keyFor(channelSelector); + if (selectionKey == null) + return; + + clearInterestOps(selectionKey, interestOps); + } + + private void clearInterestOps(SelectionKey selectionKey, int interestOps) { + if (!selectionKey.channel().isOpen()) + return; + + LOGGER.trace("Thread {} clearing {} interest-ops on channel: {}", + Thread.currentThread().getId(), + OP_NAMES[interestOps], + selectionKey.channel()); + + selectionKey.interestOpsAnd(~interestOps); + } + + public void setInterestOps(SelectableChannel socketChannel, int interestOps) { + SelectionKey selectionKey = socketChannel.keyFor(channelSelector); + if (selectionKey == null) { + try { + selectionKey = socketChannel.register(this.channelSelector, interestOps); + } catch (ClosedChannelException e) { + // Channel already closed so ignore + return; + } + // Fall-through to allow logging + } + + setInterestOps(selectionKey, interestOps); + } + + private void setInterestOps(SelectionKey selectionKey, int interestOps) { + if (!selectionKey.channel().isOpen()) + return; + + LOGGER.trace("Thread {} setting {} interest-ops on channel: {}", + Thread.currentThread().getId(), + OP_NAMES[interestOps], + selectionKey.channel()); + + selectionKey.interestOpsOr(interestOps); + } + + // Peer / Task callbacks + + public void notifyChannelNotWriting(SelectableChannel socketChannel) { + this.channelsPendingWrite.remove(socketChannel); + } protected void wakeupChannelSelector() { this.channelSelector.wakeup(); @@ -856,8 +846,6 @@ public class Network { } public void onDisconnect(Peer peer) { - // Notify Controller - Controller.getInstance().onPeerDisconnect(peer); if (peer.getConnectionEstablishedTime() > 0L) { LOGGER.debug("[{}] Disconnected from peer {}", peer.getPeerConnectionId(), peer); } else { @@ -865,6 +853,25 @@ public class Network { } this.removeConnectedPeer(peer); + this.channelsPendingWrite.remove(peer.getSocketChannel()); + + if (this.isShuttingDown) + // No need to do any further processing, like re-enabling listen socket or notifying Controller + return; + + if (getImmutableConnectedPeers().size() < maxPeers - 1 + && serverSelectionKey.isValid() + && (serverSelectionKey.interestOps() & SelectionKey.OP_ACCEPT) == 0) { + try { + LOGGER.debug("Re-enabling accepting incoming connections because the server is not longer full"); + setInterestOps(serverSelectionKey, SelectionKey.OP_ACCEPT); + } catch (CancelledKeyException e) { + LOGGER.error("Failed to re-enable accepting of incoming connections: {}", e.getMessage()); + } + } + + // Notify Controller + Controller.getInstance().onPeerDisconnect(peer); } public void peerMisbehaved(Peer peer) { @@ -1302,8 +1309,9 @@ public class Network { try { InetSocketAddress knownAddress = peerAddress.toSocketAddress(); - List peers = this.getImmutableConnectedPeers(); - peers.removeIf(peer -> !Peer.addressEquals(knownAddress, peer.getResolvedAddress())); + List peers = this.getImmutableConnectedPeers().stream() + .filter(peer -> Peer.addressEquals(knownAddress, peer.getResolvedAddress())) + .collect(Collectors.toList()); for (Peer peer : peers) { peer.disconnect("to be forgotten"); @@ -1461,54 +1469,27 @@ public class Network { } public void broadcast(Function peerMessageBuilder) { - class Broadcaster implements Runnable { - private final Random random = new Random(); + for (Peer peer : getImmutableHandshakedPeers()) { + if (this.isShuttingDown) + return; - private List targetPeers; - private Function peerMessageBuilder; + Message message = peerMessageBuilder.apply(peer); - Broadcaster(List targetPeers, Function peerMessageBuilder) { - this.targetPeers = targetPeers; - this.peerMessageBuilder = peerMessageBuilder; + if (message == null) { + continue; } - @Override - public void run() { - Thread.currentThread().setName("Network Broadcast"); - - for (Peer peer : targetPeers) { - // Very short sleep to reduce strain, improve multi-threading and catch interrupts - try { - Thread.sleep(random.nextInt(20) + 20L); - } catch (InterruptedException e) { - break; - } - - Message message = peerMessageBuilder.apply(peer); - - if (message == null) { - continue; - } - - if (!peer.sendMessage(message)) { - peer.disconnect("failed to broadcast message"); - } - } - - Thread.currentThread().setName("Network Broadcast (dormant)"); + if (!peer.sendMessage(message)) { + peer.disconnect("failed to broadcast message"); } } - - try { - broadcastExecutor.execute(new Broadcaster(this.getImmutableHandshakedPeers(), peerMessageBuilder)); - } catch (RejectedExecutionException e) { - // Can't execute - probably because we're shutting down, so ignore - } } // Shutdown public void shutdown() { + this.isShuttingDown = true; + // Close listen socket to prevent more incoming connections if (this.serverChannel.isOpen()) { try { @@ -1527,16 +1508,6 @@ public class Network { LOGGER.warn("Interrupted while waiting for networking threads to terminate"); } - // Stop broadcasts - this.broadcastExecutor.shutdownNow(); - try { - if (!this.broadcastExecutor.awaitTermination(1000, TimeUnit.MILLISECONDS)) { - LOGGER.warn("Broadcast threads failed to terminate"); - } - } catch (InterruptedException e) { - LOGGER.warn("Interrupted while waiting for broadcast threads failed to terminate"); - } - // Close all peer connections for (Peer peer : this.getImmutableConnectedPeers()) { peer.shutdown(); diff --git a/src/main/java/org/qortal/network/Peer.java b/src/main/java/org/qortal/network/Peer.java index da4a70a9..dbb03fda 100644 --- a/src/main/java/org/qortal/network/Peer.java +++ b/src/main/java/org/qortal/network/Peer.java @@ -11,25 +11,21 @@ import org.qortal.data.network.PeerChainTipData; import org.qortal.data.network.PeerData; import org.qortal.network.message.ChallengeMessage; import org.qortal.network.message.Message; -import org.qortal.network.message.Message.MessageException; -import org.qortal.network.message.Message.MessageType; -import org.qortal.network.message.PingMessage; +import org.qortal.network.message.MessageException; +import org.qortal.network.task.MessageTask; +import org.qortal.network.task.PingTask; import org.qortal.settings.Settings; -import org.qortal.utils.ExecuteProduceConsume; +import org.qortal.utils.ExecuteProduceConsume.Task; import org.qortal.utils.NTP; import java.io.IOException; import java.net.*; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; -import java.nio.channels.Selector; import java.nio.channels.SocketChannel; import java.security.SecureRandom; import java.util.*; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -48,9 +44,9 @@ public class Peer { private static final int RESPONSE_TIMEOUT = 3000; // ms /** - * Maximum time to wait for a peer to respond with blocks (ms) + * Maximum time to wait for a message to be added to sendQueue (ms) */ - public static final int FETCH_BLOCKS_TIMEOUT = 10000; + private static final int QUEUE_TIMEOUT = 1000; // ms /** * Interval between PING messages to a peer. (ms) @@ -71,10 +67,14 @@ public class Peer { private final UUID peerConnectionId = UUID.randomUUID(); private final Object byteBufferLock = new Object(); private ByteBuffer byteBuffer; - private Map> replyQueues; private LinkedBlockingQueue pendingMessages; + private TransferQueue sendQueue; + private ByteBuffer outputBuffer; + private String outputMessageType; + private int outputMessageId; + /** * True if we created connection to peer, false if we accepted incoming connection from peer. */ @@ -98,7 +98,7 @@ public class Peer { /** * When last PING message was sent, or null if pings not started yet. */ - private Long lastPingSent; + private Long lastPingSent = null; byte[] ourChallenge; @@ -160,10 +160,10 @@ public class Peer { /** * Construct Peer using existing, connected socket */ - public Peer(SocketChannel socketChannel, Selector channelSelector) throws IOException { + public Peer(SocketChannel socketChannel) throws IOException { this.isOutbound = false; this.socketChannel = socketChannel; - sharedSetup(channelSelector); + sharedSetup(); this.resolvedAddress = ((InetSocketAddress) socketChannel.socket().getRemoteSocketAddress()); this.isLocal = isAddressLocal(this.resolvedAddress.getAddress()); @@ -276,7 +276,7 @@ public class Peer { } } - protected void setLastPing(long lastPing) { + public void setLastPing(long lastPing) { synchronized (this.peerInfoLock) { this.lastPing = lastPing; } @@ -346,12 +346,6 @@ public class Peer { } } - protected void queueMessage(Message message) { - if (!this.pendingMessages.offer(message)) { - LOGGER.info("[{}] No room to queue message from peer {} - discarding", this.peerConnectionId, this); - } - } - public boolean isSyncInProgress() { return this.syncInProgress; } @@ -396,13 +390,14 @@ public class Peer { // Processing - private void sharedSetup(Selector channelSelector) throws IOException { + private void sharedSetup() throws IOException { this.connectionTimestamp = NTP.getTime(); this.socketChannel.setOption(StandardSocketOptions.TCP_NODELAY, true); this.socketChannel.configureBlocking(false); - this.socketChannel.register(channelSelector, SelectionKey.OP_READ); + Network.getInstance().setInterestOps(this.socketChannel, SelectionKey.OP_READ); this.byteBuffer = null; // Defer allocation to when we need it, to save memory. Sorry GC! - this.replyQueues = Collections.synchronizedMap(new HashMap>()); + this.sendQueue = new LinkedTransferQueue<>(); + this.replyQueues = new ConcurrentHashMap<>(); this.pendingMessages = new LinkedBlockingQueue<>(); Random random = new SecureRandom(); @@ -410,7 +405,7 @@ public class Peer { random.nextBytes(this.ourChallenge); } - public SocketChannel connect(Selector channelSelector) { + public SocketChannel connect() { LOGGER.trace("[{}] Connecting to peer {}", this.peerConnectionId, this); try { @@ -418,6 +413,8 @@ public class Peer { this.isLocal = isAddressLocal(this.resolvedAddress.getAddress()); this.socketChannel = SocketChannel.open(); + InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); + this.socketChannel.socket().bind(new InetSocketAddress(bindAddr, 0)); this.socketChannel.socket().connect(resolvedAddress, CONNECT_TIMEOUT); } catch (SocketTimeoutException e) { LOGGER.trace("[{}] Connection timed out to peer {}", this.peerConnectionId, this); @@ -432,7 +429,7 @@ public class Peer { try { LOGGER.debug("[{}] Connected to peer {}", this.peerConnectionId, this); - sharedSetup(channelSelector); + sharedSetup(); return socketChannel; } catch (IOException e) { LOGGER.trace("[{}] Post-connection setup failed, peer {}", this.peerConnectionId, this); @@ -450,7 +447,7 @@ public class Peer { * * @throws IOException If this channel is not yet connected */ - protected void readChannel() throws IOException { + public void readChannel() throws IOException { synchronized (this.byteBufferLock) { while (true) { if (!this.socketChannel.isOpen() || this.socketChannel.socket().isClosed()) { @@ -556,7 +553,67 @@ public class Peer { } } - protected ExecuteProduceConsume.Task getMessageTask() { + /** Maybe send some pending outgoing messages. + * + * @return true if more data is pending to be sent + */ + public boolean writeChannel() throws IOException { + // It is the responsibility of ChannelWriteTask's producer to produce only one call to writeChannel() at a time + + while (true) { + // If output byte buffer is null, fetch next message from queue (if any) + while (this.outputBuffer == null) { + Message message; + + try { + // Allow other thread time to add message to queue having raised OP_WRITE. + // Timeout is overkill but not excessive enough to clog up networking / EPC. + // This is to avoid race condition in sendMessageWithTimeout() below. + message = this.sendQueue.poll(QUEUE_TIMEOUT, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + // Shutdown situation + return false; + } + + // No message? No further work to be done + if (message == null) + return false; + + try { + this.outputBuffer = ByteBuffer.wrap(message.toBytes()); + this.outputMessageType = message.getType().name(); + this.outputMessageId = message.getId(); + + LOGGER.trace("[{}] Sending {} message with ID {} to peer {}", + this.peerConnectionId, this.outputMessageType, this.outputMessageId, this); + } catch (MessageException e) { + // Something went wrong converting message to bytes, so discard but allow another round + LOGGER.warn("[{}] Failed to send {} message with ID {} to peer {}: {}", this.peerConnectionId, + message.getType().name(), message.getId(), this, e.getMessage()); + } + } + + // If output byte buffer is not null, send from that + int bytesWritten = this.socketChannel.write(outputBuffer); + + LOGGER.trace("[{}] Sent {} bytes of {} message with ID {} to peer {} ({} total)", this.peerConnectionId, + bytesWritten, this.outputMessageType, this.outputMessageId, this, outputBuffer.limit()); + + // If we've sent 0 bytes then socket buffer is full so we need to wait until it's empty again + if (bytesWritten == 0) { + return true; + } + + // If we then exhaust the byte buffer, set it to null (otherwise loop and try to send more) + if (!this.outputBuffer.hasRemaining()) { + this.outputMessageType = null; + this.outputMessageId = 0; + this.outputBuffer = null; + } + } + } + + protected Task getMessageTask() { /* * If we are still handshaking and there is a message yet to be processed then * don't produce another message task. This allows us to process handshake @@ -580,7 +637,7 @@ public class Peer { } // Return a task to process message in queue - return () -> Network.getInstance().onMessage(this, nextMessage); + return new MessageTask(this, nextMessage); } /** @@ -605,54 +662,25 @@ public class Peer { } try { - // Send message - LOGGER.trace("[{}] Sending {} message with ID {} to peer {}", this.peerConnectionId, + // Queue message, to be picked up by ChannelWriteTask and then peer.writeChannel() + LOGGER.trace("[{}] Queuing {} message with ID {} to peer {}", this.peerConnectionId, message.getType().name(), message.getId(), this); - ByteBuffer outputBuffer = ByteBuffer.wrap(message.toBytes()); + // Check message properly constructed + message.checkValidOutgoing(); - synchronized (this.socketChannel) { - final long sendStart = System.currentTimeMillis(); - long totalBytes = 0; - - while (outputBuffer.hasRemaining()) { - int bytesWritten = this.socketChannel.write(outputBuffer); - totalBytes += bytesWritten; - - LOGGER.trace("[{}] Sent {} bytes of {} message with ID {} to peer {} ({} total)", this.peerConnectionId, - bytesWritten, message.getType().name(), message.getId(), this, totalBytes); - - if (bytesWritten == 0) { - // Underlying socket's internal buffer probably full, - // so wait a short while for bytes to actually be transmitted over the wire - - /* - * NOSONAR squid:S2276 - we don't want to use this.socketChannel.wait() - * as this releases the lock held by synchronized() above - * and would allow another thread to send another message, - * potentially interleaving them on-the-wire, causing checksum failures - * and connection loss. - */ - Thread.sleep(1L); //NOSONAR squid:S2276 - - if (System.currentTimeMillis() - sendStart > timeout) { - // We've taken too long to send this message - return false; - } - } - } - } - } catch (MessageException e) { - LOGGER.warn("[{}] Failed to send {} message with ID {} to peer {}: {}", this.peerConnectionId, - message.getType().name(), message.getId(), this, e.getMessage()); - return false; - } catch (IOException | InterruptedException e) { + // Possible race condition: + // We set OP_WRITE, EPC creates ChannelWriteTask which calls Peer.writeChannel, writeChannel's poll() finds no message to send + // Avoided by poll-with-timeout in writeChannel() above. + Network.getInstance().setInterestOps(this.socketChannel, SelectionKey.OP_WRITE); + return this.sendQueue.tryTransfer(message, timeout, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { // Send failure return false; + } catch (MessageException e) { + LOGGER.error(e.getMessage(), e); + return false; } - - // Sent OK - return true; } /** @@ -720,7 +748,7 @@ public class Peer { this.lastPingSent = NTP.getTime(); } - protected ExecuteProduceConsume.Task getPingTask(Long now) { + protected Task getPingTask(Long now) { // Pings not enabled yet? if (now == null || this.lastPingSent == null) { return null; @@ -734,19 +762,7 @@ public class Peer { // Not strictly true, but prevents this peer from being immediately chosen again this.lastPingSent = now; - return () -> { - PingMessage pingMessage = new PingMessage(); - Message message = this.getResponse(pingMessage); - - if (message == null || message.getType() != MessageType.PING) { - LOGGER.debug("[{}] Didn't receive reply from {} for PING ID {}", this.peerConnectionId, this, - pingMessage.getId()); - this.disconnect("no ping received"); - return; - } - - this.setLastPing(NTP.getTime() - now); - }; + return new PingTask(this, now); } public void disconnect(String reason) { diff --git a/src/main/java/org/qortal/network/message/ArbitraryDataFileListMessage.java b/src/main/java/org/qortal/network/message/ArbitraryDataFileListMessage.java index 32ba3fa7..ed3cae76 100644 --- a/src/main/java/org/qortal/network/message/ArbitraryDataFileListMessage.java +++ b/src/main/java/org/qortal/network/message/ArbitraryDataFileListMessage.java @@ -9,38 +9,59 @@ import org.qortal.utils.Serialization; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; public class ArbitraryDataFileListMessage extends Message { - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - private static final int HASH_LENGTH = Transformer.SHA256_LENGTH; - private static final int MAX_PEER_ADDRESS_LENGTH = PeerData.MAX_PEER_ADDRESS_SIZE; - - private final byte[] signature; - private final List hashes; + private byte[] signature; + private List hashes; private Long requestTime; private Integer requestHops; private String peerAddress; private Boolean isRelayPossible; - public ArbitraryDataFileListMessage(byte[] signature, List hashes, Long requestTime, - Integer requestHops, String peerAddress, boolean isRelayPossible) { + Integer requestHops, String peerAddress, Boolean isRelayPossible) { super(MessageType.ARBITRARY_DATA_FILE_LIST); - this.signature = signature; - this.hashes = hashes; - this.requestTime = requestTime; - this.requestHops = requestHops; - this.peerAddress = peerAddress; - this.isRelayPossible = isRelayPossible; + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(signature); + + bytes.write(Ints.toByteArray(hashes.size())); + + for (byte[] hash : hashes) { + bytes.write(hash); + } + + if (requestTime != null) { + // The remaining fields are optional + + bytes.write(Longs.toByteArray(requestTime)); + + bytes.write(Ints.toByteArray(requestHops)); + + Serialization.serializeSizedStringV2(bytes, peerAddress); + + bytes.write(Ints.toByteArray(Boolean.TRUE.equals(isRelayPossible) ? 1 : 0)); + } + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } - public ArbitraryDataFileListMessage(int id, byte[] signature, List hashes, Long requestTime, + /** Legacy version */ + public ArbitraryDataFileListMessage(byte[] signature, List hashes) { + this(signature, hashes, null, null, null, null); + } + + private ArbitraryDataFileListMessage(int id, byte[] signature, List hashes, Long requestTime, Integer requestHops, String peerAddress, boolean isRelayPossible) { super(id, MessageType.ARBITRARY_DATA_FILE_LIST); @@ -52,24 +73,39 @@ public class ArbitraryDataFileListMessage extends Message { this.isRelayPossible = isRelayPossible; } - public List getHashes() { - return this.hashes; - } - public byte[] getSignature() { return this.signature; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException, TransformationException { - byte[] signature = new byte[SIGNATURE_LENGTH]; + public List getHashes() { + return this.hashes; + } + + public Long getRequestTime() { + return this.requestTime; + } + + public Integer getRequestHops() { + return this.requestHops; + } + + public String getPeerAddress() { + return this.peerAddress; + } + + public Boolean isRelayPossible() { + return this.isRelayPossible; + } + + public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException { + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; bytes.get(signature); int count = bytes.getInt(); List hashes = new ArrayList<>(); for (int i = 0; i < count; ++i) { - - byte[] hash = new byte[HASH_LENGTH]; + byte[] hash = new byte[Transformer.SHA256_LENGTH]; bytes.get(hash); hashes.add(hash); } @@ -80,99 +116,21 @@ public class ArbitraryDataFileListMessage extends Message { boolean isRelayPossible = true; // Legacy versions only send this message when relaying is possible // The remaining fields are optional - if (bytes.hasRemaining()) { + try { + requestTime = bytes.getLong(); - requestTime = bytes.getLong(); + requestHops = bytes.getInt(); - requestHops = bytes.getInt(); - - peerAddress = Serialization.deserializeSizedStringV2(bytes, MAX_PEER_ADDRESS_LENGTH); - - isRelayPossible = bytes.getInt() > 0; + peerAddress = Serialization.deserializeSizedStringV2(bytes, PeerData.MAX_PEER_ADDRESS_SIZE); + isRelayPossible = bytes.getInt() > 0; + } catch (TransformationException e) { + throw new MessageException(e.getMessage(), e); + } } return new ArbitraryDataFileListMessage(id, signature, hashes, requestTime, requestHops, peerAddress, isRelayPossible); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.signature); - - bytes.write(Ints.toByteArray(this.hashes.size())); - - for (byte[] hash : this.hashes) { - bytes.write(hash); - } - - if (this.requestTime == null) { // To maintain backwards support - return bytes.toByteArray(); - } - - // The remaining fields are optional - - bytes.write(Longs.toByteArray(this.requestTime)); - - bytes.write(Ints.toByteArray(this.requestHops)); - - Serialization.serializeSizedStringV2(bytes, this.peerAddress); - - bytes.write(Ints.toByteArray(this.isRelayPossible ? 1 : 0)); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - - public ArbitraryDataFileListMessage cloneWithNewId(int newId) { - ArbitraryDataFileListMessage clone = new ArbitraryDataFileListMessage(this.signature, this.hashes, - this.requestTime, this.requestHops, this.peerAddress, this.isRelayPossible); - clone.setId(newId); - return clone; - } - - public void removeOptionalStats() { - this.requestTime = null; - this.requestHops = null; - this.peerAddress = null; - this.isRelayPossible = null; - } - - public Long getRequestTime() { - return this.requestTime; - } - - public void setRequestTime(Long requestTime) { - this.requestTime = requestTime; - } - - public Integer getRequestHops() { - return this.requestHops; - } - - public void setRequestHops(Integer requestHops) { - this.requestHops = requestHops; - } - - public String getPeerAddress() { - return this.peerAddress; - } - - public void setPeerAddress(String peerAddress) { - this.peerAddress = peerAddress; - } - - public Boolean isRelayPossible() { - return this.isRelayPossible; - } - - public void setIsRelayPossible(Boolean isRelayPossible) { - this.isRelayPossible = isRelayPossible; - } - } diff --git a/src/main/java/org/qortal/network/message/ArbitraryDataFileMessage.java b/src/main/java/org/qortal/network/message/ArbitraryDataFileMessage.java index b9f24e29..50991be3 100644 --- a/src/main/java/org/qortal/network/message/ArbitraryDataFileMessage.java +++ b/src/main/java/org/qortal/network/message/ArbitraryDataFileMessage.java @@ -9,44 +9,60 @@ import org.qortal.transform.Transformer; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; public class ArbitraryDataFileMessage extends Message { private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataFileMessage.class); - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - - private final byte[] signature; - private final ArbitraryDataFile arbitraryDataFile; + private byte[] signature; + private ArbitraryDataFile arbitraryDataFile; public ArbitraryDataFileMessage(byte[] signature, ArbitraryDataFile arbitraryDataFile) { super(MessageType.ARBITRARY_DATA_FILE); - this.signature = signature; - this.arbitraryDataFile = arbitraryDataFile; + byte[] data = arbitraryDataFile.getBytes(); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(signature); + + bytes.write(Ints.toByteArray(data.length)); + + bytes.write(data); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } - public ArbitraryDataFileMessage(int id, byte[] signature, ArbitraryDataFile arbitraryDataFile) { + private ArbitraryDataFileMessage(int id, byte[] signature, ArbitraryDataFile arbitraryDataFile) { super(id, MessageType.ARBITRARY_DATA_FILE); this.signature = signature; this.arbitraryDataFile = arbitraryDataFile; } + public byte[] getSignature() { + return this.signature; + } + public ArbitraryDataFile getArbitraryDataFile() { return this.arbitraryDataFile; } - public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws UnsupportedEncodingException { - byte[] signature = new byte[SIGNATURE_LENGTH]; + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException { + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; byteBuffer.get(signature); int dataLength = byteBuffer.getInt(); - if (byteBuffer.remaining() != dataLength) - return null; + if (byteBuffer.remaining() < dataLength) + throw new BufferUnderflowException(); byte[] data = new byte[dataLength]; byteBuffer.get(data); @@ -54,43 +70,10 @@ public class ArbitraryDataFileMessage extends Message { try { ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(data, signature); return new ArbitraryDataFileMessage(id, signature, arbitraryDataFile); - } - catch (DataException e) { + } catch (DataException e) { LOGGER.info("Unable to process received file: {}", e.getMessage()); - return null; + throw new MessageException("Unable to process received file: " + e.getMessage(), e); } } - @Override - protected byte[] toData() { - if (this.arbitraryDataFile == null) { - return null; - } - - byte[] data = this.arbitraryDataFile.getBytes(); - if (data == null) { - return null; - } - - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(signature); - - bytes.write(Ints.toByteArray(data.length)); - - bytes.write(data); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - - public ArbitraryDataFileMessage cloneWithNewId(int newId) { - ArbitraryDataFileMessage clone = new ArbitraryDataFileMessage(this.signature, this.arbitraryDataFile); - clone.setId(newId); - return clone; - } - } diff --git a/src/main/java/org/qortal/network/message/ArbitraryDataMessage.java b/src/main/java/org/qortal/network/message/ArbitraryDataMessage.java index 1ce149f7..142e35cc 100644 --- a/src/main/java/org/qortal/network/message/ArbitraryDataMessage.java +++ b/src/main/java/org/qortal/network/message/ArbitraryDataMessage.java @@ -2,7 +2,7 @@ package org.qortal.network.message; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import org.qortal.transform.Transformer; @@ -11,13 +11,26 @@ import com.google.common.primitives.Ints; public class ArbitraryDataMessage extends Message { - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - private byte[] signature; private byte[] data; public ArbitraryDataMessage(byte[] signature, byte[] data) { - this(-1, signature, data); + super(MessageType.ARBITRARY_DATA); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(signature); + + bytes.write(Ints.toByteArray(data.length)); + + bytes.write(data); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private ArbitraryDataMessage(int id, byte[] signature, byte[] data) { @@ -35,14 +48,14 @@ public class ArbitraryDataMessage extends Message { return this.data; } - public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws UnsupportedEncodingException { - byte[] signature = new byte[SIGNATURE_LENGTH]; + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) { + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; byteBuffer.get(signature); int dataLength = byteBuffer.getInt(); - if (byteBuffer.remaining() != dataLength) - return null; + if (byteBuffer.remaining() < dataLength) + throw new BufferUnderflowException(); byte[] data = new byte[dataLength]; byteBuffer.get(data); @@ -50,24 +63,4 @@ public class ArbitraryDataMessage extends Message { return new ArbitraryDataMessage(id, signature, data); } - @Override - protected byte[] toData() { - if (this.data == null) - return null; - - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.signature); - - bytes.write(Ints.toByteArray(this.data.length)); - - bytes.write(this.data); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/ArbitraryMetadataMessage.java b/src/main/java/org/qortal/network/message/ArbitraryMetadataMessage.java index 9228d458..26601d4b 100644 --- a/src/main/java/org/qortal/network/message/ArbitraryMetadataMessage.java +++ b/src/main/java/org/qortal/network/message/ArbitraryMetadataMessage.java @@ -7,28 +7,40 @@ import org.qortal.transform.Transformer; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; public class ArbitraryMetadataMessage extends Message { - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; + private byte[] signature; + private ArbitraryDataFile arbitraryMetadataFile; - private final byte[] signature; - private final ArbitraryDataFile arbitraryMetadataFile; - - public ArbitraryMetadataMessage(byte[] signature, ArbitraryDataFile arbitraryDataFile) { + public ArbitraryMetadataMessage(byte[] signature, ArbitraryDataFile arbitraryMetadataFile) { super(MessageType.ARBITRARY_METADATA); - this.signature = signature; - this.arbitraryMetadataFile = arbitraryDataFile; + byte[] data = arbitraryMetadataFile.getBytes(); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(signature); + + bytes.write(Ints.toByteArray(data.length)); + + bytes.write(data); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } - public ArbitraryMetadataMessage(int id, byte[] signature, ArbitraryDataFile arbitraryDataFile) { + private ArbitraryMetadataMessage(int id, byte[] signature, ArbitraryDataFile arbitraryMetadataFile) { super(id, MessageType.ARBITRARY_METADATA); this.signature = signature; - this.arbitraryMetadataFile = arbitraryDataFile; + this.arbitraryMetadataFile = arbitraryMetadataFile; } public byte[] getSignature() { @@ -39,14 +51,14 @@ public class ArbitraryMetadataMessage extends Message { return this.arbitraryMetadataFile; } - public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws UnsupportedEncodingException { - byte[] signature = new byte[SIGNATURE_LENGTH]; + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException { + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; byteBuffer.get(signature); int dataLength = byteBuffer.getInt(); - if (byteBuffer.remaining() != dataLength) - return null; + if (byteBuffer.remaining() < dataLength) + throw new BufferUnderflowException(); byte[] data = new byte[dataLength]; byteBuffer.get(data); @@ -54,42 +66,9 @@ public class ArbitraryMetadataMessage extends Message { try { ArbitraryDataFile arbitraryMetadataFile = new ArbitraryDataFile(data, signature); return new ArbitraryMetadataMessage(id, signature, arbitraryMetadataFile); + } catch (DataException e) { + throw new MessageException("Unable to process arbitrary metadata message: " + e.getMessage(), e); } - catch (DataException e) { - return null; - } - } - - @Override - protected byte[] toData() { - if (this.arbitraryMetadataFile == null) { - return null; - } - - byte[] data = this.arbitraryMetadataFile.getBytes(); - if (data == null) { - return null; - } - - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(signature); - - bytes.write(Ints.toByteArray(data.length)); - - bytes.write(data); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - - public ArbitraryMetadataMessage cloneWithNewId(int newId) { - ArbitraryMetadataMessage clone = new ArbitraryMetadataMessage(this.signature, this.arbitraryMetadataFile); - clone.setId(newId); - return clone; } } diff --git a/src/main/java/org/qortal/network/message/ArbitrarySignaturesMessage.java b/src/main/java/org/qortal/network/message/ArbitrarySignaturesMessage.java index 1f980b3c..aa75b2a1 100644 --- a/src/main/java/org/qortal/network/message/ArbitrarySignaturesMessage.java +++ b/src/main/java/org/qortal/network/message/ArbitrarySignaturesMessage.java @@ -8,21 +8,37 @@ import org.qortal.utils.Serialization; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; public class ArbitrarySignaturesMessage extends Message { - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - private String peerAddress; private int requestHops; private List signatures; public ArbitrarySignaturesMessage(String peerAddress, int requestHops, List signatures) { - this(-1, peerAddress, requestHops, signatures); + super(MessageType.ARBITRARY_SIGNATURES); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + Serialization.serializeSizedStringV2(bytes, peerAddress); + + bytes.write(Ints.toByteArray(requestHops)); + + bytes.write(Ints.toByteArray(signatures.size())); + + for (byte[] signature : signatures) + bytes.write(signature); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private ArbitrarySignaturesMessage(int id, String peerAddress, int requestHops, List signatures) { @@ -41,27 +57,24 @@ public class ArbitrarySignaturesMessage extends Message { return this.signatures; } - public int getRequestHops() { - return this.requestHops; - } - - public void setRequestHops(int requestHops) { - this.requestHops = requestHops; - } - - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException, TransformationException { - String peerAddress = Serialization.deserializeSizedStringV2(bytes, PeerData.MAX_PEER_ADDRESS_SIZE); + public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException { + String peerAddress; + try { + peerAddress = Serialization.deserializeSizedStringV2(bytes, PeerData.MAX_PEER_ADDRESS_SIZE); + } catch (TransformationException e) { + throw new MessageException(e.getMessage(), e); + } int requestHops = bytes.getInt(); int signatureCount = bytes.getInt(); - if (bytes.remaining() != signatureCount * SIGNATURE_LENGTH) - return null; + if (bytes.remaining() < signatureCount * Transformer.SIGNATURE_LENGTH) + throw new BufferUnderflowException(); List signatures = new ArrayList<>(); for (int i = 0; i < signatureCount; ++i) { - byte[] signature = new byte[SIGNATURE_LENGTH]; + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; bytes.get(signature); signatures.add(signature); } @@ -69,24 +82,4 @@ public class ArbitrarySignaturesMessage extends Message { return new ArbitrarySignaturesMessage(id, peerAddress, requestHops, signatures); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - Serialization.serializeSizedStringV2(bytes, this.peerAddress); - - bytes.write(Ints.toByteArray(this.requestHops)); - - bytes.write(Ints.toByteArray(this.signatures.size())); - - for (byte[] signature : this.signatures) - bytes.write(signature); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/BlockMessage.java b/src/main/java/org/qortal/network/message/BlockMessage.java index b07dc8b1..2dd4db87 100644 --- a/src/main/java/org/qortal/network/message/BlockMessage.java +++ b/src/main/java/org/qortal/network/message/BlockMessage.java @@ -1,14 +1,10 @@ package org.qortal.network.message; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.util.List; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.block.Block; import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; import org.qortal.data.transaction.TransactionData; @@ -16,27 +12,15 @@ import org.qortal.transform.TransformationException; import org.qortal.transform.block.BlockTransformer; import org.qortal.utils.Triple; -import com.google.common.primitives.Ints; - public class BlockMessage extends Message { private static final Logger LOGGER = LogManager.getLogger(BlockMessage.class); - private Block block = null; + private final BlockData blockData; + private final List transactions; + private final List atStates; - private BlockData blockData = null; - private List transactions = null; - private List atStates = null; - - private int height; - - public BlockMessage(Block block) { - super(MessageType.BLOCK); - - this.block = block; - this.blockData = block.getBlockData(); - this.height = block.getBlockData().getHeight(); - } + // No public constructor as we're an incoming-only message type. private BlockMessage(int id, BlockData blockData, List transactions, List atStates) { super(id, MessageType.BLOCK); @@ -44,8 +28,6 @@ public class BlockMessage extends Message { this.blockData = blockData; this.transactions = transactions; this.atStates = atStates; - - this.height = blockData.getHeight(); } public BlockData getBlockData() { @@ -60,7 +42,7 @@ public class BlockMessage extends Message { return this.atStates; } - public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException { try { int height = byteBuffer.getInt(); @@ -72,32 +54,8 @@ public class BlockMessage extends Message { return new BlockMessage(id, blockData, blockInfo.getB(), blockInfo.getC()); } catch (TransformationException e) { LOGGER.info(String.format("Received garbled BLOCK message: %s", e.getMessage())); - return null; + throw new MessageException(e.getMessage(), e); } } - @Override - protected byte[] toData() { - if (this.block == null) - return null; - - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(Ints.toByteArray(this.height)); - - bytes.write(BlockTransformer.toBytes(this.block)); - - return bytes.toByteArray(); - } catch (TransformationException | IOException e) { - return null; - } - } - - public BlockMessage cloneWithNewId(int newId) { - BlockMessage clone = new BlockMessage(this.block); - clone.setId(newId); - return clone; - } - } diff --git a/src/main/java/org/qortal/network/message/BlockSummariesMessage.java b/src/main/java/org/qortal/network/message/BlockSummariesMessage.java index 6a30608b..513e30ae 100644 --- a/src/main/java/org/qortal/network/message/BlockSummariesMessage.java +++ b/src/main/java/org/qortal/network/message/BlockSummariesMessage.java @@ -2,7 +2,7 @@ package org.qortal.network.message; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; @@ -20,7 +20,25 @@ public class BlockSummariesMessage extends Message { private List blockSummaries; public BlockSummariesMessage(List blockSummaries) { - this(-1, blockSummaries); + super(MessageType.BLOCK_SUMMARIES); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(Ints.toByteArray(blockSummaries.size())); + + for (BlockSummaryData blockSummary : blockSummaries) { + bytes.write(Ints.toByteArray(blockSummary.getHeight())); + bytes.write(blockSummary.getSignature()); + bytes.write(blockSummary.getMinterPublicKey()); + bytes.write(Ints.toByteArray(blockSummary.getOnlineAccountsCount())); + } + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private BlockSummariesMessage(int id, List blockSummaries) { @@ -33,11 +51,11 @@ public class BlockSummariesMessage extends Message { return this.blockSummaries; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { int count = bytes.getInt(); - if (bytes.remaining() != count * BLOCK_SUMMARY_LENGTH) - return null; + if (bytes.remaining() < count * BLOCK_SUMMARY_LENGTH) + throw new BufferUnderflowException(); List blockSummaries = new ArrayList<>(); for (int i = 0; i < count; ++i) { @@ -58,24 +76,4 @@ public class BlockSummariesMessage extends Message { return new BlockSummariesMessage(id, blockSummaries); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(Ints.toByteArray(this.blockSummaries.size())); - - for (BlockSummaryData blockSummary : this.blockSummaries) { - bytes.write(Ints.toByteArray(blockSummary.getHeight())); - bytes.write(blockSummary.getSignature()); - bytes.write(blockSummary.getMinterPublicKey()); - bytes.write(Ints.toByteArray(blockSummary.getOnlineAccountsCount())); - } - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/CachedBlockMessage.java b/src/main/java/org/qortal/network/message/CachedBlockMessage.java index e5029ab0..48e9ef36 100644 --- a/src/main/java/org/qortal/network/message/CachedBlockMessage.java +++ b/src/main/java/org/qortal/network/message/CachedBlockMessage.java @@ -2,7 +2,6 @@ package org.qortal.network.message; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import org.qortal.block.Block; @@ -12,59 +11,34 @@ import org.qortal.transform.block.BlockTransformer; import com.google.common.primitives.Ints; // This is an OUTGOING-only Message which more readily lends itself to being cached -public class CachedBlockMessage extends Message { +public class CachedBlockMessage extends Message implements Cloneable { - private Block block = null; - private byte[] cachedBytes = null; - - public CachedBlockMessage(Block block) { + public CachedBlockMessage(Block block) throws TransformationException { super(MessageType.BLOCK); - this.block = block; + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); + + bytes.write(BlockTransformer.toBytes(block)); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } public CachedBlockMessage(byte[] cachedBytes) { super(MessageType.BLOCK); - this.block = null; - this.cachedBytes = cachedBytes; + this.dataBytes = cachedBytes; + this.checksumBytes = Message.generateChecksum(this.dataBytes); } - - public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws UnsupportedEncodingException { + + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) { throw new UnsupportedOperationException("CachedBlockMessage is for outgoing messages only"); } - @Override - protected byte[] toData() { - // Already serialized? - if (this.cachedBytes != null) - return cachedBytes; - - if (this.block == null) - return null; - - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(Ints.toByteArray(this.block.getBlockData().getHeight())); - - bytes.write(BlockTransformer.toBytes(this.block)); - - this.cachedBytes = bytes.toByteArray(); - // We no longer need source Block - // and Block contains repository handle which is highly likely to be invalid after this call - this.block = null; - - return this.cachedBytes; - } catch (TransformationException | IOException e) { - return null; - } - } - - public CachedBlockMessage cloneWithNewId(int newId) { - CachedBlockMessage clone = new CachedBlockMessage(this.cachedBytes); - clone.setId(newId); - return clone; - } - } diff --git a/src/main/java/org/qortal/network/message/ChallengeMessage.java b/src/main/java/org/qortal/network/message/ChallengeMessage.java index 425f9790..bb5b2ae9 100644 --- a/src/main/java/org/qortal/network/message/ChallengeMessage.java +++ b/src/main/java/org/qortal/network/message/ChallengeMessage.java @@ -10,8 +10,25 @@ public class ChallengeMessage extends Message { public static final int CHALLENGE_LENGTH = 32; - private final byte[] publicKey; - private final byte[] challenge; + private byte[] publicKey; + private byte[] challenge; + + public ChallengeMessage(byte[] publicKey, byte[] challenge) { + super(MessageType.CHALLENGE); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(publicKey.length + challenge.length); + + try { + bytes.write(publicKey); + + bytes.write(challenge); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + } private ChallengeMessage(int id, byte[] publicKey, byte[] challenge) { super(id, MessageType.CHALLENGE); @@ -20,10 +37,6 @@ public class ChallengeMessage extends Message { this.challenge = challenge; } - public ChallengeMessage(byte[] publicKey, byte[] challenge) { - this(-1, publicKey, challenge); - } - public byte[] getPublicKey() { return this.publicKey; } @@ -42,15 +55,4 @@ public class ChallengeMessage extends Message { return new ChallengeMessage(id, publicKey, challenge); } - @Override - protected byte[] toData() throws IOException { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.publicKey); - - bytes.write(this.challenge); - - return bytes.toByteArray(); - } - } diff --git a/src/main/java/org/qortal/network/message/GetArbitraryDataFileListMessage.java b/src/main/java/org/qortal/network/message/GetArbitraryDataFileListMessage.java index 542854a5..467a229f 100644 --- a/src/main/java/org/qortal/network/message/GetArbitraryDataFileListMessage.java +++ b/src/main/java/org/qortal/network/message/GetArbitraryDataFileListMessage.java @@ -5,33 +5,54 @@ import com.google.common.primitives.Longs; import org.qortal.data.network.PeerData; import org.qortal.transform.TransformationException; import org.qortal.transform.Transformer; -import org.qortal.transform.transaction.TransactionTransformer; import org.qortal.utils.Serialization; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; -import static org.qortal.transform.Transformer.INT_LENGTH; -import static org.qortal.transform.Transformer.LONG_LENGTH; - public class GetArbitraryDataFileListMessage extends Message { - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - private static final int HASH_LENGTH = TransactionTransformer.SHA256_LENGTH; - private static final int MAX_PEER_ADDRESS_LENGTH = PeerData.MAX_PEER_ADDRESS_SIZE; - - private final byte[] signature; + private byte[] signature; private List hashes; - private final long requestTime; + private long requestTime; private int requestHops; private String requestingPeer; public GetArbitraryDataFileListMessage(byte[] signature, List hashes, long requestTime, int requestHops, String requestingPeer) { - this(-1, signature, hashes, requestTime, requestHops, requestingPeer); + super(MessageType.GET_ARBITRARY_DATA_FILE_LIST); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(signature); + + bytes.write(Longs.toByteArray(requestTime)); + + bytes.write(Ints.toByteArray(requestHops)); + + if (hashes != null) { + bytes.write(Ints.toByteArray(hashes.size())); + + for (byte[] hash : hashes) { + bytes.write(hash); + } + } + else { + bytes.write(Ints.toByteArray(0)); + } + + if (requestingPeer != null) { + Serialization.serializeSizedStringV2(bytes, requestingPeer); + } + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetArbitraryDataFileListMessage(int id, byte[] signature, List hashes, long requestTime, int requestHops, String requestingPeer) { @@ -52,8 +73,20 @@ public class GetArbitraryDataFileListMessage extends Message { return this.hashes; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException, TransformationException { - byte[] signature = new byte[SIGNATURE_LENGTH]; + public long getRequestTime() { + return this.requestTime; + } + + public int getRequestHops() { + return this.requestHops; + } + + public String getRequestingPeer() { + return this.requestingPeer; + } + + public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException { + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; bytes.get(signature); @@ -67,7 +100,7 @@ public class GetArbitraryDataFileListMessage extends Message { hashes = new ArrayList<>(); for (int i = 0; i < hashCount; ++i) { - byte[] hash = new byte[HASH_LENGTH]; + byte[] hash = new byte[Transformer.SHA256_LENGTH]; bytes.get(hash); hashes.add(hash); } @@ -75,57 +108,14 @@ public class GetArbitraryDataFileListMessage extends Message { String requestingPeer = null; if (bytes.hasRemaining()) { - requestingPeer = Serialization.deserializeSizedStringV2(bytes, MAX_PEER_ADDRESS_LENGTH); + try { + requestingPeer = Serialization.deserializeSizedStringV2(bytes, PeerData.MAX_PEER_ADDRESS_SIZE); + } catch (TransformationException e) { + throw new MessageException(e.getMessage(), e); + } } return new GetArbitraryDataFileListMessage(id, signature, hashes, requestTime, requestHops, requestingPeer); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.signature); - - bytes.write(Longs.toByteArray(this.requestTime)); - - bytes.write(Ints.toByteArray(this.requestHops)); - - if (this.hashes != null) { - bytes.write(Ints.toByteArray(this.hashes.size())); - - for (byte[] hash : this.hashes) { - bytes.write(hash); - } - } - else { - bytes.write(Ints.toByteArray(0)); - } - - if (this.requestingPeer != null) { - Serialization.serializeSizedStringV2(bytes, this.requestingPeer); - } - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - - public long getRequestTime() { - return this.requestTime; - } - - public int getRequestHops() { - return this.requestHops; - } - public void setRequestHops(int requestHops) { - this.requestHops = requestHops; - } - - public String getRequestingPeer() { - return this.requestingPeer; - } - } diff --git a/src/main/java/org/qortal/network/message/GetArbitraryDataFileMessage.java b/src/main/java/org/qortal/network/message/GetArbitraryDataFileMessage.java index 809b983d..d97a4847 100644 --- a/src/main/java/org/qortal/network/message/GetArbitraryDataFileMessage.java +++ b/src/main/java/org/qortal/network/message/GetArbitraryDataFileMessage.java @@ -1,23 +1,31 @@ package org.qortal.network.message; import org.qortal.transform.Transformer; -import org.qortal.transform.transaction.TransactionTransformer; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; public class GetArbitraryDataFileMessage extends Message { - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - private static final int HASH_LENGTH = TransactionTransformer.SHA256_LENGTH; - - private final byte[] signature; - private final byte[] hash; + private byte[] signature; + private byte[] hash; public GetArbitraryDataFileMessage(byte[] signature, byte[] hash) { - this(-1, signature, hash); + super(MessageType.GET_ARBITRARY_DATA_FILE); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(signature.length + hash.length); + + try { + bytes.write(signature); + + bytes.write(hash); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetArbitraryDataFileMessage(int id, byte[] signature, byte[] hash) { @@ -35,32 +43,14 @@ public class GetArbitraryDataFileMessage extends Message { return this.hash; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { - if (bytes.remaining() != HASH_LENGTH + SIGNATURE_LENGTH) - return null; - - byte[] signature = new byte[SIGNATURE_LENGTH]; + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; bytes.get(signature); - byte[] hash = new byte[HASH_LENGTH]; + byte[] hash = new byte[Transformer.SHA256_LENGTH]; bytes.get(hash); return new GetArbitraryDataFileMessage(id, signature, hash); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.signature); - - bytes.write(this.hash); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/GetArbitraryDataMessage.java b/src/main/java/org/qortal/network/message/GetArbitraryDataMessage.java index 689d704b..bf604fe7 100644 --- a/src/main/java/org/qortal/network/message/GetArbitraryDataMessage.java +++ b/src/main/java/org/qortal/network/message/GetArbitraryDataMessage.java @@ -1,20 +1,19 @@ package org.qortal.network.message; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; +import java.util.Arrays; import org.qortal.transform.Transformer; public class GetArbitraryDataMessage extends Message { - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - private byte[] signature; public GetArbitraryDataMessage(byte[] signature) { - this(-1, signature); + super(MessageType.GET_ARBITRARY_DATA); + + this.dataBytes = Arrays.copyOf(signature, signature.length); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetArbitraryDataMessage(int id, byte[] signature) { @@ -27,28 +26,12 @@ public class GetArbitraryDataMessage extends Message { return this.signature; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { - if (bytes.remaining() != SIGNATURE_LENGTH) - return null; - - byte[] signature = new byte[SIGNATURE_LENGTH]; + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; bytes.get(signature); return new GetArbitraryDataMessage(id, signature); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.signature); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/GetArbitraryMetadataMessage.java b/src/main/java/org/qortal/network/message/GetArbitraryMetadataMessage.java index 66c8f86c..2501d5c3 100644 --- a/src/main/java/org/qortal/network/message/GetArbitraryMetadataMessage.java +++ b/src/main/java/org/qortal/network/message/GetArbitraryMetadataMessage.java @@ -6,22 +6,31 @@ import org.qortal.transform.Transformer; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; -import static org.qortal.transform.Transformer.INT_LENGTH; -import static org.qortal.transform.Transformer.LONG_LENGTH; - public class GetArbitraryMetadataMessage extends Message { - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - - private final byte[] signature; - private final long requestTime; + private byte[] signature; + private long requestTime; private int requestHops; public GetArbitraryMetadataMessage(byte[] signature, long requestTime, int requestHops) { - this(-1, signature, requestTime, requestHops); + super(MessageType.GET_ARBITRARY_METADATA); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(signature); + + bytes.write(Longs.toByteArray(requestTime)); + + bytes.write(Ints.toByteArray(requestHops)); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetArbitraryMetadataMessage(int id, byte[] signature, long requestTime, int requestHops) { @@ -36,12 +45,16 @@ public class GetArbitraryMetadataMessage extends Message { return this.signature; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { - if (bytes.remaining() != SIGNATURE_LENGTH + LONG_LENGTH + INT_LENGTH) - return null; + public long getRequestTime() { + return this.requestTime; + } - byte[] signature = new byte[SIGNATURE_LENGTH]; + public int getRequestHops() { + return this.requestHops; + } + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; bytes.get(signature); long requestTime = bytes.getLong(); @@ -51,33 +64,4 @@ public class GetArbitraryMetadataMessage extends Message { return new GetArbitraryMetadataMessage(id, signature, requestTime, requestHops); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.signature); - - bytes.write(Longs.toByteArray(this.requestTime)); - - bytes.write(Ints.toByteArray(this.requestHops)); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - - public long getRequestTime() { - return this.requestTime; - } - - public int getRequestHops() { - return this.requestHops; - } - - public void setRequestHops(int requestHops) { - this.requestHops = requestHops; - } - } diff --git a/src/main/java/org/qortal/network/message/GetBlockMessage.java b/src/main/java/org/qortal/network/message/GetBlockMessage.java index 43484e69..d39dcca0 100644 --- a/src/main/java/org/qortal/network/message/GetBlockMessage.java +++ b/src/main/java/org/qortal/network/message/GetBlockMessage.java @@ -1,20 +1,19 @@ package org.qortal.network.message; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; +import java.util.Arrays; import org.qortal.transform.block.BlockTransformer; public class GetBlockMessage extends Message { - private static final int BLOCK_SIGNATURE_LENGTH = BlockTransformer.BLOCK_SIGNATURE_LENGTH; - private byte[] signature; public GetBlockMessage(byte[] signature) { - this(-1, signature); + super(MessageType.GET_BLOCK); + + this.dataBytes = Arrays.copyOf(signature, signature.length); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetBlockMessage(int id, byte[] signature) { @@ -27,28 +26,11 @@ public class GetBlockMessage extends Message { return this.signature; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { - if (bytes.remaining() != BLOCK_SIGNATURE_LENGTH) - return null; - - byte[] signature = new byte[BLOCK_SIGNATURE_LENGTH]; - + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + byte[] signature = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; bytes.get(signature); return new GetBlockMessage(id, signature); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.signature); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/GetBlockSummariesMessage.java b/src/main/java/org/qortal/network/message/GetBlockSummariesMessage.java index 148640fd..70f0d5c5 100644 --- a/src/main/java/org/qortal/network/message/GetBlockSummariesMessage.java +++ b/src/main/java/org/qortal/network/message/GetBlockSummariesMessage.java @@ -2,23 +2,32 @@ package org.qortal.network.message; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; -import org.qortal.transform.Transformer; import org.qortal.transform.block.BlockTransformer; import com.google.common.primitives.Ints; public class GetBlockSummariesMessage extends Message { - private static final int BLOCK_SIGNATURE_LENGTH = BlockTransformer.BLOCK_SIGNATURE_LENGTH; - private byte[] parentSignature; private int numberRequested; public GetBlockSummariesMessage(byte[] parentSignature, int numberRequested) { - this(-1, parentSignature, numberRequested); + super(MessageType.GET_BLOCK_SUMMARIES); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(parentSignature); + + bytes.write(Ints.toByteArray(numberRequested)); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetBlockSummariesMessage(int id, byte[] parentSignature, int numberRequested) { @@ -36,11 +45,8 @@ public class GetBlockSummariesMessage extends Message { return this.numberRequested; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { - if (bytes.remaining() != BLOCK_SIGNATURE_LENGTH + Transformer.INT_LENGTH) - return null; - - byte[] parentSignature = new byte[BLOCK_SIGNATURE_LENGTH]; + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + byte[] parentSignature = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; bytes.get(parentSignature); int numberRequested = bytes.getInt(); @@ -48,19 +54,4 @@ public class GetBlockSummariesMessage extends Message { return new GetBlockSummariesMessage(id, parentSignature, numberRequested); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.parentSignature); - - bytes.write(Ints.toByteArray(this.numberRequested)); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java b/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java index 709f9782..fe6b5d72 100644 --- a/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java +++ b/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java @@ -7,7 +7,6 @@ import org.qortal.transform.Transformer; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.HashMap; @@ -24,11 +23,51 @@ import java.util.Map; * Also V2 only builds online accounts message once! */ public class GetOnlineAccountsV2Message extends Message { + private List onlineAccounts; - private byte[] cachedData; public GetOnlineAccountsV2Message(List onlineAccounts) { - this(-1, onlineAccounts); + super(MessageType.GET_ONLINE_ACCOUNTS_V2); + + // If we don't have ANY online accounts then it's an easier construction... + if (onlineAccounts.isEmpty()) { + // Always supply a number of accounts + this.dataBytes = Ints.toByteArray(0); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + return; + } + + // How many of each timestamp + Map countByTimestamp = new HashMap<>(); + + for (OnlineAccountData onlineAccountData : onlineAccounts) { + Long timestamp = onlineAccountData.getTimestamp(); + countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); + } + + // We should know exactly how many bytes to allocate now + int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) + + onlineAccounts.size() * Transformer.PUBLIC_KEY_LENGTH; + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); + + try { + for (long timestamp : countByTimestamp.keySet()) { + bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); + + bytes.write(Longs.toByteArray(timestamp)); + + for (OnlineAccountData onlineAccountData : onlineAccounts) { + if (onlineAccountData.getTimestamp() == timestamp) + bytes.write(onlineAccountData.getPublicKey()); + } + } + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetOnlineAccountsV2Message(int id, List onlineAccounts) { @@ -41,7 +80,7 @@ public class GetOnlineAccountsV2Message extends Message { return this.onlineAccounts; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { int accountCount = bytes.getInt(); List onlineAccounts = new ArrayList<>(accountCount); @@ -67,51 +106,4 @@ public class GetOnlineAccountsV2Message extends Message { return new GetOnlineAccountsV2Message(id, onlineAccounts); } - @Override - protected synchronized byte[] toData() { - if (this.cachedData != null) - return this.cachedData; - - // Shortcut in case we have no online accounts - if (this.onlineAccounts.isEmpty()) { - this.cachedData = Ints.toByteArray(0); - return this.cachedData; - } - - // How many of each timestamp - Map countByTimestamp = new HashMap<>(); - - for (int i = 0; i < this.onlineAccounts.size(); ++i) { - OnlineAccountData onlineAccountData = this.onlineAccounts.get(i); - Long timestamp = onlineAccountData.getTimestamp(); - countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); - } - - // We should know exactly how many bytes to allocate now - int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) - + this.onlineAccounts.size() * Transformer.PUBLIC_KEY_LENGTH; - - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); - - for (long timestamp : countByTimestamp.keySet()) { - bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); - - bytes.write(Longs.toByteArray(timestamp)); - - for (int i = 0; i < this.onlineAccounts.size(); ++i) { - OnlineAccountData onlineAccountData = this.onlineAccounts.get(i); - - if (onlineAccountData.getTimestamp() == timestamp) - bytes.write(onlineAccountData.getPublicKey()); - } - } - - this.cachedData = bytes.toByteArray(); - return this.cachedData; - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/GetPeersMessage.java b/src/main/java/org/qortal/network/message/GetPeersMessage.java index 21b06df5..b8f7e128 100644 --- a/src/main/java/org/qortal/network/message/GetPeersMessage.java +++ b/src/main/java/org/qortal/network/message/GetPeersMessage.java @@ -1,25 +1,21 @@ package org.qortal.network.message; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; public class GetPeersMessage extends Message { public GetPeersMessage() { - this(-1); + super(MessageType.GET_PEERS); + + this.dataBytes = EMPTY_DATA_BYTES; } private GetPeersMessage(int id) { super(id, MessageType.GET_PEERS); } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { return new GetPeersMessage(id); } - @Override - protected byte[] toData() { - return new byte[0]; - } - } diff --git a/src/main/java/org/qortal/network/message/GetSignaturesV2Message.java b/src/main/java/org/qortal/network/message/GetSignaturesV2Message.java index 2dc54365..0f88ba7d 100644 --- a/src/main/java/org/qortal/network/message/GetSignaturesV2Message.java +++ b/src/main/java/org/qortal/network/message/GetSignaturesV2Message.java @@ -2,24 +2,32 @@ package org.qortal.network.message; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; -import org.qortal.transform.Transformer; import org.qortal.transform.block.BlockTransformer; import com.google.common.primitives.Ints; public class GetSignaturesV2Message extends Message { - private static final int BLOCK_SIGNATURE_LENGTH = BlockTransformer.BLOCK_SIGNATURE_LENGTH; - private static final int NUMBER_REQUESTED_LENGTH = Transformer.INT_LENGTH; - private byte[] parentSignature; private int numberRequested; public GetSignaturesV2Message(byte[] parentSignature, int numberRequested) { - this(-1, parentSignature, numberRequested); + super(MessageType.GET_SIGNATURES_V2); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(parentSignature); + + bytes.write(Ints.toByteArray(numberRequested)); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetSignaturesV2Message(int id, byte[] parentSignature, int numberRequested) { @@ -37,11 +45,8 @@ public class GetSignaturesV2Message extends Message { return this.numberRequested; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { - if (bytes.remaining() != BLOCK_SIGNATURE_LENGTH + NUMBER_REQUESTED_LENGTH) - return null; - - byte[] parentSignature = new byte[BLOCK_SIGNATURE_LENGTH]; + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + byte[] parentSignature = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; bytes.get(parentSignature); int numberRequested = bytes.getInt(); @@ -49,19 +54,4 @@ public class GetSignaturesV2Message extends Message { return new GetSignaturesV2Message(id, parentSignature, numberRequested); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.parentSignature); - - bytes.write(Ints.toByteArray(this.numberRequested)); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/GetTradePresencesMessage.java b/src/main/java/org/qortal/network/message/GetTradePresencesMessage.java index d9be3c1b..7246c424 100644 --- a/src/main/java/org/qortal/network/message/GetTradePresencesMessage.java +++ b/src/main/java/org/qortal/network/message/GetTradePresencesMessage.java @@ -7,7 +7,6 @@ import org.qortal.transform.Transformer; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.HashMap; @@ -21,10 +20,48 @@ import java.util.Map; */ public class GetTradePresencesMessage extends Message { private List tradePresences; - private byte[] cachedData; public GetTradePresencesMessage(List tradePresences) { - this(-1, tradePresences); + super(MessageType.GET_TRADE_PRESENCES); + + // Shortcut in case we have no trade presences + if (tradePresences.isEmpty()) { + this.dataBytes = Ints.toByteArray(0); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + return; + } + + // How many of each timestamp + Map countByTimestamp = new HashMap<>(); + + for (TradePresenceData tradePresenceData : tradePresences) { + Long timestamp = tradePresenceData.getTimestamp(); + countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); + } + + // We should know exactly how many bytes to allocate now + int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) + + tradePresences.size() * Transformer.PUBLIC_KEY_LENGTH; + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); + + try { + for (long timestamp : countByTimestamp.keySet()) { + bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); + + bytes.write(Longs.toByteArray(timestamp)); + + for (TradePresenceData tradePresenceData : tradePresences) { + if (tradePresenceData.getTimestamp() == timestamp) + bytes.write(tradePresenceData.getPublicKey()); + } + } + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetTradePresencesMessage(int id, List tradePresences) { @@ -37,7 +74,7 @@ public class GetTradePresencesMessage extends Message { return this.tradePresences; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { int groupedEntriesCount = bytes.getInt(); List tradePresences = new ArrayList<>(groupedEntriesCount); @@ -63,48 +100,4 @@ public class GetTradePresencesMessage extends Message { return new GetTradePresencesMessage(id, tradePresences); } - @Override - protected synchronized byte[] toData() { - if (this.cachedData != null) - return this.cachedData; - - // Shortcut in case we have no trade presences - if (this.tradePresences.isEmpty()) { - this.cachedData = Ints.toByteArray(0); - return this.cachedData; - } - - // How many of each timestamp - Map countByTimestamp = new HashMap<>(); - - for (TradePresenceData tradePresenceData : this.tradePresences) { - Long timestamp = tradePresenceData.getTimestamp(); - countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); - } - - // We should know exactly how many bytes to allocate now - int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) - + this.tradePresences.size() * Transformer.PUBLIC_KEY_LENGTH; - - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); - - for (long timestamp : countByTimestamp.keySet()) { - bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); - - bytes.write(Longs.toByteArray(timestamp)); - - for (TradePresenceData tradePresenceData : this.tradePresences) { - if (tradePresenceData.getTimestamp() == timestamp) - bytes.write(tradePresenceData.getPublicKey()); - } - } - - this.cachedData = bytes.toByteArray(); - return this.cachedData; - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/GetTransactionMessage.java b/src/main/java/org/qortal/network/message/GetTransactionMessage.java index 2ea06580..fe0c750f 100644 --- a/src/main/java/org/qortal/network/message/GetTransactionMessage.java +++ b/src/main/java/org/qortal/network/message/GetTransactionMessage.java @@ -1,20 +1,19 @@ package org.qortal.network.message; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; +import java.util.Arrays; import org.qortal.transform.Transformer; public class GetTransactionMessage extends Message { - private static final int TRANSACTION_SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - private byte[] signature; public GetTransactionMessage(byte[] signature) { - this(-1, signature); + super(MessageType.GET_TRANSACTION); + + this.dataBytes = Arrays.copyOf(signature, signature.length); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private GetTransactionMessage(int id, byte[] signature) { @@ -27,28 +26,12 @@ public class GetTransactionMessage extends Message { return this.signature; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { - if (bytes.remaining() != TRANSACTION_SIGNATURE_LENGTH) - return null; - - byte[] signature = new byte[TRANSACTION_SIGNATURE_LENGTH]; + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; bytes.get(signature); return new GetTransactionMessage(id, signature); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(this.signature); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/GetUnconfirmedTransactionsMessage.java b/src/main/java/org/qortal/network/message/GetUnconfirmedTransactionsMessage.java index 18260568..fccd4c74 100644 --- a/src/main/java/org/qortal/network/message/GetUnconfirmedTransactionsMessage.java +++ b/src/main/java/org/qortal/network/message/GetUnconfirmedTransactionsMessage.java @@ -1,25 +1,21 @@ package org.qortal.network.message; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; public class GetUnconfirmedTransactionsMessage extends Message { public GetUnconfirmedTransactionsMessage() { - this(-1); + super(MessageType.GET_UNCONFIRMED_TRANSACTIONS); + + this.dataBytes = EMPTY_DATA_BYTES; } private GetUnconfirmedTransactionsMessage(int id) { super(id, MessageType.GET_UNCONFIRMED_TRANSACTIONS); } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { return new GetUnconfirmedTransactionsMessage(id); } - @Override - protected byte[] toData() { - return new byte[0]; - } - } diff --git a/src/main/java/org/qortal/network/message/GoodbyeMessage.java b/src/main/java/org/qortal/network/message/GoodbyeMessage.java index 75864060..74130be2 100644 --- a/src/main/java/org/qortal/network/message/GoodbyeMessage.java +++ b/src/main/java/org/qortal/network/message/GoodbyeMessage.java @@ -3,7 +3,6 @@ package org.qortal.network.message; import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; -import java.io.IOException; import java.nio.ByteBuffer; import java.util.Map; @@ -22,7 +21,7 @@ public class GoodbyeMessage extends Message { private static final Map map = stream(Reason.values()) .collect(toMap(reason -> reason.value, reason -> reason)); - private Reason(int value) { + Reason(int value) { this.value = value; } @@ -31,7 +30,14 @@ public class GoodbyeMessage extends Message { } } - private final Reason reason; + private Reason reason; + + public GoodbyeMessage(Reason reason) { + super(MessageType.GOODBYE); + + this.dataBytes = Ints.toByteArray(reason.value); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + } private GoodbyeMessage(int id, Reason reason) { super(id, MessageType.GOODBYE); @@ -39,27 +45,18 @@ public class GoodbyeMessage extends Message { this.reason = reason; } - public GoodbyeMessage(Reason reason) { - this(-1, reason); - } - public Reason getReason() { return this.reason; } - public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) { + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException { int reasonValue = byteBuffer.getInt(); Reason reason = Reason.valueOf(reasonValue); if (reason == null) - return null; + throw new MessageException("Invalid reason " + reasonValue + " in GOODBYE message"); return new GoodbyeMessage(id, reason); } - @Override - protected byte[] toData() throws IOException { - return Ints.toByteArray(this.reason.value); - } - } diff --git a/src/main/java/org/qortal/network/message/HeightV2Message.java b/src/main/java/org/qortal/network/message/HeightV2Message.java index 4d6f3f21..0e775a84 100644 --- a/src/main/java/org/qortal/network/message/HeightV2Message.java +++ b/src/main/java/org/qortal/network/message/HeightV2Message.java @@ -2,7 +2,6 @@ package org.qortal.network.message; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import org.qortal.transform.Transformer; @@ -19,7 +18,24 @@ public class HeightV2Message extends Message { private byte[] minterPublicKey; public HeightV2Message(int height, byte[] signature, long timestamp, byte[] minterPublicKey) { - this(-1, height, signature, timestamp, minterPublicKey); + super(MessageType.HEIGHT_V2); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(Ints.toByteArray(height)); + + bytes.write(signature); + + bytes.write(Longs.toByteArray(timestamp)); + + bytes.write(minterPublicKey); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private HeightV2Message(int id, int height, byte[] signature, long timestamp, byte[] minterPublicKey) { @@ -47,7 +63,7 @@ public class HeightV2Message extends Message { return this.minterPublicKey; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { int height = bytes.getInt(); byte[] signature = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; @@ -61,23 +77,4 @@ public class HeightV2Message extends Message { return new HeightV2Message(id, height, signature, timestamp, minterPublicKey); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(Ints.toByteArray(this.height)); - - bytes.write(this.signature); - - bytes.write(Longs.toByteArray(this.timestamp)); - - bytes.write(this.minterPublicKey); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/HelloMessage.java b/src/main/java/org/qortal/network/message/HelloMessage.java index 1b6de17d..30b7d9be 100644 --- a/src/main/java/org/qortal/network/message/HelloMessage.java +++ b/src/main/java/org/qortal/network/message/HelloMessage.java @@ -11,9 +11,28 @@ import com.google.common.primitives.Longs; public class HelloMessage extends Message { - private final long timestamp; - private final String versionString; - private final String senderPeerAddress; + private long timestamp; + private String versionString; + private String senderPeerAddress; + + public HelloMessage(long timestamp, String versionString, String senderPeerAddress) { + super(MessageType.HELLO); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(Longs.toByteArray(timestamp)); + + Serialization.serializeSizedString(bytes, versionString); + + Serialization.serializeSizedString(bytes, senderPeerAddress); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + } private HelloMessage(int id, long timestamp, String versionString, String senderPeerAddress) { super(id, MessageType.HELLO); @@ -23,10 +42,6 @@ public class HelloMessage extends Message { this.senderPeerAddress = senderPeerAddress; } - public HelloMessage(long timestamp, String versionString, String senderPeerAddress) { - this(-1, timestamp, versionString, senderPeerAddress); - } - public long getTimestamp() { return this.timestamp; } @@ -39,31 +54,23 @@ public class HelloMessage extends Message { return this.senderPeerAddress; } - public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws TransformationException { + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException { long timestamp = byteBuffer.getLong(); - String versionString = Serialization.deserializeSizedString(byteBuffer, 255); - - // Sender peer address added in v3.0, so is an optional field. Older versions won't send it. + String versionString; String senderPeerAddress = null; - if (byteBuffer.hasRemaining()) { - senderPeerAddress = Serialization.deserializeSizedString(byteBuffer, 255); + try { + versionString = Serialization.deserializeSizedString(byteBuffer, 255); + + // Sender peer address added in v3.0, so is an optional field. Older versions won't send it. + if (byteBuffer.hasRemaining()) { + senderPeerAddress = Serialization.deserializeSizedString(byteBuffer, 255); + } + } catch (TransformationException e) { + throw new MessageException(e.getMessage(), e); } return new HelloMessage(id, timestamp, versionString, senderPeerAddress); } - @Override - protected byte[] toData() throws IOException { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(Longs.toByteArray(this.timestamp)); - - Serialization.serializeSizedString(bytes, this.versionString); - - Serialization.serializeSizedString(bytes, this.senderPeerAddress); - - return bytes.toByteArray(); - } - } diff --git a/src/main/java/org/qortal/network/message/Message.java b/src/main/java/org/qortal/network/message/Message.java index 094c1143..e92aca89 100644 --- a/src/main/java/org/qortal/network/message/Message.java +++ b/src/main/java/org/qortal/network/message/Message.java @@ -1,160 +1,67 @@ package org.qortal.network.message; -import java.util.Map; - import org.qortal.crypto.Crypto; import org.qortal.network.Network; -import org.qortal.transform.TransformationException; import com.google.common.primitives.Ints; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; - import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.util.Arrays; +/** + * Network message for sending over network, or unpacked data received from network. + *

+ *

+ * For messages received from network, subclass's {@code fromByteBuffer()} method is used + * to construct a subclassed instance. Original bytes from network are not retained. + * Access to deserialized data should be via subclass's getters. Ideally there should be NO setters! + *

+ *

+ *

+ * Each subclass's public constructor is for building a message to send only. + * The constructor will serialize into byte form but not store the passed args. + * Serialized bytes are saved into superclass (Message) {@code dataBytes} and, if not empty, + * a checksum is created and saved into {@code checksumBytes}. + * Therefore: do not use subclass's getters after using constructor! + *

+ *

+ *

+ * For subclasses where outgoing versions might be usefully cached, they can implement Clonable + * as long if they are safe to use {@link Object#clone()}. + *

+ */ public abstract class Message { // MAGIC(4) + TYPE(4) + HAS-ID(1) + ID?(4) + DATA-SIZE(4) + CHECKSUM?(4) + DATA?(*) private static final int MAGIC_LENGTH = 4; + private static final int TYPE_LENGTH = 4; + private static final int HAS_ID_LENGTH = 1; + private static final int ID_LENGTH = 4; + private static final int DATA_SIZE_LENGTH = 4; private static final int CHECKSUM_LENGTH = 4; private static final int MAX_DATA_SIZE = 10 * 1024 * 1024; // 10MB - @SuppressWarnings("serial") - public static class MessageException extends Exception { - public MessageException() { - } + protected static final byte[] EMPTY_DATA_BYTES = new byte[0]; - public MessageException(String message) { - super(message); - } + protected int id; + protected final MessageType type; - public MessageException(String message, Throwable cause) { - super(message, cause); - } - - public MessageException(Throwable cause) { - super(cause); - } - } - - public enum MessageType { - // Handshaking - HELLO(0), - GOODBYE(1), - CHALLENGE(2), - RESPONSE(3), - - // Status / notifications - HEIGHT_V2(10), - PING(11), - PONG(12), - - // Requesting data - PEERS_V2(20), - GET_PEERS(21), - - TRANSACTION(30), - GET_TRANSACTION(31), - - TRANSACTION_SIGNATURES(40), - GET_UNCONFIRMED_TRANSACTIONS(41), - - BLOCK(50), - GET_BLOCK(51), - - SIGNATURES(60), - GET_SIGNATURES_V2(61), - - BLOCK_SUMMARIES(70), - GET_BLOCK_SUMMARIES(71), - - ONLINE_ACCOUNTS_V2(82), - GET_ONLINE_ACCOUNTS_V2(83), - ONLINE_ACCOUNTS_V3(84), - - ARBITRARY_DATA(90), - GET_ARBITRARY_DATA(91), - - BLOCKS(100), - GET_BLOCKS(101), - - ARBITRARY_DATA_FILE(110), - GET_ARBITRARY_DATA_FILE(111), - - ARBITRARY_DATA_FILE_LIST(120), - GET_ARBITRARY_DATA_FILE_LIST(121), - - ARBITRARY_SIGNATURES(130), - - TRADE_PRESENCES(140), - GET_TRADE_PRESENCES(141), - - ARBITRARY_METADATA(150), - GET_ARBITRARY_METADATA(151); - - public final int value; - public final Method fromByteBufferMethod; - - private static final Map map = stream(MessageType.values()) - .collect(toMap(messageType -> messageType.value, messageType -> messageType)); - - private MessageType(int value) { - this.value = value; - - String[] classNameParts = this.name().toLowerCase().split("_"); - - for (int i = 0; i < classNameParts.length; ++i) - classNameParts[i] = classNameParts[i].substring(0, 1).toUpperCase().concat(classNameParts[i].substring(1)); - - String className = String.join("", classNameParts); - - Method method; - try { - Class subclass = Class.forName(String.join("", Message.class.getPackage().getName(), ".", className, "Message")); - - method = subclass.getDeclaredMethod("fromByteBuffer", int.class, ByteBuffer.class); - } catch (ClassNotFoundException | NoSuchMethodException | SecurityException e) { - method = null; - } - - this.fromByteBufferMethod = method; - } - - public static MessageType valueOf(int value) { - return map.get(value); - } - - public Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException { - if (this.fromByteBufferMethod == null) - throw new MessageException("Unsupported message type [" + value + "] during conversion from bytes"); - - try { - return (Message) this.fromByteBufferMethod.invoke(null, id, byteBuffer); - } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { - if (e.getCause() instanceof BufferUnderflowException) - throw new MessageException("Byte data too short for " + name() + " message"); - - throw new MessageException("Internal error with " + name() + " message during conversion from bytes"); - } - } - } - - private int id; - private MessageType type; + /** Serialized outgoing message data. Expected to be written to by subclass. */ + protected byte[] dataBytes; + /** Serialized outgoing message checksum. Expected to be written to by subclass. */ + protected byte[] checksumBytes; + /** Typically called by subclass when constructing message from received network data. */ protected Message(int id, MessageType type) { this.id = id; this.type = type; } + /** Typically called by subclass when constructing outgoing message. */ protected Message(MessageType type) { this(-1, type); } @@ -178,9 +85,9 @@ public abstract class Message { /** * Attempt to read a message from byte buffer. * - * @param readOnlyBuffer + * @param readOnlyBuffer ByteBuffer containing bytes read from network * @return null if no complete message can be read - * @throws MessageException + * @throws MessageException if message could not be decoded or is invalid */ public static Message fromByteBuffer(ByteBuffer readOnlyBuffer) throws MessageException { try { @@ -255,9 +162,27 @@ public abstract class Message { return Arrays.copyOfRange(Crypto.digest(dataBuffer), 0, CHECKSUM_LENGTH); } + public void checkValidOutgoing() throws MessageException { + // We expect subclass to have initialized these + if (this.dataBytes == null) + throw new MessageException("Missing data payload"); + if (this.dataBytes.length > 0 && this.checksumBytes == null) + throw new MessageException("Missing data checksum"); + } + public byte[] toBytes() throws MessageException { + checkValidOutgoing(); + + // We can calculate exact length + int messageLength = MAGIC_LENGTH + TYPE_LENGTH + HAS_ID_LENGTH; + messageLength += this.hasId() ? ID_LENGTH : 0; + messageLength += DATA_SIZE_LENGTH + this.dataBytes.length > 0 ? CHECKSUM_LENGTH + this.dataBytes.length : 0; + + if (messageLength > MAX_DATA_SIZE) + throw new MessageException(String.format("About to send message with length %d larger than allowed %d", messageLength, MAX_DATA_SIZE)); + try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(256); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(messageLength); // Magic bytes.write(Network.getInstance().getMessageMagic()); @@ -272,26 +197,30 @@ public abstract class Message { bytes.write(0); } - byte[] data = this.toData(); - if (data == null) - throw new MessageException("Missing data payload"); + bytes.write(Ints.toByteArray(this.dataBytes.length)); - bytes.write(Ints.toByteArray(data.length)); - - if (data.length > 0) { - bytes.write(generateChecksum(data)); - bytes.write(data); + if (this.dataBytes.length > 0) { + bytes.write(this.checksumBytes); + bytes.write(this.dataBytes); } - if (bytes.size() > MAX_DATA_SIZE) - throw new MessageException(String.format("About to send message with length %d larger than allowed %d", bytes.size(), MAX_DATA_SIZE)); - return bytes.toByteArray(); - } catch (IOException | TransformationException e) { + } catch (IOException e) { throw new MessageException("Failed to serialize message", e); } } - protected abstract byte[] toData() throws IOException, TransformationException; + public static M cloneWithNewId(M message, int newId) { + M clone; + + try { + clone = (M) message.clone(); + } catch (CloneNotSupportedException e) { + throw new UnsupportedOperationException("Message sub-class not cloneable"); + } + + clone.setId(newId); + return clone; + } } diff --git a/src/main/java/org/qortal/network/message/MessageException.java b/src/main/java/org/qortal/network/message/MessageException.java new file mode 100644 index 00000000..97e8d0be --- /dev/null +++ b/src/main/java/org/qortal/network/message/MessageException.java @@ -0,0 +1,19 @@ +package org.qortal.network.message; + +@SuppressWarnings("serial") +public class MessageException extends Exception { + public MessageException() { + } + + public MessageException(String message) { + super(message); + } + + public MessageException(String message, Throwable cause) { + super(message, cause); + } + + public MessageException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/org/qortal/network/message/MessageProducer.java b/src/main/java/org/qortal/network/message/MessageProducer.java new file mode 100644 index 00000000..7f203788 --- /dev/null +++ b/src/main/java/org/qortal/network/message/MessageProducer.java @@ -0,0 +1,8 @@ +package org.qortal.network.message; + +import java.nio.ByteBuffer; + +@FunctionalInterface +public interface MessageProducer { + Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException; +} diff --git a/src/main/java/org/qortal/network/message/MessageType.java b/src/main/java/org/qortal/network/message/MessageType.java new file mode 100644 index 00000000..48039a4d --- /dev/null +++ b/src/main/java/org/qortal/network/message/MessageType.java @@ -0,0 +1,96 @@ +package org.qortal.network.message; + +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.util.Map; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +public enum MessageType { + // Handshaking + HELLO(0, HelloMessage::fromByteBuffer), + GOODBYE(1, GoodbyeMessage::fromByteBuffer), + CHALLENGE(2, ChallengeMessage::fromByteBuffer), + RESPONSE(3, ResponseMessage::fromByteBuffer), + + // Status / notifications + HEIGHT_V2(10, HeightV2Message::fromByteBuffer), + PING(11, PingMessage::fromByteBuffer), + PONG(12, PongMessage::fromByteBuffer), + + // Requesting data + PEERS_V2(20, PeersV2Message::fromByteBuffer), + GET_PEERS(21, GetPeersMessage::fromByteBuffer), + + TRANSACTION(30, TransactionMessage::fromByteBuffer), + GET_TRANSACTION(31, GetTransactionMessage::fromByteBuffer), + + TRANSACTION_SIGNATURES(40, TransactionSignaturesMessage::fromByteBuffer), + GET_UNCONFIRMED_TRANSACTIONS(41, GetUnconfirmedTransactionsMessage::fromByteBuffer), + + BLOCK(50, BlockMessage::fromByteBuffer), + GET_BLOCK(51, GetBlockMessage::fromByteBuffer), + + SIGNATURES(60, SignaturesMessage::fromByteBuffer), + GET_SIGNATURES_V2(61, GetSignaturesV2Message::fromByteBuffer), + + BLOCK_SUMMARIES(70, BlockSummariesMessage::fromByteBuffer), + GET_BLOCK_SUMMARIES(71, GetBlockSummariesMessage::fromByteBuffer), + + ONLINE_ACCOUNTS(80, OnlineAccountsMessage::fromByteBuffer), + GET_ONLINE_ACCOUNTS(81, GetOnlineAccountsMessage::fromByteBuffer), + ONLINE_ACCOUNTS_V2(82, OnlineAccountsV2Message::fromByteBuffer), + GET_ONLINE_ACCOUNTS_V2(83, GetOnlineAccountsV2Message::fromByteBuffer), + + ARBITRARY_DATA(90, ArbitraryDataMessage::fromByteBuffer), + GET_ARBITRARY_DATA(91, GetArbitraryDataMessage::fromByteBuffer), + + BLOCKS(100, null), // unsupported + GET_BLOCKS(101, null), // unsupported + + ARBITRARY_DATA_FILE(110, ArbitraryDataFileMessage::fromByteBuffer), + GET_ARBITRARY_DATA_FILE(111, GetArbitraryDataFileMessage::fromByteBuffer), + + ARBITRARY_DATA_FILE_LIST(120, ArbitraryDataFileListMessage::fromByteBuffer), + GET_ARBITRARY_DATA_FILE_LIST(121, GetArbitraryDataFileListMessage::fromByteBuffer), + + ARBITRARY_SIGNATURES(130, ArbitrarySignaturesMessage::fromByteBuffer), + + TRADE_PRESENCES(140, TradePresencesMessage::fromByteBuffer), + GET_TRADE_PRESENCES(141, GetTradePresencesMessage::fromByteBuffer), + + ARBITRARY_METADATA(150, ArbitraryMetadataMessage::fromByteBuffer), + GET_ARBITRARY_METADATA(151, GetArbitraryMetadataMessage::fromByteBuffer); + + public final int value; + public final MessageProducer fromByteBufferMethod; + + private static final Map map = stream(MessageType.values()) + .collect(toMap(messageType -> messageType.value, messageType -> messageType)); + + MessageType(int value, MessageProducer fromByteBufferMethod) { + this.value = value; + this.fromByteBufferMethod = fromByteBufferMethod; + } + + public static MessageType valueOf(int value) { + return map.get(value); + } + + /** + * Attempt to read a message from byte buffer. + * + * @param id message ID or -1 + * @param byteBuffer ByteBuffer source for message + * @return null if no complete message can be read + * @throws MessageException if message could not be decoded or is invalid + * @throws BufferUnderflowException if not enough bytes in buffer to read message + */ + public Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException { + if (this.fromByteBufferMethod == null) + throw new MessageException("Message type " + this.name() + " unsupported"); + + return this.fromByteBufferMethod.fromByteBuffer(id, byteBuffer); + } +} diff --git a/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java b/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java index f0fce81e..6803e3bf 100644 --- a/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java +++ b/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java @@ -7,13 +7,11 @@ import org.qortal.transform.Transformer; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; /** * For sending online accounts info to remote peer. @@ -25,11 +23,52 @@ import java.util.stream.Collectors; * Also V2 only builds online accounts message once! */ public class OnlineAccountsV2Message extends Message { + private List onlineAccounts; - private byte[] cachedData; public OnlineAccountsV2Message(List onlineAccounts) { - this(-1, onlineAccounts); + super(MessageType.ONLINE_ACCOUNTS_V2); + + // Shortcut in case we have no online accounts + if (onlineAccounts.isEmpty()) { + this.dataBytes = Ints.toByteArray(0); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + return; + } + + // How many of each timestamp + Map countByTimestamp = new HashMap<>(); + + for (OnlineAccountData onlineAccountData : onlineAccounts) { + Long timestamp = onlineAccountData.getTimestamp(); + countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); + } + + // We should know exactly how many bytes to allocate now + int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) + + onlineAccounts.size() * (Transformer.SIGNATURE_LENGTH + Transformer.PUBLIC_KEY_LENGTH); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); + + try { + for (long timestamp : countByTimestamp.keySet()) { + bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); + + bytes.write(Longs.toByteArray(timestamp)); + + for (OnlineAccountData onlineAccountData : onlineAccounts) { + if (onlineAccountData.getTimestamp() == timestamp) { + bytes.write(onlineAccountData.getSignature()); + bytes.write(onlineAccountData.getPublicKey()); + } + } + } + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private OnlineAccountsV2Message(int id, List onlineAccounts) { @@ -42,7 +81,7 @@ public class OnlineAccountsV2Message extends Message { return this.onlineAccounts; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException { int accountCount = bytes.getInt(); List onlineAccounts = new ArrayList<>(accountCount); @@ -71,54 +110,4 @@ public class OnlineAccountsV2Message extends Message { return new OnlineAccountsV2Message(id, onlineAccounts); } - @Override - protected synchronized byte[] toData() { - if (this.cachedData != null) - return this.cachedData; - - // Shortcut in case we have no online accounts - if (this.onlineAccounts.isEmpty()) { - this.cachedData = Ints.toByteArray(0); - return this.cachedData; - } - - // How many of each timestamp - Map countByTimestamp = new HashMap<>(); - - for (int i = 0; i < this.onlineAccounts.size(); ++i) { - OnlineAccountData onlineAccountData = this.onlineAccounts.get(i); - Long timestamp = onlineAccountData.getTimestamp(); - countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); - } - - // We should know exactly how many bytes to allocate now - int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) - + this.onlineAccounts.size() * (Transformer.SIGNATURE_LENGTH + Transformer.PUBLIC_KEY_LENGTH); - - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); - - for (long timestamp : countByTimestamp.keySet()) { - bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); - - bytes.write(Longs.toByteArray(timestamp)); - - for (int i = 0; i < this.onlineAccounts.size(); ++i) { - OnlineAccountData onlineAccountData = this.onlineAccounts.get(i); - - if (onlineAccountData.getTimestamp() == timestamp) { - bytes.write(onlineAccountData.getSignature()); - - bytes.write(onlineAccountData.getPublicKey()); - } - } - } - - this.cachedData = bytes.toByteArray(); - return this.cachedData; - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/PeersV2Message.java b/src/main/java/org/qortal/network/message/PeersV2Message.java index bfea87c7..e844246f 100644 --- a/src/main/java/org/qortal/network/message/PeersV2Message.java +++ b/src/main/java/org/qortal/network/message/PeersV2Message.java @@ -2,7 +2,6 @@ package org.qortal.network.message; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -19,7 +18,35 @@ public class PeersV2Message extends Message { private List peerAddresses; public PeersV2Message(List peerAddresses) { - this(-1, peerAddresses); + super(MessageType.PEERS_V2); + + List addresses = new ArrayList<>(); + + // First entry represents sending node but contains only port number with empty address. + addresses.add(("0.0.0.0:" + Settings.getInstance().getListenPort()).getBytes(StandardCharsets.UTF_8)); + + for (PeerAddress peerAddress : peerAddresses) + addresses.add(peerAddress.toString().getBytes(StandardCharsets.UTF_8)); + + // We can't send addresses that are longer than 255 bytes as length itself is encoded in one byte. + addresses.removeIf(addressString -> addressString.length > 255); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + // Number of entries + bytes.write(Ints.toByteArray(addresses.size())); + + for (byte[] address : addresses) { + bytes.write(address.length); + bytes.write(address); + } + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private PeersV2Message(int id, List peerAddresses) { @@ -32,7 +59,7 @@ public class PeersV2Message extends Message { return this.peerAddresses; } - public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException { // Read entry count int count = byteBuffer.getInt(); @@ -49,43 +76,11 @@ public class PeersV2Message extends Message { PeerAddress peerAddress = PeerAddress.fromString(addressString); peerAddresses.add(peerAddress); } catch (IllegalArgumentException e) { - // Not valid - ignore + throw new MessageException("Invalid peer address in received PEERS_V2 message"); } } return new PeersV2Message(id, peerAddresses); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - List addresses = new ArrayList<>(); - - // First entry represents sending node but contains only port number with empty address. - addresses.add(("0.0.0.0:" + Settings.getInstance().getListenPort()).getBytes(StandardCharsets.UTF_8)); - - for (PeerAddress peerAddress : this.peerAddresses) - addresses.add(peerAddress.toString().getBytes(StandardCharsets.UTF_8)); - - // We can't send addresses that are longer than 255 bytes as length itself is encoded in one byte. - addresses.removeIf(addressString -> addressString.length > 255); - - // Serialize - - // Number of entries - bytes.write(Ints.toByteArray(addresses.size())); - - for (byte[] address : addresses) { - bytes.write(address.length); - bytes.write(address); - } - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/PingMessage.java b/src/main/java/org/qortal/network/message/PingMessage.java index ddec0fd7..0b66d507 100644 --- a/src/main/java/org/qortal/network/message/PingMessage.java +++ b/src/main/java/org/qortal/network/message/PingMessage.java @@ -1,25 +1,21 @@ package org.qortal.network.message; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; public class PingMessage extends Message { public PingMessage() { - this(-1); + super(MessageType.PING); + + this.dataBytes = EMPTY_DATA_BYTES; } private PingMessage(int id) { super(id, MessageType.PING); } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { return new PingMessage(id); } - @Override - protected byte[] toData() { - return new byte[0]; - } - } diff --git a/src/main/java/org/qortal/network/message/PongMessage.java b/src/main/java/org/qortal/network/message/PongMessage.java new file mode 100644 index 00000000..4e73c07c --- /dev/null +++ b/src/main/java/org/qortal/network/message/PongMessage.java @@ -0,0 +1,21 @@ +package org.qortal.network.message; + +import java.nio.ByteBuffer; + +public class PongMessage extends Message { + + public PongMessage() { + super(MessageType.PONG); + + this.dataBytes = EMPTY_DATA_BYTES; + } + + private PongMessage(int id) { + super(id, MessageType.PONG); + } + + public static Message fromByteBuffer(int id, ByteBuffer bytes) { + return new PongMessage(id); + } + +} diff --git a/src/main/java/org/qortal/network/message/ResponseMessage.java b/src/main/java/org/qortal/network/message/ResponseMessage.java index 6fed6d6a..292fe697 100644 --- a/src/main/java/org/qortal/network/message/ResponseMessage.java +++ b/src/main/java/org/qortal/network/message/ResponseMessage.java @@ -10,8 +10,25 @@ public class ResponseMessage extends Message { public static final int DATA_LENGTH = 32; - private final int nonce; - private final byte[] data; + private int nonce; + private byte[] data; + + public ResponseMessage(int nonce, byte[] data) { + super(MessageType.RESPONSE); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(4 + DATA_LENGTH); + + try { + bytes.write(Ints.toByteArray(nonce)); + + bytes.write(data); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + } private ResponseMessage(int id, int nonce, byte[] data) { super(id, MessageType.RESPONSE); @@ -20,10 +37,6 @@ public class ResponseMessage extends Message { this.data = data; } - public ResponseMessage(int nonce, byte[] data) { - this(-1, nonce, data); - } - public int getNonce() { return this.nonce; } @@ -41,15 +54,4 @@ public class ResponseMessage extends Message { return new ResponseMessage(id, nonce, data); } - @Override - protected byte[] toData() throws IOException { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(4 + DATA_LENGTH); - - bytes.write(Ints.toByteArray(this.nonce)); - - bytes.write(data); - - return bytes.toByteArray(); - } - } diff --git a/src/main/java/org/qortal/network/message/SignaturesMessage.java b/src/main/java/org/qortal/network/message/SignaturesMessage.java index 008f4c1a..c0b44fcd 100644 --- a/src/main/java/org/qortal/network/message/SignaturesMessage.java +++ b/src/main/java/org/qortal/network/message/SignaturesMessage.java @@ -2,7 +2,7 @@ package org.qortal.network.message; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; @@ -13,12 +13,24 @@ import com.google.common.primitives.Ints; public class SignaturesMessage extends Message { - private static final int BLOCK_SIGNATURE_LENGTH = BlockTransformer.BLOCK_SIGNATURE_LENGTH; - private List signatures; public SignaturesMessage(List signatures) { - this(-1, signatures); + super(MessageType.SIGNATURES); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(Ints.toByteArray(signatures.size())); + + for (byte[] signature : signatures) + bytes.write(signature); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private SignaturesMessage(int id, List signatures) { @@ -31,15 +43,15 @@ public class SignaturesMessage extends Message { return this.signatures; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { int count = bytes.getInt(); - if (bytes.remaining() != count * BLOCK_SIGNATURE_LENGTH) - return null; + if (bytes.remaining() < count * BlockTransformer.BLOCK_SIGNATURE_LENGTH) + throw new BufferUnderflowException(); List signatures = new ArrayList<>(); for (int i = 0; i < count; ++i) { - byte[] signature = new byte[BLOCK_SIGNATURE_LENGTH]; + byte[] signature = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; bytes.get(signature); signatures.add(signature); } @@ -47,20 +59,4 @@ public class SignaturesMessage extends Message { return new SignaturesMessage(id, signatures); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(Ints.toByteArray(this.signatures.size())); - - for (byte[] signature : this.signatures) - bytes.write(signature); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/TradePresencesMessage.java b/src/main/java/org/qortal/network/message/TradePresencesMessage.java index 9d846722..8d7da156 100644 --- a/src/main/java/org/qortal/network/message/TradePresencesMessage.java +++ b/src/main/java/org/qortal/network/message/TradePresencesMessage.java @@ -8,7 +8,6 @@ import org.qortal.utils.Base58; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.HashMap; @@ -21,11 +20,55 @@ import java.util.Map; * Groups of: number of entries, timestamp, then pubkey + sig + AT address for each entry. */ public class TradePresencesMessage extends Message { + private List tradePresences; - private byte[] cachedData; public TradePresencesMessage(List tradePresences) { - this(-1, tradePresences); + super(MessageType.TRADE_PRESENCES); + + // Shortcut in case we have no trade presences + if (tradePresences.isEmpty()) { + this.dataBytes = Ints.toByteArray(0); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + return; + } + + // How many of each timestamp + Map countByTimestamp = new HashMap<>(); + + for (TradePresenceData tradePresenceData : tradePresences) { + Long timestamp = tradePresenceData.getTimestamp(); + countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); + } + + // We should know exactly how many bytes to allocate now + int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) + + tradePresences.size() * (Transformer.PUBLIC_KEY_LENGTH + Transformer.SIGNATURE_LENGTH + Transformer.ADDRESS_LENGTH); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); + + try { + for (long timestamp : countByTimestamp.keySet()) { + bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); + + bytes.write(Longs.toByteArray(timestamp)); + + for (TradePresenceData tradePresenceData : tradePresences) { + if (tradePresenceData.getTimestamp() == timestamp) { + bytes.write(tradePresenceData.getPublicKey()); + + bytes.write(tradePresenceData.getSignature()); + + bytes.write(Base58.decode(tradePresenceData.getAtAddress())); + } + } + } + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private TradePresencesMessage(int id, List tradePresences) { @@ -38,7 +81,7 @@ public class TradePresencesMessage extends Message { return this.tradePresences; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { int groupedEntriesCount = bytes.getInt(); List tradePresences = new ArrayList<>(groupedEntriesCount); @@ -71,53 +114,4 @@ public class TradePresencesMessage extends Message { return new TradePresencesMessage(id, tradePresences); } - @Override - protected synchronized byte[] toData() { - if (this.cachedData != null) - return this.cachedData; - - // Shortcut in case we have no trade presences - if (this.tradePresences.isEmpty()) { - this.cachedData = Ints.toByteArray(0); - return this.cachedData; - } - - // How many of each timestamp - Map countByTimestamp = new HashMap<>(); - - for (TradePresenceData tradePresenceData : this.tradePresences) { - Long timestamp = tradePresenceData.getTimestamp(); - countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); - } - - // We should know exactly how many bytes to allocate now - int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) - + this.tradePresences.size() * (Transformer.PUBLIC_KEY_LENGTH + Transformer.SIGNATURE_LENGTH + Transformer.ADDRESS_LENGTH); - - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); - - for (long timestamp : countByTimestamp.keySet()) { - bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); - - bytes.write(Longs.toByteArray(timestamp)); - - for (TradePresenceData tradePresenceData : this.tradePresences) { - if (tradePresenceData.getTimestamp() == timestamp) { - bytes.write(tradePresenceData.getPublicKey()); - - bytes.write(tradePresenceData.getSignature()); - - bytes.write(Base58.decode(tradePresenceData.getAtAddress())); - } - } - } - - this.cachedData = bytes.toByteArray(); - return this.cachedData; - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/message/TransactionMessage.java b/src/main/java/org/qortal/network/message/TransactionMessage.java index 92cce086..51db6cf9 100644 --- a/src/main/java/org/qortal/network/message/TransactionMessage.java +++ b/src/main/java/org/qortal/network/message/TransactionMessage.java @@ -1,6 +1,5 @@ package org.qortal.network.message; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import org.qortal.data.transaction.TransactionData; @@ -11,8 +10,11 @@ public class TransactionMessage extends Message { private TransactionData transactionData; - public TransactionMessage(TransactionData transactionData) { - this(-1, transactionData); + public TransactionMessage(TransactionData transactionData) throws TransformationException { + super(MessageType.TRANSACTION); + + this.dataBytes = TransactionTransformer.toBytes(transactionData); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private TransactionMessage(int id, TransactionData transactionData) { @@ -25,26 +27,16 @@ public class TransactionMessage extends Message { return this.transactionData; } - public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws UnsupportedEncodingException { - try { - TransactionData transactionData = TransactionTransformer.fromByteBuffer(byteBuffer); - - return new TransactionMessage(id, transactionData); - } catch (TransformationException e) { - return null; - } - } - - @Override - protected byte[] toData() { - if (this.transactionData == null) - return null; + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws MessageException { + TransactionData transactionData; try { - return TransactionTransformer.toBytes(this.transactionData); + transactionData = TransactionTransformer.fromByteBuffer(byteBuffer); } catch (TransformationException e) { - return null; + throw new MessageException(e.getMessage(), e); } + + return new TransactionMessage(id, transactionData); } } diff --git a/src/main/java/org/qortal/network/message/TransactionSignaturesMessage.java b/src/main/java/org/qortal/network/message/TransactionSignaturesMessage.java index 082a7187..395d3f00 100644 --- a/src/main/java/org/qortal/network/message/TransactionSignaturesMessage.java +++ b/src/main/java/org/qortal/network/message/TransactionSignaturesMessage.java @@ -2,7 +2,7 @@ package org.qortal.network.message; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; @@ -13,12 +13,24 @@ import com.google.common.primitives.Ints; public class TransactionSignaturesMessage extends Message { - private static final int SIGNATURE_LENGTH = Transformer.SIGNATURE_LENGTH; - private List signatures; public TransactionSignaturesMessage(List signatures) { - this(-1, signatures); + super(MessageType.TRANSACTION_SIGNATURES); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(Ints.toByteArray(signatures.size())); + + for (byte[] signature : signatures) + bytes.write(signature); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); } private TransactionSignaturesMessage(int id, List signatures) { @@ -31,15 +43,15 @@ public class TransactionSignaturesMessage extends Message { return this.signatures; } - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException { + public static Message fromByteBuffer(int id, ByteBuffer bytes) { int count = bytes.getInt(); - if (bytes.remaining() != count * SIGNATURE_LENGTH) - return null; + if (bytes.remaining() < count * Transformer.SIGNATURE_LENGTH) + throw new BufferUnderflowException(); List signatures = new ArrayList<>(); for (int i = 0; i < count; ++i) { - byte[] signature = new byte[SIGNATURE_LENGTH]; + byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; bytes.get(signature); signatures.add(signature); } @@ -47,20 +59,4 @@ public class TransactionSignaturesMessage extends Message { return new TransactionSignaturesMessage(id, signatures); } - @Override - protected byte[] toData() { - try { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - bytes.write(Ints.toByteArray(this.signatures.size())); - - for (byte[] signature : this.signatures) - bytes.write(signature); - - return bytes.toByteArray(); - } catch (IOException e) { - return null; - } - } - } diff --git a/src/main/java/org/qortal/network/task/BroadcastTask.java b/src/main/java/org/qortal/network/task/BroadcastTask.java new file mode 100644 index 00000000..5714ebf6 --- /dev/null +++ b/src/main/java/org/qortal/network/task/BroadcastTask.java @@ -0,0 +1,22 @@ +package org.qortal.network.task; + +import org.qortal.controller.Controller; +import org.qortal.network.Network; +import org.qortal.network.Peer; +import org.qortal.network.message.Message; +import org.qortal.utils.ExecuteProduceConsume.Task; + +public class BroadcastTask implements Task { + public BroadcastTask() { + } + + @Override + public String getName() { + return "BroadcastTask"; + } + + @Override + public void perform() throws InterruptedException { + Controller.getInstance().doNetworkBroadcast(); + } +} diff --git a/src/main/java/org/qortal/network/task/ChannelAcceptTask.java b/src/main/java/org/qortal/network/task/ChannelAcceptTask.java new file mode 100644 index 00000000..3e2a3033 --- /dev/null +++ b/src/main/java/org/qortal/network/task/ChannelAcceptTask.java @@ -0,0 +1,97 @@ +package org.qortal.network.task; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.network.Network; +import org.qortal.network.Peer; +import org.qortal.network.PeerAddress; +import org.qortal.settings.Settings; +import org.qortal.utils.ExecuteProduceConsume.Task; +import org.qortal.utils.NTP; + +import java.io.IOException; +import java.nio.channels.SelectionKey; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.util.List; + +public class ChannelAcceptTask implements Task { + private static final Logger LOGGER = LogManager.getLogger(ChannelAcceptTask.class); + + private final ServerSocketChannel serverSocketChannel; + + public ChannelAcceptTask(ServerSocketChannel serverSocketChannel) { + this.serverSocketChannel = serverSocketChannel; + } + + @Override + public String getName() { + return "ChannelAcceptTask"; + } + + @Override + public void perform() throws InterruptedException { + Network network = Network.getInstance(); + SocketChannel socketChannel; + + try { + if (network.getImmutableConnectedPeers().size() >= network.getMaxPeers()) { + // We have enough peers + LOGGER.debug("Ignoring pending incoming connections because the server is full"); + return; + } + + socketChannel = serverSocketChannel.accept(); + + network.setInterestOps(serverSocketChannel, SelectionKey.OP_ACCEPT); + } catch (IOException e) { + return; + } + + // No connection actually accepted? + if (socketChannel == null) { + return; + } + + PeerAddress address = PeerAddress.fromSocket(socketChannel.socket()); + List fixedNetwork = Settings.getInstance().getFixedNetwork(); + if (fixedNetwork != null && !fixedNetwork.isEmpty() && network.ipNotInFixedList(address, fixedNetwork)) { + try { + LOGGER.debug("Connection discarded from peer {} as not in the fixed network list", address); + socketChannel.close(); + } catch (IOException e) { + // IGNORE + } + return; + } + + final Long now = NTP.getTime(); + Peer newPeer; + + try { + if (now == null) { + LOGGER.debug("Connection discarded from peer {} due to lack of NTP sync", address); + socketChannel.close(); + return; + } + + LOGGER.debug("Connection accepted from peer {}", address); + + newPeer = new Peer(socketChannel); + network.addConnectedPeer(newPeer); + + } catch (IOException e) { + if (socketChannel.isOpen()) { + try { + LOGGER.debug("Connection failed from peer {} while connecting/closing", address); + socketChannel.close(); + } catch (IOException ce) { + // Couldn't close? + } + } + return; + } + + network.onPeerReady(newPeer); + } +} diff --git a/src/main/java/org/qortal/network/task/ChannelReadTask.java b/src/main/java/org/qortal/network/task/ChannelReadTask.java new file mode 100644 index 00000000..edd4e8c0 --- /dev/null +++ b/src/main/java/org/qortal/network/task/ChannelReadTask.java @@ -0,0 +1,49 @@ +package org.qortal.network.task; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.qortal.network.Network; +import org.qortal.network.Peer; +import org.qortal.utils.ExecuteProduceConsume.Task; + +import java.io.IOException; +import java.nio.channels.SelectionKey; +import java.nio.channels.SocketChannel; + +public class ChannelReadTask implements Task { + private static final Logger LOGGER = LogManager.getLogger(ChannelReadTask.class); + + private final SocketChannel socketChannel; + private final Peer peer; + private final String name; + + public ChannelReadTask(SocketChannel socketChannel, Peer peer) { + this.socketChannel = socketChannel; + this.peer = peer; + this.name = "ChannelReadTask::" + peer; + } + + @Override + public String getName() { + return name; + } + + @Override + public void perform() throws InterruptedException { + try { + peer.readChannel(); + + Network.getInstance().setInterestOps(socketChannel, SelectionKey.OP_READ); + } catch (IOException e) { + if (e.getMessage() != null && e.getMessage().toLowerCase().contains("connection reset")) { + peer.disconnect("Connection reset"); + return; + } + + LOGGER.trace("[{}] Network thread {} encountered I/O error: {}", peer.getPeerConnectionId(), + Thread.currentThread().getId(), e.getMessage(), e); + peer.disconnect("I/O error"); + } + } +} diff --git a/src/main/java/org/qortal/network/task/ChannelWriteTask.java b/src/main/java/org/qortal/network/task/ChannelWriteTask.java new file mode 100644 index 00000000..59bc557e --- /dev/null +++ b/src/main/java/org/qortal/network/task/ChannelWriteTask.java @@ -0,0 +1,52 @@ +package org.qortal.network.task; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.network.Network; +import org.qortal.network.Peer; +import org.qortal.utils.ExecuteProduceConsume.Task; + +import java.io.IOException; +import java.nio.channels.SelectionKey; +import java.nio.channels.SocketChannel; + +public class ChannelWriteTask implements Task { + private static final Logger LOGGER = LogManager.getLogger(ChannelWriteTask.class); + + private final SocketChannel socketChannel; + private final Peer peer; + private final String name; + + public ChannelWriteTask(SocketChannel socketChannel, Peer peer) { + this.socketChannel = socketChannel; + this.peer = peer; + this.name = "ChannelWriteTask::" + peer; + } + + @Override + public String getName() { + return name; + } + + @Override + public void perform() throws InterruptedException { + try { + boolean isSocketClogged = peer.writeChannel(); + + // Tell Network that we've finished + Network.getInstance().notifyChannelNotWriting(socketChannel); + + if (isSocketClogged) + Network.getInstance().setInterestOps(this.socketChannel, SelectionKey.OP_WRITE); + } catch (IOException e) { + if (e.getMessage() != null && e.getMessage().toLowerCase().contains("connection reset")) { + peer.disconnect("Connection reset"); + return; + } + + LOGGER.trace("[{}] Network thread {} encountered I/O error: {}", peer.getPeerConnectionId(), + Thread.currentThread().getId(), e.getMessage(), e); + peer.disconnect("I/O error"); + } + } +} diff --git a/src/main/java/org/qortal/network/task/MessageTask.java b/src/main/java/org/qortal/network/task/MessageTask.java new file mode 100644 index 00000000..c1907b62 --- /dev/null +++ b/src/main/java/org/qortal/network/task/MessageTask.java @@ -0,0 +1,28 @@ +package org.qortal.network.task; + +import org.qortal.network.Network; +import org.qortal.network.Peer; +import org.qortal.network.message.Message; +import org.qortal.utils.ExecuteProduceConsume.Task; + +public class MessageTask implements Task { + private final Peer peer; + private final Message nextMessage; + private final String name; + + public MessageTask(Peer peer, Message nextMessage) { + this.peer = peer; + this.nextMessage = nextMessage; + this.name = "MessageTask::" + peer + "::" + nextMessage.getType(); + } + + @Override + public String getName() { + return name; + } + + @Override + public void perform() throws InterruptedException { + Network.getInstance().onMessage(peer, nextMessage); + } +} diff --git a/src/main/java/org/qortal/network/task/PeerConnectTask.java b/src/main/java/org/qortal/network/task/PeerConnectTask.java new file mode 100644 index 00000000..759cabce --- /dev/null +++ b/src/main/java/org/qortal/network/task/PeerConnectTask.java @@ -0,0 +1,33 @@ +package org.qortal.network.task; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.network.Network; +import org.qortal.network.Peer; +import org.qortal.network.message.Message; +import org.qortal.network.message.MessageType; +import org.qortal.network.message.PingMessage; +import org.qortal.utils.ExecuteProduceConsume.Task; +import org.qortal.utils.NTP; + +public class PeerConnectTask implements Task { + private static final Logger LOGGER = LogManager.getLogger(PeerConnectTask.class); + + private final Peer peer; + private final String name; + + public PeerConnectTask(Peer peer) { + this.peer = peer; + this.name = "PeerConnectTask::" + peer; + } + + @Override + public String getName() { + return name; + } + + @Override + public void perform() throws InterruptedException { + Network.getInstance().connectPeer(peer); + } +} diff --git a/src/main/java/org/qortal/network/task/PingTask.java b/src/main/java/org/qortal/network/task/PingTask.java new file mode 100644 index 00000000..f47ecd32 --- /dev/null +++ b/src/main/java/org/qortal/network/task/PingTask.java @@ -0,0 +1,44 @@ +package org.qortal.network.task; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.network.Peer; +import org.qortal.network.message.Message; +import org.qortal.network.message.MessageType; +import org.qortal.network.message.PingMessage; +import org.qortal.utils.ExecuteProduceConsume.Task; +import org.qortal.utils.NTP; + +public class PingTask implements Task { + private static final Logger LOGGER = LogManager.getLogger(PingTask.class); + + private final Peer peer; + private final Long now; + private final String name; + + public PingTask(Peer peer, Long now) { + this.peer = peer; + this.now = now; + this.name = "PingTask::" + peer; + } + + @Override + public String getName() { + return name; + } + + @Override + public void perform() throws InterruptedException { + PingMessage pingMessage = new PingMessage(); + Message message = peer.getResponse(pingMessage); + + if (message == null || message.getType() != MessageType.PING) { + LOGGER.debug("[{}] Didn't receive reply from {} for PING ID {}", + peer.getPeerConnectionId(), peer, pingMessage.getId()); + peer.disconnect("no ping received"); + return; + } + + peer.setLastPing(NTP.getTime() - now); + } +} diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index d9791475..2e3d0859 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -26,6 +26,8 @@ import org.qortal.controller.arbitrary.ArbitraryDataStorageManager.*; import org.qortal.crosschain.Bitcoin.BitcoinNet; import org.qortal.crosschain.Litecoin.LitecoinNet; import org.qortal.crosschain.Dogecoin.DogecoinNet; +import org.qortal.crosschain.Digibyte.DigibyteNet; +import org.qortal.crosschain.Ravencoin.RavencoinNet; import org.qortal.utils.EnumUtils; // All properties to be converted to JSON via JAXB @@ -222,6 +224,8 @@ public class Settings { private BitcoinNet bitcoinNet = BitcoinNet.MAIN; private LitecoinNet litecoinNet = LitecoinNet.MAIN; private DogecoinNet dogecoinNet = DogecoinNet.MAIN; + private DigibyteNet digibyteNet = DigibyteNet.MAIN; + private RavencoinNet ravencoinNet = RavencoinNet.MAIN; // Also crosschain-related: /** Whether to show SysTray pop-up notifications when trade-bot entries change state */ private boolean tradebotSystrayEnabled = false; @@ -685,6 +689,14 @@ public class Settings { return this.dogecoinNet; } + public DigibyteNet getDigibyteNet() { + return this.digibyteNet; + } + + public RavencoinNet getRavencoinNet() { + return this.ravencoinNet; + } + public boolean isTradebotSystrayEnabled() { return this.tradebotSystrayEnabled; } diff --git a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java index c3190f03..b1554e8d 100644 --- a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java @@ -39,12 +39,12 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { private static final int IDENTIFIER_SIZE_LENGTH = INT_LENGTH; private static final int COMPRESSION_LENGTH = INT_LENGTH; private static final int METHOD_LENGTH = INT_LENGTH; - private static final int SECRET_LENGTH = INT_LENGTH; // TODO: wtf? + private static final int SECRET_SIZE_LENGTH = INT_LENGTH; private static final int EXTRAS_LENGTH = SERVICE_LENGTH + DATA_TYPE_LENGTH + DATA_SIZE_LENGTH; private static final int EXTRAS_V5_LENGTH = NONCE_LENGTH + NAME_SIZE_LENGTH + IDENTIFIER_SIZE_LENGTH + - METHOD_LENGTH + SECRET_LENGTH + COMPRESSION_LENGTH + RAW_DATA_SIZE_LENGTH + METADATA_HASH_SIZE_LENGTH; + METHOD_LENGTH + SECRET_SIZE_LENGTH + COMPRESSION_LENGTH + RAW_DATA_SIZE_LENGTH + METADATA_HASH_SIZE_LENGTH; protected static final TransactionLayout layout; diff --git a/src/main/java/org/qortal/utils/ExecuteProduceConsume.java b/src/main/java/org/qortal/utils/ExecuteProduceConsume.java index 57caab9c..223d0e93 100644 --- a/src/main/java/org/qortal/utils/ExecuteProduceConsume.java +++ b/src/main/java/org/qortal/utils/ExecuteProduceConsume.java @@ -28,7 +28,6 @@ public abstract class ExecuteProduceConsume implements Runnable { private final String className; private final Logger logger; - private final boolean isLoggerTraceEnabled; protected ExecutorService executor; @@ -43,12 +42,12 @@ public abstract class ExecuteProduceConsume implements Runnable { private volatile int tasksConsumed = 0; private volatile int spawnFailures = 0; + /** Whether a new thread has already been spawned and is waiting to start. Used to prevent spawning multiple new threads. */ private volatile boolean hasThreadPending = false; public ExecuteProduceConsume(ExecutorService executor) { this.className = this.getClass().getSimpleName(); this.logger = LogManager.getLogger(this.getClass()); - this.isLoggerTraceEnabled = this.logger.isTraceEnabled(); this.executor = executor; } @@ -98,15 +97,14 @@ public abstract class ExecuteProduceConsume implements Runnable { */ protected abstract Task produceTask(boolean canBlock) throws InterruptedException; - @FunctionalInterface public interface Task { - public abstract void perform() throws InterruptedException; + String getName(); + void perform() throws InterruptedException; } @Override public void run() { - if (this.isLoggerTraceEnabled) - Thread.currentThread().setName(this.className + "-" + Thread.currentThread().getId()); + Thread.currentThread().setName(this.className + "-" + Thread.currentThread().getId()); boolean wasThreadPending; synchronized (this) { @@ -114,25 +112,19 @@ public abstract class ExecuteProduceConsume implements Runnable { if (this.activeThreadCount > this.greatestActiveThreadCount) this.greatestActiveThreadCount = this.activeThreadCount; - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] started, hasThreadPending was: %b, activeThreadCount now: %d", - Thread.currentThread().getId(), this.hasThreadPending, this.activeThreadCount)); - } + this.logger.trace(() -> String.format("[%d] started, hasThreadPending was: %b, activeThreadCount now: %d", + Thread.currentThread().getId(), this.hasThreadPending, this.activeThreadCount)); // Defer clearing hasThreadPending to prevent unnecessary threads waiting to produce... wasThreadPending = this.hasThreadPending; } try { - // It's possible this might need to become a class instance private volatile - boolean canBlock = false; - while (!Thread.currentThread().isInterrupted()) { Task task = null; + String taskType; - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] waiting to produce...", Thread.currentThread().getId())); - } + this.logger.trace(() -> String.format("[%d] waiting to produce...", Thread.currentThread().getId())); synchronized (this) { if (wasThreadPending) { @@ -141,13 +133,13 @@ public abstract class ExecuteProduceConsume implements Runnable { wasThreadPending = false; } - final boolean lambdaCanIdle = canBlock; - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] producing, activeThreadCount: %d, consumerCount: %d, canBlock is %b...", - Thread.currentThread().getId(), this.activeThreadCount, this.consumerCount, lambdaCanIdle)); - } + // If we're the only non-consuming thread - producer can afford to block this round + boolean canBlock = this.activeThreadCount - this.consumerCount <= 1; - final long beforeProduce = isLoggerTraceEnabled ? System.currentTimeMillis() : 0; + this.logger.trace(() -> String.format("[%d] producing... [activeThreadCount: %d, consumerCount: %d, canBlock: %b]", + Thread.currentThread().getId(), this.activeThreadCount, this.consumerCount, canBlock)); + + final long beforeProduce = this.logger.isDebugEnabled() ? System.currentTimeMillis() : 0; try { task = produceTask(canBlock); @@ -158,31 +150,36 @@ public abstract class ExecuteProduceConsume implements Runnable { this.logger.warn(() -> String.format("[%d] exception while trying to produce task", Thread.currentThread().getId()), e); } - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] producing took %dms", Thread.currentThread().getId(), System.currentTimeMillis() - beforeProduce)); + if (this.logger.isDebugEnabled()) { + final long productionPeriod = System.currentTimeMillis() - beforeProduce; + taskType = task == null ? "no task" : task.getName(); + + this.logger.debug(() -> String.format("[%d] produced [%s] in %dms [canBlock: %b]", + Thread.currentThread().getId(), + taskType, + productionPeriod, + canBlock + )); + } else { + taskType = null; } } if (task == null) synchronized (this) { - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] no task, activeThreadCount: %d, consumerCount: %d", - Thread.currentThread().getId(), this.activeThreadCount, this.consumerCount)); - } + this.logger.trace(() -> String.format("[%d] no task, activeThreadCount: %d, consumerCount: %d", + Thread.currentThread().getId(), this.activeThreadCount, this.consumerCount)); - if (this.activeThreadCount > this.consumerCount + 1) { + // If we have an excess of non-consuming threads then we can exit + if (this.activeThreadCount - this.consumerCount > 1) { --this.activeThreadCount; - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] ending, activeThreadCount now: %d", - Thread.currentThread().getId(), this.activeThreadCount)); - } + + this.logger.trace(() -> String.format("[%d] ending, activeThreadCount now: %d", + Thread.currentThread().getId(), this.activeThreadCount)); return; } - // We're the last surviving thread - producer can afford to block next round - canBlock = true; - continue; } @@ -192,16 +189,13 @@ public abstract class ExecuteProduceConsume implements Runnable { ++this.tasksProduced; ++this.consumerCount; - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] hasThreadPending: %b, activeThreadCount: %d, consumerCount now: %d", - Thread.currentThread().getId(), this.hasThreadPending, this.activeThreadCount, this.consumerCount)); - } + this.logger.trace(() -> String.format("[%d] hasThreadPending: %b, activeThreadCount: %d, consumerCount now: %d", + Thread.currentThread().getId(), this.hasThreadPending, this.activeThreadCount, this.consumerCount)); // If we have no thread pending and no excess of threads then we should spawn a fresh thread - if (!this.hasThreadPending && this.activeThreadCount <= this.consumerCount + 1) { - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] spawning another thread", Thread.currentThread().getId())); - } + if (!this.hasThreadPending && this.activeThreadCount == this.consumerCount) { + this.logger.trace(() -> String.format("[%d] spawning another thread", Thread.currentThread().getId())); + this.hasThreadPending = true; try { @@ -209,21 +203,19 @@ public abstract class ExecuteProduceConsume implements Runnable { } catch (RejectedExecutionException e) { ++this.spawnFailures; this.hasThreadPending = false; - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] failed to spawn another thread", Thread.currentThread().getId())); - } + + this.logger.trace(() -> String.format("[%d] failed to spawn another thread", Thread.currentThread().getId())); + this.onSpawnFailure(); } } else { - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] NOT spawning another thread", Thread.currentThread().getId())); - } + this.logger.trace(() -> String.format("[%d] NOT spawning another thread", Thread.currentThread().getId())); } } - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] performing task...", Thread.currentThread().getId())); - } + this.logger.trace(() -> String.format("[%d] consuming [%s] task...", Thread.currentThread().getId(), taskType)); + + final long beforePerform = this.logger.isDebugEnabled() ? System.currentTimeMillis() : 0; try { task.perform(); // This can block for a while @@ -231,29 +223,25 @@ public abstract class ExecuteProduceConsume implements Runnable { // We're in shutdown situation so exit Thread.currentThread().interrupt(); } catch (Exception e) { - this.logger.warn(() -> String.format("[%d] exception while performing task", Thread.currentThread().getId()), e); + this.logger.warn(() -> String.format("[%d] exception while consuming task", Thread.currentThread().getId()), e); } - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] finished task", Thread.currentThread().getId())); + if (this.logger.isDebugEnabled()) { + final long productionPeriod = System.currentTimeMillis() - beforePerform; + + this.logger.debug(() -> String.format("[%d] consumed [%s] task in %dms", Thread.currentThread().getId(), taskType, productionPeriod)); } synchronized (this) { ++this.tasksConsumed; --this.consumerCount; - if (this.isLoggerTraceEnabled) { - this.logger.trace(() -> String.format("[%d] consumerCount now: %d", - Thread.currentThread().getId(), this.consumerCount)); - } - - // Quicker, non-blocking produce next round - canBlock = false; + this.logger.trace(() -> String.format("[%d] consumerCount now: %d", + Thread.currentThread().getId(), this.consumerCount)); } } } finally { - if (this.isLoggerTraceEnabled) - Thread.currentThread().setName(this.className); + Thread.currentThread().setName(this.className); } } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index e9d6a6f1..403e35bb 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -5,7 +5,8 @@ "maxBytesPerUnitFee": 1024, "unitFee": "0.001", "nameRegistrationUnitFees": [ - { "timestamp": 1645372800000, "fee": "5" } + { "timestamp": 1645372800000, "fee": "5" }, + { "timestamp": 1651420800000, "fee": "1.25" } ], "useBrokenMD160ForAddresses": false, "requireGroupForApproval": false, diff --git a/src/test/java/org/qortal/test/EPCTests.java b/src/test/java/org/qortal/test/EPCTests.java index fe48af24..1a41b75d 100644 --- a/src/test/java/org/qortal/test/EPCTests.java +++ b/src/test/java/org/qortal/test/EPCTests.java @@ -13,9 +13,25 @@ import org.junit.Test; import org.qortal.utils.ExecuteProduceConsume; import org.qortal.utils.ExecuteProduceConsume.StatsSnapshot; +import static org.junit.Assert.fail; + public class EPCTests { - class RandomEPC extends ExecuteProduceConsume { + static class SleepTask implements ExecuteProduceConsume.Task { + private static final Random RANDOM = new Random(); + + @Override + public String getName() { + return "SleepTask"; + } + + @Override + public void perform() throws InterruptedException { + Thread.sleep(RANDOM.nextInt(500) + 100); + } + } + + static class RandomEPC extends ExecuteProduceConsume { private final int TASK_PERCENT; private final int PAUSE_PERCENT; @@ -37,9 +53,7 @@ public class EPCTests { // Sometimes produce a task if (percent < TASK_PERCENT) { - return () -> { - Thread.sleep(random.nextInt(500) + 100); - }; + return new SleepTask(); } else { // If we don't produce a task, then maybe simulate a pause until work arrives if (canIdle && percent < PAUSE_PERCENT) @@ -50,45 +64,6 @@ public class EPCTests { } } - private void testEPC(ExecuteProduceConsume testEPC) throws InterruptedException { - final int runTime = 60; // seconds - System.out.println(String.format("Testing EPC for %s seconds:", runTime)); - - final long start = System.currentTimeMillis(); - testEPC.start(); - - // Status reports every second (bar waiting for synchronization) - ScheduledExecutorService statusExecutor = Executors.newSingleThreadScheduledExecutor(); - - statusExecutor.scheduleAtFixedRate(() -> { - final StatsSnapshot snapshot = testEPC.getStatsSnapshot(); - final long seconds = (System.currentTimeMillis() - start) / 1000L; - System.out.print(String.format("After %d second%s, ", seconds, (seconds != 1 ? "s" : ""))); - printSnapshot(snapshot); - }, 1L, 1L, TimeUnit.SECONDS); - - // Let it run for a minute - Thread.sleep(runTime * 1000L); - statusExecutor.shutdownNow(); - - final long before = System.currentTimeMillis(); - testEPC.shutdown(30 * 1000); - final long after = System.currentTimeMillis(); - - System.out.println(String.format("Shutdown took %d milliseconds", after - before)); - - final StatsSnapshot snapshot = testEPC.getStatsSnapshot(); - System.out.print("After shutdown, "); - printSnapshot(snapshot); - } - - private void printSnapshot(final StatsSnapshot snapshot) { - System.out.println(String.format("threads: %d active (%d max, %d exhaustion%s), tasks: %d produced / %d consumed", - snapshot.activeThreadCount, snapshot.greatestActiveThreadCount, - snapshot.spawnFailures, (snapshot.spawnFailures != 1 ? "s": ""), - snapshot.tasksProduced, snapshot.tasksConsumed)); - } - @Test public void testRandomEPC() throws InterruptedException { final int TASK_PERCENT = 25; // Produce a task this % of the time @@ -131,18 +106,39 @@ public class EPCTests { final int MAX_PEERS = 20; - final List lastPings = new ArrayList<>(Collections.nCopies(MAX_PEERS, System.currentTimeMillis())); + final List lastPingProduced = new ArrayList<>(Collections.nCopies(MAX_PEERS, System.currentTimeMillis())); class PingTask implements ExecuteProduceConsume.Task { private final int peerIndex; + private final long lastPing; + private final long productionTimestamp; + private final String name; - public PingTask(int peerIndex) { + public PingTask(int peerIndex, long lastPing, long productionTimestamp) { this.peerIndex = peerIndex; + this.lastPing = lastPing; + this.productionTimestamp = productionTimestamp; + this.name = "PingTask::[" + this.peerIndex + "]"; + } + + @Override + public String getName() { + return name; } @Override public void perform() throws InterruptedException { - System.out.println("Pinging peer " + peerIndex); + long now = System.currentTimeMillis(); + + System.out.println(String.format("Pinging peer %d after post-production delay of %dms and ping interval of %dms", + peerIndex, + now - productionTimestamp, + now - lastPing + )); + + long threshold = now - PING_INTERVAL - PRODUCER_SLEEP_TIME; + if (lastPing < threshold) + fail("excessive peer ping interval for peer " + peerIndex); // At least half the worst case ping round-trip Random random = new Random(); @@ -155,32 +151,73 @@ public class EPCTests { class PingEPC extends ExecuteProduceConsume { @Override protected Task produceTask(boolean canIdle) throws InterruptedException { - // If we can idle, then we do, to simulate worst case - if (canIdle) - Thread.sleep(PRODUCER_SLEEP_TIME); - // Is there a peer that needs a ping? final long now = System.currentTimeMillis(); - synchronized (lastPings) { - for (int peerIndex = 0; peerIndex < lastPings.size(); ++peerIndex) { - long lastPing = lastPings.get(peerIndex); - - if (lastPing < now - PING_INTERVAL - PING_ROUND_TRIP_TIME - PRODUCER_SLEEP_TIME) - throw new RuntimeException("excessive peer ping interval for peer " + peerIndex); + synchronized (lastPingProduced) { + for (int peerIndex = 0; peerIndex < lastPingProduced.size(); ++peerIndex) { + long lastPing = lastPingProduced.get(peerIndex); if (lastPing < now - PING_INTERVAL) { - lastPings.set(peerIndex, System.currentTimeMillis()); - return new PingTask(peerIndex); + lastPingProduced.set(peerIndex, System.currentTimeMillis()); + return new PingTask(peerIndex, lastPing, now); } } } + // If we can idle, then we do, to simulate worst case + if (canIdle) + Thread.sleep(PRODUCER_SLEEP_TIME); + // No work to do return null; } } + System.out.println(String.format("Pings should start after %s seconds", PING_INTERVAL)); + testEPC(new PingEPC()); } + private void testEPC(ExecuteProduceConsume testEPC) throws InterruptedException { + final int runTime = 60; // seconds + System.out.println(String.format("Testing EPC for %s seconds:", runTime)); + + final long start = System.currentTimeMillis(); + + // Status reports every second (bar waiting for synchronization) + ScheduledExecutorService statusExecutor = Executors.newSingleThreadScheduledExecutor(); + + statusExecutor.scheduleAtFixedRate( + () -> { + final StatsSnapshot snapshot = testEPC.getStatsSnapshot(); + final long seconds = (System.currentTimeMillis() - start) / 1000L; + System.out.println(String.format("After %d second%s, %s", seconds, seconds != 1 ? "s" : "", formatSnapshot(snapshot))); + }, + 0L, 1L, TimeUnit.SECONDS + ); + + testEPC.start(); + + // Let it run for a minute + Thread.sleep(runTime * 1000L); + statusExecutor.shutdownNow(); + + final long before = System.currentTimeMillis(); + testEPC.shutdown(30 * 1000); + final long after = System.currentTimeMillis(); + + System.out.println(String.format("Shutdown took %d milliseconds", after - before)); + + final StatsSnapshot snapshot = testEPC.getStatsSnapshot(); + System.out.println("After shutdown, " + formatSnapshot(snapshot)); + } + + private String formatSnapshot(StatsSnapshot snapshot) { + return String.format("threads: %d active (%d max, %d exhaustion%s), tasks: %d produced / %d consumed", + snapshot.activeThreadCount, snapshot.greatestActiveThreadCount, + snapshot.spawnFailures, (snapshot.spawnFailures != 1 ? "s": ""), + snapshot.tasksProduced, snapshot.tasksConsumed + ); + } + } diff --git a/src/test/java/org/qortal/test/crosschain/DigibyteTests.java b/src/test/java/org/qortal/test/crosschain/DigibyteTests.java new file mode 100644 index 00000000..38dde242 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/DigibyteTests.java @@ -0,0 +1,115 @@ +package org.qortal.test.crosschain; + +import static org.junit.Assert.*; + +import java.util.Arrays; + +import org.bitcoinj.core.Transaction; +import org.bitcoinj.store.BlockStoreException; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Digibyte; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +public class DigibyteTests extends Common { + + private Digibyte digibyte; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + digibyte = Digibyte.getInstance(); + } + + @After + public void afterTest() { + Digibyte.resetForTesting(); + digibyte = null; + } + + @Test + public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { + long before = System.currentTimeMillis(); + System.out.println(String.format("Digibyte median blocktime: %d", digibyte.getMedianBlockTime())); + long afterFirst = System.currentTimeMillis(); + + System.out.println(String.format("Digibyte median blocktime: %d", digibyte.getMedianBlockTime())); + long afterSecond = System.currentTimeMillis(); + + long firstPeriod = afterFirst - before; + long secondPeriod = afterSecond - afterFirst; + + System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); + + assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + } + + @Test + @Ignore(value = "Doesn't work, to be fixed later") + public void testFindHtlcSecret() throws ForeignBlockchainException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BitcoinyHTLC.findHtlcSecret(digibyte, p2shAddress); + + assertNotNull("secret not found", secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + + @Test + @Ignore(value = "No testnet nodes available, so we can't regularly test buildSpend yet") + public void testBuildSpend() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + long amount = 1000L; + + Transaction transaction = digibyte.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + + // Check spent key caching doesn't affect outcome + + transaction = digibyte.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + } + + @Test + public void testGetWalletBalance() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + Long balance = digibyte.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(digibyte.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = digibyte.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(digibyte.format(repeatBalance)); + + assertEquals(balance, repeatBalance); + } + + @Test + public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String address = digibyte.getUnusedReceiveAddress(xprv58); + + assertNotNull(address); + + System.out.println(address); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/RavencoinTests.java b/src/test/java/org/qortal/test/crosschain/RavencoinTests.java new file mode 100644 index 00000000..16d811dc --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/RavencoinTests.java @@ -0,0 +1,115 @@ +package org.qortal.test.crosschain; + +import static org.junit.Assert.*; + +import java.util.Arrays; + +import org.bitcoinj.core.Transaction; +import org.bitcoinj.store.BlockStoreException; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Ravencoin; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +public class RavencoinTests extends Common { + + private Ravencoin ravencoin; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + ravencoin = Ravencoin.getInstance(); + } + + @After + public void afterTest() { + Ravencoin.resetForTesting(); + ravencoin = null; + } + + @Test + public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { + long before = System.currentTimeMillis(); + System.out.println(String.format("Ravencoin median blocktime: %d", ravencoin.getMedianBlockTime())); + long afterFirst = System.currentTimeMillis(); + + System.out.println(String.format("Ravencoin median blocktime: %d", ravencoin.getMedianBlockTime())); + long afterSecond = System.currentTimeMillis(); + + long firstPeriod = afterFirst - before; + long secondPeriod = afterSecond - afterFirst; + + System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); + + assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + } + + @Test + @Ignore(value = "Doesn't work, to be fixed later") + public void testFindHtlcSecret() throws ForeignBlockchainException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BitcoinyHTLC.findHtlcSecret(ravencoin, p2shAddress); + + assertNotNull("secret not found", secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + + @Test + @Ignore(value = "No testnet nodes available, so we can't regularly test buildSpend yet") + public void testBuildSpend() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + long amount = 1000L; + + Transaction transaction = ravencoin.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + + // Check spent key caching doesn't affect outcome + + transaction = ravencoin.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + } + + @Test + public void testGetWalletBalance() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + Long balance = ravencoin.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(ravencoin.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = ravencoin.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(ravencoin.format(repeatBalance)); + + assertEquals(balance, repeatBalance); + } + + @Test + public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String address = ravencoin.getUnusedReceiveAddress(xprv58); + + assertNotNull(address); + + System.out.println(address); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java new file mode 100644 index 00000000..d13aba4c --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java @@ -0,0 +1,769 @@ +package org.qortal.test.crosschain.digibytev3; + +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.DigibyteACCTv3; +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 DigibyteACCTv3Tests 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[] digibytePublicKeyHash = 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 digibyteAmount = 864200L; // 0.00864200 DGB + + 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 = DigibyteACCTv3.buildQortalAT(tradeAccount.getAddress(), digibytePublicKeyHash, redeemAmount, digibyteAmount, 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 = DigibyteACCTv3.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 = DigibyteACCTv3.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 = DigibyteACCTv3.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 = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, 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 = DigibyteACCTv3.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 digibyte PKH was extracted correctly + assertTrue(Arrays.equals(digibytePublicKeyHash, 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 = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, 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 = DigibyteACCTv3.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 = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, 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 = DigibyteACCTv3.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 = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, 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 = DigibyteACCTv3.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 = DigibyteACCTv3.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 = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, 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 = DigibyteACCTv3.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 = DigibyteACCTv3.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 = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, 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 = DigibyteACCTv3.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 = DigibyteACCTv3.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 = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, 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 = DigibyteACCTv3.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, DigibyteACCTv3.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 = DigibyteACCTv3.buildQortalAT(tradeAddress, digibytePublicKeyHash, redeemAmount, digibyteAmount, 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-DGB cross-chain trade"; + String description = String.format("Qortal-Digibyte cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-DGB 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 = DigibyteACCTv3.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 digibyte: %s DGB,\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" + + "\tDigibyte 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/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java new file mode 100644 index 00000000..012d5f5d --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java @@ -0,0 +1,769 @@ +package org.qortal.test.crosschain.ravencoinv3; + +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.RavencoinACCTv3; +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 RavencoinACCTv3Tests 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[] ravencoinPublicKeyHash = 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 ravencoinAmount = 864200L; // 0.00864200 RVN + + 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 = RavencoinACCTv3.buildQortalAT(tradeAccount.getAddress(), ravencoinPublicKeyHash, redeemAmount, ravencoinAmount, 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 = RavencoinACCTv3.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 = RavencoinACCTv3.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 = RavencoinACCTv3.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 = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, 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 = RavencoinACCTv3.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 ravencoin PKH was extracted correctly + assertTrue(Arrays.equals(ravencoinPublicKeyHash, 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 = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, 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 = RavencoinACCTv3.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 = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, 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 = RavencoinACCTv3.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 = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, 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 = RavencoinACCTv3.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 = RavencoinACCTv3.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 = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, 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 = RavencoinACCTv3.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 = RavencoinACCTv3.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 = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, 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 = RavencoinACCTv3.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 = RavencoinACCTv3.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 = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, 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 = RavencoinACCTv3.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, RavencoinACCTv3.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 = RavencoinACCTv3.buildQortalAT(tradeAddress, ravencoinPublicKeyHash, redeemAmount, ravencoinAmount, 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-RVN cross-chain trade"; + String description = String.format("Qortal-Ravencoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-RVN 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 = RavencoinACCTv3.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 ravencoin: %s RVN,\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" + + "\tRavencoin 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/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 8252453c..2bcd098d 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -356,8 +356,15 @@ public class MiscTests extends Common { UnitFeesByTimestamp pastFeeIncrease = new UnitFeesByTimestamp(); pastFeeIncrease.timestamp = now - 1000L; // 1 second ago pastFeeIncrease.fee = new AmountTypeAdapter().unmarshal("3"); - FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(pastFeeIncrease), true); + + // Set another increase in the future + futureFeeIncrease = new UnitFeesByTimestamp(); + futureFeeIncrease.timestamp = now + (60 * 60 * 1000L); // 1 hour in the future + futureFeeIncrease.fee = new AmountTypeAdapter().unmarshal("10"); + + FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(pastFeeIncrease, futureFeeIncrease), true); assertEquals(pastFeeIncrease.fee, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(pastFeeIncrease.timestamp)); + assertEquals(futureFeeIncrease.fee, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); // Register a different name // First try with the default unit fee diff --git a/src/test/java/org/qortal/test/network/OnlineAccountsTests.java b/src/test/java/org/qortal/test/network/OnlineAccountsTests.java index 478709af..2b836461 100644 --- a/src/test/java/org/qortal/test/network/OnlineAccountsTests.java +++ b/src/test/java/org/qortal/test/network/OnlineAccountsTests.java @@ -49,7 +49,7 @@ public class OnlineAccountsTests extends Common { @Test - public void testGetOnlineAccountsV2() throws Message.MessageException { + public void testGetOnlineAccountsV2() throws MessageException { List onlineAccountsOut = generateOnlineAccounts(false); Message messageOut = new GetOnlineAccountsV2Message(onlineAccountsOut); @@ -66,7 +66,7 @@ public class OnlineAccountsTests extends Common { } @Test - public void testOnlineAccountsV2() throws Message.MessageException { + public void testOnlineAccountsV2() throws MessageException { List onlineAccountsOut = generateOnlineAccounts(true); Message messageOut = new OnlineAccountsV2Message(onlineAccountsOut);