diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 6b89ef44..7d69ffb9 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - + diff --git a/src/main/java/org/qortal/api/ApiError.java b/src/main/java/org/qortal/api/ApiError.java index d43aa197..495910e4 100644 --- a/src/main/java/org/qortal/api/ApiError.java +++ b/src/main/java/org/qortal/api/ApiError.java @@ -131,9 +131,12 @@ public enum ApiError { FOREIGN_BLOCKCHAIN_BALANCE_ISSUE(1202, 402), FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408), + // Trade portal + ORDER_SIZE_TOO_SMALL(1300, 402); + // Data - FILE_NOT_FOUND(1301, 404), - NO_REPLY(1302, 404); + FILE_NOT_FOUND(1401, 404), + NO_REPLY(1402, 404); private static final Map map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError)); diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java index 274dd818..edc137c0 100644 --- a/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java +++ b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java @@ -25,6 +25,12 @@ public class CrossChainTradeSummary { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) private long foreignAmount; + private String atAddress; + + private String sellerAddress; + + private String buyerReceivingAddress; + protected CrossChainTradeSummary() { /* For JAXB */ } @@ -34,6 +40,9 @@ public class CrossChainTradeSummary { this.qortAmount = crossChainTradeData.qortAmount; this.foreignAmount = crossChainTradeData.expectedForeignAmount; this.btcAmount = this.foreignAmount; + this.sellerAddress = crossChainTradeData.qortalCreator; + this.buyerReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress; + this.atAddress = crossChainTradeData.qortalAtAddress; } public long getTradeTimestamp() { @@ -48,7 +57,11 @@ public class CrossChainTradeSummary { return this.btcAmount; } - public long getForeignAmount() { - return this.foreignAmount; - } + public long getForeignAmount() { return this.foreignAmount; } + + public String getAtAddress() { return this.atAddress; } + + public String getSellerAddress() { return this.sellerAddress; } + + public String getBuyerReceivingAddressAddress() { return this.buyerReceivingAddress; } } diff --git a/src/main/java/org/qortal/api/model/crosschain/DogecoinSendRequest.java b/src/main/java/org/qortal/api/model/crosschain/DogecoinSendRequest.java new file mode 100644 index 00000000..88740058 --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/DogecoinSendRequest.java @@ -0,0 +1,29 @@ +package org.qortal.api.model.crosschain; + +import io.swagger.v3.oas.annotations.media.Schema; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +@XmlAccessorType(XmlAccessType.FIELD) +public class DogecoinSendRequest { + + @Schema(description = "Dogecoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________") + public String xprv58; + + @Schema(description = "Recipient's Dogecoin address ('legacy' P2PKH only)", example = "DoGecoinEaterAddressDontSendhLfzKD") + public String receivingAddress; + + @Schema(description = "Amount of DOGE to send", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long dogecoinAmount; + + @Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 DOGE (100 sats) per byte", example = "0.00000100", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long feePerByte; + + public DogecoinSendRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainDogecoinACCTv1Resource.java b/src/main/java/org/qortal/api/resource/CrossChainDogecoinACCTv1Resource.java new file mode 100644 index 00000000..1645f89b --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainDogecoinACCTv1Resource.java @@ -0,0 +1,140 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.qortal.account.PrivateKeyAccount; +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.CrossChainSecretRequest; +import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.DogecoinACCTv1; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.Transformer; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import java.util.Arrays; + +@Path("/crosschain/DogecoinACCTv1") +@Tag(name = "Cross-Chain (DogecoinACCTv1)") +public class CrossChainDogecoinACCTv1Resource { + + @Context + HttpServletRequest request; + + @POST + @Path("/redeemmessage") + @Operation( + summary = "Signs and broadcasts a 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner", + description = "Specify address of cross-chain AT that needs to be messaged, Alice's trade private key, the 32-byte secret,
" + + "and an address for receiving QORT from AT. All of these can be found in Alice's trade bot data.
" + + "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" + + "You need to use the private key that the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainSecretRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public boolean buildRedeemMessage(CrossChainSecretRequest secretRequest) { + Security.checkApiCallAllowed(request); + + byte[] partnerPrivateKey = secretRequest.partnerPrivateKey; + + if (partnerPrivateKey == null || partnerPrivateKey.length != Transformer.PRIVATE_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (secretRequest.secret == null || secretRequest.secret.length != DogecoinACCTv1.SECRET_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); + CrossChainTradeData crossChainTradeData = DogecoinACCTv1.getInstance().populateTradeData(repository, atData); + + if (crossChainTradeData.mode != AcctMode.TRADING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] partnerPublicKey = new PrivateKeyAccount(null, partnerPrivateKey).getPublicKey(); + String partnerAddress = Crypto.toAddress(partnerPublicKey); + + // MESSAGE must come from address that AT considers trade partner + if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Good to make MESSAGE + + byte[] messageData = DogecoinACCTv1.buildRedeemMessage(secretRequest.secret, secretRequest.receivingAddress); + + PrivateKeyAccount sender = new PrivateKeyAccount(repository, partnerPrivateKey); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, secretRequest.atAddress, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); + + return true; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Must be correct AT - check functionality using code hash + if (!Arrays.equals(atData.getCodeHash(), DogecoinACCTv1.CODE_BYTES_HASH)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // No point sending message to AT that's finished + if (atData.getIsFinished()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return atData; + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java new file mode 100644 index 00000000..bceda7e9 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java @@ -0,0 +1,165 @@ +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.tags.Tag; +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.DogecoinSendRequest; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Dogecoin; +import org.qortal.crosschain.SimpleTransaction; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import java.util.List; + +@Path("/crosschain/doge") +@Tag(name = "Cross-Chain (Dogecoin)") +public class CrossChainDogecoinResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/walletbalance") + @Operation( + summary = "Returns DOGE 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}) + public String getDogecoinWalletBalance(String key58) { + Security.checkApiCallAllowed(request); + + Dogecoin dogecoin = Dogecoin.getInstance(); + + if (!dogecoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Long balance = dogecoin.getWalletBalance(key58); + if (balance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return balance.toString(); + } + + @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}) + public List getDogecoinWalletTransactions(String key58) { + Security.checkApiCallAllowed(request); + + Dogecoin dogecoin = Dogecoin.getInstance(); + + if (!dogecoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return dogecoin.getWalletTransactions(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + + @POST + @Path("/send") + @Operation( + summary = "Sends DOGE from hierarchical, deterministic BIP32 wallet to specific address", + description = "Currently only supports 'legacy' P2PKH Dogecoin 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 = DogecoinSendRequest.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}) + public String sendBitcoin(DogecoinSendRequest dogecoinSendRequest) { + Security.checkApiCallAllowed(request); + + if (dogecoinSendRequest.dogecoinAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (dogecoinSendRequest.feePerByte != null && dogecoinSendRequest.feePerByte <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Dogecoin dogecoin = Dogecoin.getInstance(); + + if (!dogecoin.isValidAddress(dogecoinSendRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (!dogecoin.isValidDeterministicKey(dogecoinSendRequest.xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Transaction spendTransaction = dogecoin.buildSpend(dogecoinSendRequest.xprv58, + dogecoinSendRequest.receivingAddress, + dogecoinSendRequest.dogecoinAmount, + dogecoinSendRequest.feePerByte); + + if (spendTransaction == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + try { + dogecoin.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/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 98e9b01d..0076609a 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -46,7 +46,7 @@ public class CrossChainHtlcResource { @Path("/address/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}") @Operation( summary = "Returns HTLC address based on trade info", - description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.", + description = "Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.", responses = { @ApiResponse( content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) @@ -96,7 +96,7 @@ public class CrossChainHtlcResource { @Path("/status/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}") @Operation( summary = "Checks HTLC status", - description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.", + description = "Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.", responses = { @ApiResponse( content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class)) @@ -174,55 +174,10 @@ public class CrossChainHtlcResource { } @GET - @Path("/redeem/LITECOIN/{ataddress}/{tradePrivateKey}/{secret}/{receivingAddress}") - @Operation( - summary = "Redeems HTLC associated with supplied AT, using private key, secret, and receiving address", - description = "Secret and private key should be 32 bytes (base58 encoded). Receiving address must be a valid LTC P2PKH address.
" + - "The secret can be found in Alice's trade bot data or in the message to Bob's AT.
" + - "The trade private key and receiving address can be found in Bob's trade bot data.", - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) - ) - } - ) - @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) - public boolean redeemHtlc(@PathParam("ataddress") String atAddress, - @PathParam("tradePrivateKey") String tradePrivateKey, - @PathParam("secret") String secret, - @PathParam("receivingAddress") String receivingAddress) { - Security.checkApiCallAllowed(request); - - // base58 decode the trade private key - byte[] decodedTradePrivateKey = null; - if (tradePrivateKey != null) - decodedTradePrivateKey = Base58.decode(tradePrivateKey); - - // base58 decode the secret - byte[] decodedSecret = null; - if (secret != null) - decodedSecret = Base58.decode(secret); - - // Convert supplied Litecoin receiving address into public key hash (we only support P2PKH at this time) - Address litecoinReceivingAddress; - try { - litecoinReceivingAddress = Address.fromString(Litecoin.getInstance().getNetworkParameters(), receivingAddress); - } catch (AddressFormatException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - } - if (litecoinReceivingAddress.getOutputScriptType() != Script.ScriptType.P2PKH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - byte[] litecoinReceivingAccountInfo = litecoinReceivingAddress.getHash(); - - return this.doRedeemHtlc(atAddress, decodedTradePrivateKey, decodedSecret, litecoinReceivingAccountInfo); - } - - @GET - @Path("/redeem/LITECOIN/{ataddress}") + @Path("/redeem/{ataddress}") @Operation( summary = "Redeems HTLC associated with supplied AT", - description = "To be used by a QORT seller (Bob) who needs to redeem LTC proceeds that are stuck in a P2SH.
" + + description = "To be used by a QORT seller (Bob) who needs to redeem LTC/DOGE/etc proceeds that are stuck in a P2SH.
" + "This requires Bob's trade bot data to be present in the database for this AT.
" + "It will fail if the buyer has yet to redeem the QORT held in the AT.", responses = { @@ -249,7 +204,7 @@ public class CrossChainHtlcResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); // Attempt to find secret from the buyer's message to AT - byte[] decodedSecret = LitecoinACCTv1.findSecretA(repository, crossChainTradeData); + byte[] decodedSecret = acct.findSecretA(repository, crossChainTradeData); if (decodedSecret == null) { LOGGER.info(() -> String.format("Unable to find secret-A from redeem message to AT %s", atAddress)); throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -263,13 +218,13 @@ public class CrossChainHtlcResource { if (tradeBotData != null) decodedPrivateKey = tradeBotData.getTradePrivateKey(); - // Search for the litecoin receiving address in the tradebot data - byte[] litecoinReceivingAccountInfo = null; + // Search for the foreign blockchain receiving address in the tradebot data + byte[] foreignBlockchainReceivingAccountInfo = null; if (tradeBotData != null) // Use receiving address PKH from tradebot data - litecoinReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + foreignBlockchainReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); - return this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, litecoinReceivingAccountInfo); + return this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, foreignBlockchainReceivingAccountInfo); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); @@ -277,10 +232,10 @@ public class CrossChainHtlcResource { } @GET - @Path("/redeemAll/LITECOIN") + @Path("/redeemAll") @Operation( summary = "Redeems HTLC for all applicable ATs in tradebot data", - description = "To be used by a QORT seller (Bob) who needs to redeem LTC proceeds that are stuck in P2SH transactions.
" + + description = "To be used by a QORT seller (Bob) who needs to redeem LTC/DOGE/etc proceeds that are stuck in P2SH transactions.
" + "This requires Bob's trade bot data to be present in the database for any ATs that need redeeming.
" + "Returns true if at least one trade is redeemed. More detail is available in the log.txt.* file.", responses = { @@ -333,7 +288,7 @@ public class CrossChainHtlcResource { } // Attempt to find secret from the buyer's message to AT - byte[] decodedSecret = LitecoinACCTv1.findSecretA(repository, crossChainTradeData); + byte[] decodedSecret = acct.findSecretA(repository, crossChainTradeData); if (decodedSecret == null) { LOGGER.info("Unable to find secret-A from redeem message to AT {}", atAddress); continue; @@ -342,12 +297,12 @@ public class CrossChainHtlcResource { // Search for the tradePrivateKey in the tradebot data byte[] decodedPrivateKey = tradeBotData.getTradePrivateKey(); - // Search for the litecoin receiving address PKH in the tradebot data - byte[] litecoinReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + // Search for the foreign blockchain receiving address PKH in the tradebot data + byte[] foreignBlockchainReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); try { LOGGER.info("Attempting to redeem P2SH balance associated with AT {}...", atAddress); - boolean redeemed = this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, litecoinReceivingAccountInfo); + boolean redeemed = this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, foreignBlockchainReceivingAccountInfo); if (redeemed) { LOGGER.info("Redeemed P2SH balance associated with AT {}", atAddress); success = true; @@ -367,8 +322,10 @@ public class CrossChainHtlcResource { return success; } - private boolean doRedeemHtlc(String atAddress, byte[] decodedTradePrivateKey, byte[] decodedSecret, byte[] litecoinReceivingAccountInfo) { + private boolean doRedeemHtlc(String atAddress, byte[] decodedTradePrivateKey, byte[] decodedSecret, + byte[] foreignBlockchainReceivingAccountInfo) { try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = repository.getATRepository().fromATAddress(atAddress); if (atData == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); @@ -390,30 +347,34 @@ public class CrossChainHtlcResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); // Validate receiving address - if (litecoinReceivingAccountInfo == null || litecoinReceivingAccountInfo.length != 20) + if (foreignBlockchainReceivingAccountInfo == null || foreignBlockchainReceivingAccountInfo.length != 20) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - // Make sure the receiving address isn't a QORT address, given that we can share the same field for both QORT and LTC - if (Crypto.isValidAddress(litecoinReceivingAccountInfo)) - if (Base58.encode(litecoinReceivingAccountInfo).startsWith("Q")) - // This is likely a QORT address, not an LTC + // Make sure the receiving address isn't a QORT address, given that we can share the same field for both QORT and foreign blockchains + if (Crypto.isValidAddress(foreignBlockchainReceivingAccountInfo)) + if (Base58.encode(foreignBlockchainReceivingAccountInfo).startsWith("Q")) + // This is likely a QORT address, not a foreign blockchain throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); // Use secret-A to redeem P2SH-A - Litecoin litecoin = Litecoin.getInstance(); + Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); + if (bitcoiny.getClass() == Bitcoin.class) { + LOGGER.info("Redeeming a Bitcoin HTLC is not yet supported"); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } int lockTime = crossChainTradeData.lockTimeA; byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTime, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); - String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA); + String p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA); LOGGER.info(String.format("Redeeming P2SH address: %s", p2shAddressA)); // Fee for redeem/refund is subtracted from P2SH-A balance. long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); - long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + long p2shFee = bitcoiny.getP2shFee(feeTimestamp); long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; - BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); switch (htlcStatusA) { case UNFUNDED: @@ -434,13 +395,14 @@ public class CrossChainHtlcResource { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey redeemKey = ECKey.fromPrivate(decodedTradePrivateKey); - List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); - Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey, - fundingOutputs, redeemScriptA, decodedSecret, litecoinReceivingAccountInfo); + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoiny.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, decodedSecret, foreignBlockchainReceivingAccountInfo); - litecoin.broadcastTransaction(p2shRedeemTransaction); - return true; // TODO: validate? + bitcoiny.broadcastTransaction(p2shRedeemTransaction); + LOGGER.info(String.format("P2SH address %s redeemed!", p2shAddressA)); + return true; } } @@ -454,10 +416,10 @@ public class CrossChainHtlcResource { } @GET - @Path("/refund/LITECOIN/{ataddress}") + @Path("/refund/{ataddress}") @Operation( summary = "Refunds HTLC associated with supplied AT", - description = "To be used by a QORT buyer (Alice) who needs to refund their LTC that is stuck in a P2SH.
" + + description = "To be used by a QORT buyer (Alice) who needs to refund their LTC/DOGE/etc that is stuck in a P2SH.
" + "This requires Alice's trade bot data to be present in the database for this AT.
" + "It will fail if it's already redeemed by the seller, or if the lockTime (60 minutes) hasn't passed yet.", responses = { @@ -479,9 +441,17 @@ public class CrossChainHtlcResource { if (tradeBotData.getForeignKey() == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - // Determine LTC receive address for refund - Litecoin litecoin = Litecoin.getInstance(); - String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + if (acct == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Determine foreign blockchain receive address for refund + Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); + String receiveAddress = bitcoiny.getUnusedReceiveAddress(tradeBotData.getForeignKey()); return this.doRefundHtlc(atAddress, receiveAddress); @@ -492,11 +462,12 @@ public class CrossChainHtlcResource { } } + @GET - @Path("/refund/LITECOIN/{ataddress}/{receivingAddress}") + @Path("/refundAll") @Operation( - summary = "Refunds HTLC associated with supplied AT, to the specified LTC receiving address", - description = "To be used by a QORT buyer (Alice) who needs to refund their LTC that is stuck in a P2SH.
" + + summary = "Refunds HTLC for all applicable ATs in tradebot data", + description = "To be used by a QORT buyer (Alice) who needs to refund their LTC/DOGE/etc proceeds that are stuck in P2SH transactions.
" + "This requires Alice's trade bot data to be present in the database for this AT.
" + "It will fail if it's already redeemed by the seller, or if the lockTime (60 minutes) hasn't passed yet.", responses = { @@ -506,15 +477,85 @@ public class CrossChainHtlcResource { } ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) - public boolean refundHtlc(@PathParam("ataddress") String atAddress, - @PathParam("receivingAddress") String receivingAddress) { + public boolean refundAllHtlc() { Security.checkApiCallAllowed(request); - return this.doRefundHtlc(atAddress, receivingAddress); + + Security.checkApiCallAllowed(request); + boolean success = false; + + try (final Repository repository = RepositoryManager.getRepository()) { + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + + for (TradeBotData tradeBotData : allTradeBotData) { + String atAddress = tradeBotData.getAtAddress(); + if (atAddress == null) { + LOGGER.info("Missing AT address in tradebot data", atAddress); + continue; + } + + String tradeState = tradeBotData.getState(); + if (tradeState == null) { + LOGGER.info("Missing trade state for AT {}", atAddress); + continue; + } + + if (tradeState.startsWith("BOB")) { + LOGGER.info("AT {} isn't refundable because it is a sell order", atAddress); + continue; + } + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) { + LOGGER.info("Couldn't find AT with address {}", atAddress); + continue; + } + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + if (acct == null) { + continue; + } + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) { + LOGGER.info("Couldn't find crosschain trade data for AT {}", atAddress); + continue; + } + + if (tradeBotData.getForeignKey() == null) { + LOGGER.info("Couldn't find foreign key for AT {}", atAddress); + continue; + } + + try { + // Determine foreign blockchain receive address for refund + Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); + String receivingAddress = bitcoiny.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + + LOGGER.info("Attempting to refund P2SH balance associated with AT {}...", atAddress); + boolean refunded = this.doRefundHtlc(atAddress, receivingAddress); + if (refunded) { + LOGGER.info("Refunded P2SH balance associated with AT {}", atAddress); + success = true; + } + else { + LOGGER.info("Couldn't refund P2SH balance associated with AT {}. Already redeemed?", atAddress); + } + } catch (ApiException | ForeignBlockchainException e) { + LOGGER.info("Couldn't refund P2SH balance associated with AT {}. Missing data?", atAddress); + } + } + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + + return success; } private boolean doRefundHtlc(String atAddress, String receiveAddress) { try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = repository.getATRepository().fromATAddress(atAddress); if (atData == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); @@ -532,6 +573,11 @@ public class CrossChainHtlcResource { if (tradeBotData == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); + if (bitcoiny.getClass() == Bitcoin.class) { + LOGGER.info("Refunding a Bitcoin HTLC is not yet supported"); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } int lockTime = tradeBotData.getLockTimeA(); @@ -539,22 +585,20 @@ public class CrossChainHtlcResource { if (NTP.getTime() <= lockTime * 1000L) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); - Litecoin litecoin = Litecoin.getInstance(); - // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) - int medianBlockTime = litecoin.getMedianBlockTime(); + int medianBlockTime = bitcoiny.getMedianBlockTime(); if (medianBlockTime <= lockTime) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); - String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA); + String p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA); LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA)); // Fee for redeem/refund is subtracted from P2SH-A balance. long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); - long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + long p2shFee = bitcoiny.getP2shFee(feeTimestamp); long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; - BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); switch (htlcStatusA) { case UNFUNDED: @@ -572,18 +616,18 @@ public class CrossChainHtlcResource { case FUNDED:{ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); - // Validate the destination LTC address - Address receiving = Address.fromString(litecoin.getNetworkParameters(), receiveAddress); + // Validate the destination foreign blockchain address + Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress); if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(litecoin.getNetworkParameters(), refundAmount, refundKey, + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey, fundingOutputs, redeemScriptA, lockTime, receiving.getHash()); - litecoin.broadcastTransaction(p2shRefundTransaction); - return true; // TODO: validate? + bitcoiny.broadcastTransaction(p2shRefundTransaction); + return true; } } diff --git a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java index cd8766ca..c3d7e397 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java @@ -107,7 +107,7 @@ public class CrossChainTradeBotResource { ) } ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE}) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE, ApiError.ORDER_SIZE_TOO_SMALL}) @SuppressWarnings("deprecation") public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { Security.checkApiCallAllowed(request); @@ -128,10 +128,13 @@ public class CrossChainTradeBotResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); if (tradeBotCreateRequest.foreignAmount == null || tradeBotCreateRequest.foreignAmount <= 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ORDER_SIZE_TOO_SMALL); + + if (tradeBotCreateRequest.foreignAmount < foreignBlockchain.getMinimumOrderAmount()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ORDER_SIZE_TOO_SMALL); if (tradeBotCreateRequest.qortAmount <= 0 || tradeBotCreateRequest.fundingQortAmount <= 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ORDER_SIZE_TOO_SMALL); try (final Repository repository = RepositoryManager.getRepository()) { // Do some simple checking first @@ -283,4 +286,4 @@ public class CrossChainTradeBotResource { return atData; } -} \ No newline at end of file +} diff --git a/src/main/java/org/qortal/api/resource/UtilsResource.java b/src/main/java/org/qortal/api/resource/UtilsResource.java index cc492a2f..54ea660b 100644 --- a/src/main/java/org/qortal/api/resource/UtilsResource.java +++ b/src/main/java/org/qortal/api/resource/UtilsResource.java @@ -33,7 +33,6 @@ import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transform.Transformer; import org.qortal.transform.transaction.TransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer.Transformation; -import org.qortal.utils.BIP39; import org.qortal.utils.Base58; import com.google.common.hash.HashCode; @@ -195,123 +194,6 @@ public class UtilsResource { return Base58.encode(random); } - @GET - @Path("/mnemonic") - @Operation( - summary = "Generate 12-word BIP39 mnemonic", - description = "Optionally pass 16-byte, base58-encoded entropy or entropy will be internally generated.
" - + "Example entropy input: YcVfxkQb6JRzqk5kF2tNLv", - responses = { - @ApiResponse( - description = "mnemonic", - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string" - ) - ) - ) - } - ) - @ApiErrors({ApiError.NON_PRODUCTION, ApiError.INVALID_DATA}) - public String getMnemonic(@QueryParam("entropy") String suppliedEntropy) { - if (Settings.getInstance().isApiRestricted()) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION); - - /* - * BIP39 word lists have 2048 entries so can be represented by 11 bits. - * UUID (128bits) and another 4 bits gives 132 bits. - * 132 bits, divided by 11, gives 12 words. - */ - byte[] entropy; - if (suppliedEntropy != null) { - // Use caller-supplied entropy input - try { - entropy = Base58.decode(suppliedEntropy); - } catch (NumberFormatException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - } - - // Must be 16-bytes - if (entropy.length != 16) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - } else { - // Generate entropy internally - UUID uuid = UUID.randomUUID(); - - byte[] uuidMSB = Longs.toByteArray(uuid.getMostSignificantBits()); - byte[] uuidLSB = Longs.toByteArray(uuid.getLeastSignificantBits()); - entropy = Bytes.concat(uuidMSB, uuidLSB); - } - - // Use SHA256 to generate more bits - byte[] hash = Crypto.digest(entropy); - - // Append first 4 bits from hash to end. (Actually 8 bits but we only use 4). - byte checksum = (byte) (hash[0] & 0xf0); - entropy = Bytes.concat(entropy, new byte[] { - checksum - }); - - return BIP39.encode(entropy, "en"); - } - - @POST - @Path("/mnemonic") - @Operation( - summary = "Calculate binary entropy from 12-word BIP39 mnemonic", - description = "Returns the base58-encoded binary form, or \"false\" if mnemonic is invalid.", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string" - ) - ) - ), - responses = { - @ApiResponse( - description = "entropy in base58", - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string" - ) - ) - ) - } - ) - @ApiErrors({ApiError.NON_PRODUCTION}) - public String fromMnemonic(String mnemonic) { - if (Settings.getInstance().isApiRestricted()) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION); - - if (mnemonic.isEmpty()) - return "false"; - - // Strip leading/trailing whitespace if any - mnemonic = mnemonic.trim(); - - String[] phraseWords = mnemonic.split(" "); - if (phraseWords.length != 12) - return "false"; - - // Convert BIP39 mnemonic to binary - byte[] binary = BIP39.decode(phraseWords, "en"); - if (binary == null) - return "false"; - - byte[] entropy = Arrays.copyOf(binary, 16); // 132 bits is 16.5 bytes, but we're discarding checksum nybble - - byte checksumNybble = (byte) (binary[16] & 0xf0); - byte[] checksum = Crypto.digest(entropy); - if (checksumNybble != (byte) (checksum[0] & 0xf0)) - return "false"; - - return Base58.encode(entropy); - } - @POST @Path("/privatekey") @Operation( diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 3d2bd48e..8fd9e9da 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -710,6 +710,7 @@ public class Controller extends Thread { hasStatusChanged = true; } } + peer.setSyncInProgress(true); if (hasStatusChanged) updateSysTray(); @@ -789,6 +790,7 @@ public class Controller extends Thread { return syncResult; } finally { isSynchronizing = false; + peer.setSyncInProgress(false); } } @@ -840,6 +842,7 @@ public class Controller extends Thread { private void updateSysTray() { if (NTP.getTime() == null) { SysTray.getInstance().setToolTipText(Translator.INSTANCE.translate("SysTray", "SYNCHRONIZING_CLOCK")); + SysTray.getInstance().setTrayIcon(1); return; } @@ -853,14 +856,22 @@ public class Controller extends Thread { String actionText; synchronized (this.syncLock) { - if (this.isMintingPossible) + if (this.isMintingPossible) { actionText = Translator.INSTANCE.translate("SysTray", "MINTING_ENABLED"); - else if (this.isSynchronizing) + SysTray.getInstance().setTrayIcon(2); + } + else if (this.isSynchronizing) { actionText = String.format("%s - %d%%", Translator.INSTANCE.translate("SysTray", "SYNCHRONIZING_BLOCKCHAIN"), this.syncPercent); - else if (numberOfPeers < Settings.getInstance().getMinBlockchainPeers()) + SysTray.getInstance().setTrayIcon(3); + } + else if (numberOfPeers < Settings.getInstance().getMinBlockchainPeers()) { actionText = Translator.INSTANCE.translate("SysTray", "CONNECTING"); - else + SysTray.getInstance().setTrayIcon(3); + } + else { actionText = Translator.INSTANCE.translate("SysTray", "MINTING_DISABLED"); + SysTray.getInstance().setTrayIcon(4); + } } String tooltip = String.format("%s - %d %s - %s %d", actionText, numberOfPeers, connectionsText, heightText, height) + "\n" + String.format("Build version: %s", this.buildVersion); diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 25d5643f..6ddbad16 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -68,6 +68,9 @@ public class Synchronizer { + // Keep track of the size of the last re-org, so it can be logged + private int lastReorgSize; + private static Synchronizer instance; public enum SynchronizationResult { @@ -515,9 +518,22 @@ public class Synchronizer { byte[] peersLastBlockSignature = peerChainTipData.getLastBlockSignature(); byte[] ourLastBlockSignature = ourLatestBlockData.getSignature(); - LOGGER.debug(String.format("Synchronizing with peer %s at height %d, sig %.8s, ts %d; our height %d, sig %.8s, ts %d", peer, + String syncString = String.format("Synchronizing with peer %s at height %d, sig %.8s, ts %d; our height %d, sig %.8s, ts %d", peer, peerHeight, Base58.encode(peersLastBlockSignature), peer.getChainTipData().getLastBlockTimestamp(), - ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp())); + ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp()); + + // If our latest block is very old, we should log that we're attempting to sync with a peer + // Otherwise, it can appear as though nothing is happening for a while after launch + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp != null && ourLatestBlockData.getTimestamp() < minLatestBlockTimestamp) { + LOGGER.info(syncString); + } + else { + LOGGER.debug(syncString); + } + + // Reset last re-org size as we are starting a new sync round + this.lastReorgSize = 0; List peerBlockSummaries = new ArrayList<>(); SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, force, peerBlockSummaries, true); @@ -576,10 +592,19 @@ public class Synchronizer { // Commit repository.saveChanges(); + // Create string for logging final BlockData newLatestBlockData = repository.getBlockRepository().getLastBlock(); - LOGGER.info(String.format("Synchronized with peer %s to height %d, sig %.8s, ts: %d", peer, + String syncLog = String.format("Synchronized with peer %s to height %d, sig %.8s, ts: %d", peer, newLatestBlockData.getHeight(), Base58.encode(newLatestBlockData.getSignature()), - newLatestBlockData.getTimestamp())); + newLatestBlockData.getTimestamp()); + + // Append re-org info + if (this.lastReorgSize > 0) { + syncLog = syncLog.concat(String.format(", size: %d", this.lastReorgSize)); + } + + // Log sync info + LOGGER.info(syncLog); return SynchronizationResult.OK; } finally { @@ -933,6 +958,7 @@ public class Synchronizer { // Unwind to common block (unless common block is our latest block) int ourHeight = ourInitialHeight; LOGGER.debug(String.format("Orphaning blocks back to common block height %d, sig %.8s. Our height: %d", commonBlockHeight, commonBlockSig58, ourHeight)); + int reorgSize = ourHeight - commonBlockHeight; BlockData orphanBlockData = repository.getBlockRepository().fromHeight(ourInitialHeight); while (ourHeight > commonBlockHeight) { @@ -981,6 +1007,7 @@ public class Synchronizer { Controller.getInstance().onNewBlock(newBlock.getBlockData()); } + this.lastReorgSize = reorgSize; return SynchronizationResult.OK; } diff --git a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java index ca2e2518..790584d3 100644 --- a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java @@ -1033,7 +1033,7 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot { return; } - byte[] secretA = BitcoinACCTv1.findSecretA(repository, crossChainTradeData); + byte[] secretA = BitcoinACCTv1.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; diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java new file mode 100644 index 00000000..516fa621 --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java @@ -0,0 +1,883 @@ +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 DogecoinACCTv1TradeBot implements AcctTradeBot { + + private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv1TradeBot.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 DogecoinACCTv1TradeBot 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 DogecoinACCTv1TradeBot() { + } + + public static synchronized DogecoinACCTv1TradeBot getInstance() { + if (instance == null) + instance = new DogecoinACCTv1TradeBot(); + + 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 DOGE. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' (as in Dogecoin) 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'/Dogecoin public key hash - used by Alice's P2SH scripts to allow redeem
  • + *
  • QORT amount on offer by Bob
  • + *
  • DOGE 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 Dogecoin receiving address into public key hash (we only support P2PKH at this time) + Address dogecoinReceivingAddress; + try { + dogecoinReceivingAddress = Address.fromString(Dogecoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); + } catch (AddressFormatException e) { + throw new DataException("Unsupported Dogecoin receiving address: " + tradeBotCreateRequest.receivingAddress); + } + if (dogecoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH) + throw new DataException("Unsupported Dogecoin receiving address: " + tradeBotCreateRequest.receivingAddress); + + byte[] dogecoinReceivingAccountInfo = dogecoinReceivingAddress.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/DOGE ACCT"; + String description = "QORT/DOGE cross-chain trade"; + String aTType = "ACCT"; + String tags = "ACCT QORT DOGE"; + byte[] creationBytes = DogecoinACCTv1.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, DogecoinACCTv1.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.DOGECOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + tradeBotCreateRequest.foreignAmount, null, null, null, dogecoinReceivingAccountInfo); + + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress)); + + // Attempt to backup the trade bot data + TradeBot.backupTradeBotData(repository); + + // 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 DOGE to an existing offer. + *

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

+ * If the Dogecoin 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 Dogecoin 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, DogecoinACCTv1.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.DOGECOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash); + + // Attempt to backup the trade bot data + TradeBot.backupTradeBotData(repository); + + // Check we have enough funds via xprv58 to fund P2SH to cover expectedForeignAmount + long p2shFee; + try { + p2shFee = Dogecoin.getInstance().getP2shFee(now); + } catch (ForeignBlockchainException e) { + LOGGER.debug("Couldn't estimate Dogecoin 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 = Dogecoin.getInstance().deriveP2shAddress(redeemScriptBytes); + + // Build transaction for funding P2SH-A + Transaction p2shFundingTransaction = Dogecoin.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 { + Dogecoin.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 = DogecoinACCTv1.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: + 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 = DogecoinACCTv1.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 Dogecoin 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; + } + + Dogecoin dogecoin = Dogecoin.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 Dogecoin pubkeyhash and lockTime-A + byte[] messageData = messageTransactionData.getData(); + DogecoinACCTv1.OfferMessageData offerMessageData = DogecoinACCTv1.extractOfferMessageData(messageData); + if (offerMessageData == null) + continue; + + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerDogecoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + long messageTimestamp = messageTransactionData.getTimestamp(); + int refundTimeout = DogecoinACCTv1.calcRefundTimeout(messageTimestamp, lockTimeA); + + // Determine P2SH-A address and confirm funded + byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); + String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); + final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.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 = DogecoinACCTv1.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 DOGE 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; + + Dogecoin dogecoin = Dogecoin.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 = dogecoin.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.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 = DogecoinACCTv1.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 = DogecoinACCTv1.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 DOGE 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 DOGE funds from P2SH-A + * to Bob's 'foreign'/Dogecoin trade legacy-format address, as derived from trade private key. + *

+ * (This could potentially be 'improved' to send DOGE 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 DOGE + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + + return; + } + + byte[] secretA = DogecoinACCTv1.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 + + Dogecoin dogecoin = Dogecoin.getInstance(); + + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + int lockTimeA = crossChainTradeData.lockTimeA; + byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.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 = dogecoin.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(dogecoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); + + dogecoin.broadcastTransaction(p2shRedeemTransaction); + break; + } + } + + String receivingAddress = dogecoin.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; + + Dogecoin dogecoin = Dogecoin.getInstance(); + + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = dogecoin.getMedianBlockTime(); + if (medianBlockTime <= lockTimeA) + return; + + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.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 = dogecoin.getUnspentOutputs(p2shAddressA); + + // Determine receive address for refund + String receiveAddress = dogecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(dogecoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(dogecoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash()); + + dogecoin.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/LitecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java index 0bd2972b..0246c199 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java @@ -725,7 +725,7 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot { return; } - byte[] secretA = LitecoinACCTv1.findSecretA(repository, crossChainTradeData); + byte[] secretA = LitecoinACCTv1.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; diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index fa3b599e..6e9d1474 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -17,11 +17,7 @@ import org.qortal.account.PrivateKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; import org.qortal.controller.Controller; import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult; -import org.qortal.crosschain.ACCT; -import org.qortal.crosschain.BitcoinACCTv1; -import org.qortal.crosschain.ForeignBlockchainException; -import org.qortal.crosschain.LitecoinACCTv1; -import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crosschain.*; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; @@ -80,6 +76,7 @@ public class TradeBot implements Listener { static { acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance); acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance); + acctTradeBotSuppliers.put(DogecoinACCTv1.class, DogecoinACCTv1TradeBot::getInstance); } private static TradeBot instance; diff --git a/src/main/java/org/qortal/crosschain/ACCT.java b/src/main/java/org/qortal/crosschain/ACCT.java index e557a3e2..de28cfce 100644 --- a/src/main/java/org/qortal/crosschain/ACCT.java +++ b/src/main/java/org/qortal/crosschain/ACCT.java @@ -20,4 +20,6 @@ public interface ACCT { public byte[] buildCancelMessage(String creatorQortalAddress); + public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException; + } diff --git a/src/main/java/org/qortal/crosschain/Bitcoin.java b/src/main/java/org/qortal/crosschain/Bitcoin.java index 28275d6a..2ce21d2f 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoin.java +++ b/src/main/java/org/qortal/crosschain/Bitcoin.java @@ -67,7 +67,11 @@ public class Bitcoin extends Bitcoiny { new Server("192.166.219.200", Server.ConnectionType.SSL, 50002), new Server("2ex.digitaleveryware.com", Server.ConnectionType.SSL, 50002), new Server("dxm.no-ip.biz", Server.ConnectionType.SSL, 50002), - new Server("caleb.vegas", Server.ConnectionType.SSL, 50002)); + new Server("caleb.vegas", Server.ConnectionType.SSL, 50002), + new Server("ecdsa.net", Server.ConnectionType.SSL, 110), + new Server("electrum.hsmiths.com", Server.ConnectionType.SSL, 995), + new Server("elec.luggs.co", Server.ConnectionType.SSL, 443), + new Server("btc.smsys.me", Server.ConnectionType.SSL, 995)); } @Override diff --git a/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java index 5118e103..eea541ad 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java +++ b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java @@ -872,7 +872,8 @@ public class BitcoinACCTv1 implements ACCT { return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L); } - public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + @Override + public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { String atAddress = crossChainTradeData.qortalAtAddress; String redeemerAddress = crossChainTradeData.qortalPartnerAddress; diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index fc98f959..d4693818 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -169,6 +169,11 @@ public abstract class Bitcoiny implements ForeignBlockchain { return this.bitcoinjContext.getFeePerKb(); } + /** Returns minimum order size in sats. To be overridden for coins that need to restrict order size. */ + public long getMinimumOrderAmount() { + return 0L; + } + /** * Returns fixed P2SH spending fee, in sats per 1000bytes, optionally for historic timestamp. * @@ -346,6 +351,10 @@ public abstract class Bitcoiny implements ForeignBlockchain { Set walletTransactions = new HashSet<>(); Set keySet = new HashSet<>(); + // Set the number of consecutive empty batches required before giving up + final int numberOfAdditionalBatchesToSearch = 5; + + int unusedCounter = 0; int ki = 0; do { boolean areAllKeysUnused = true; @@ -369,9 +378,19 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } - if (areAllKeysUnused) - // No transactions for this batch of keys so assume we're done searching. - break; + if (areAllKeysUnused) { + // No transactions + if (unusedCounter >= numberOfAdditionalBatchesToSearch) { + // ... and we've hit our search limit + break; + } + // We haven't hit our search limit yet so increment the counter and keep looking + unusedCounter++; + } + else { + // Some keys in this batch were used, so reset the counter + unusedCounter = 0; + } // Generate some more keys keys.addAll(generateMoreKeys(keyChain)); diff --git a/src/main/java/org/qortal/crosschain/Dogecoin.java b/src/main/java/org/qortal/crosschain/Dogecoin.java new file mode 100644 index 00000000..4acd95aa --- /dev/null +++ b/src/main/java/org/qortal/crosschain/Dogecoin.java @@ -0,0 +1,171 @@ +package org.qortal.crosschain; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Context; +import org.bitcoinj.core.NetworkParameters; +import org.libdohj.params.DogecoinMainNetParams; +//import org.libdohj.params.DogecoinRegTestParams; +import org.libdohj.params.DogecoinTestNet3Params; +import org.qortal.crosschain.ElectrumX.Server; +import org.qortal.crosschain.ElectrumX.Server.ConnectionType; +import org.qortal.settings.Settings; + +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; + +public class Dogecoin extends Bitcoiny { + + public static final String CURRENCY_CODE = "DOGE"; + + private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(500000000); // 5 DOGE per 1000 bytes + + private static final long MINIMUM_ORDER_AMOUNT = 300000000L; // 3 DOGE minimum order. The RPC dust threshold is around 2 DOGE + + // Temporary values until a dynamic fee system is written. + private static final long MAINNET_FEE = 110000000L; + private static final long NON_MAINNET_FEE = 10000L; // TODO: calibrate this + + 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 DogecoinNet { + MAIN { + @Override + public NetworkParameters getParams() { + return DogecoinMainNetParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + // Servers chosen on NO BASIS WHATSOEVER from various sources! + new Server("electrum1.cipig.net", ConnectionType.TCP, 10060), + new Server("electrum2.cipig.net", ConnectionType.TCP, 10060), + new Server("electrum3.cipig.net", ConnectionType.TCP, 10060)); + // TODO: add more mainnet servers. It's too centralized. + } + + @Override + public String getGenesisHash() { + return "1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691"; + } + + @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 DogecoinTestNet3Params.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList(); // TODO: find testnet servers + } + + @Override + public String getGenesisHash() { + return "4966625a4b2851d9fdee139e56211a0d88575f59ed816ff5e6a63deb4e3e29a0"; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }, + REGTEST { + @Override + public NetworkParameters getParams() { + return null; // TODO: DogecoinRegTestParams.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 Dogecoin instance; + + private final DogecoinNet dogecoinNet; + + // Constructors and instance + + private Dogecoin(DogecoinNet dogecoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) { + super(blockchain, bitcoinjContext, currencyCode); + this.dogecoinNet = dogecoinNet; + + LOGGER.info(() -> String.format("Starting Dogecoin support using %s", this.dogecoinNet.name())); + } + + public static synchronized Dogecoin getInstance() { + if (instance == null) { + DogecoinNet dogecoinNet = Settings.getInstance().getDogecoinNet(); + + BitcoinyBlockchainProvider electrumX = new ElectrumX("Dogecoin-" + dogecoinNet.name(), dogecoinNet.getGenesisHash(), dogecoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS); + Context bitcoinjContext = new Context(dogecoinNet.getParams()); + + instance = new Dogecoin(dogecoinNet, 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 DOGE 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.dogecoinNet.getP2shFee(timestamp); + } + +} diff --git a/src/main/java/org/qortal/crosschain/DogecoinACCTv1.java b/src/main/java/org/qortal/crosschain/DogecoinACCTv1.java new file mode 100644 index 00000000..36ff7c5c --- /dev/null +++ b/src/main/java/org/qortal/crosschain/DogecoinACCTv1.java @@ -0,0 +1,855 @@ +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 Dogecoin & 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 Dogecoin & Qortal 'trade' keys
    • + *
    • Alice funds Dogecoin P2SH-A
    • + *
    • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: + *
        + *
      • hash-of-secret-A
      • + *
      • her 'trade' Dogecoin 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 Dogecoin 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 Dogecoin trade key and secret-A
    • + *
    • P2SH-A DOGE funds end up at Dogecoin address determined by redeem transaction output(s)
    • + *
    + *
  • + *
+ */ +public class DogecoinACCTv1 implements ACCT { + + private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv1.class); + + public static final String NAME = DogecoinACCTv1.class.getSimpleName(); + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0eb49b0313ff3855a29d860c2a8203faa2ef62e28ea30459321f176079cfa3a5").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[] partnerDogecoinPKH; + public byte[] hashOfSecretA; + public long lockTimeA; + } + public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerDogecoinPKH*/ + 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 Dogecoin 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 DogecoinACCTv1 instance; + + private DogecoinACCTv1() { + } + + public static synchronized DogecoinACCTv1 getInstance() { + if (instance == null) + instance = new DogecoinACCTv1(); + + return instance; + } + + @Override + public byte[] getCodeBytesHash() { + return CODE_BYTES_HASH; + } + + @Override + public int getModeByteOffset() { + return MODE_BYTE_OFFSET; + } + + @Override + public ForeignBlockchain getBlockchain() { + return Dogecoin.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 dogecoinPublicKeyHash 20-byte HASH160 of creator's trade Dogecoin public key + * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT + * @param dogecoinAmount how much DOGE the AT creator is expecting to trade + * @param tradeTimeout suggested timeout for entire trade + */ + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] dogecoinPublicKeyHash, long qortAmount, long dogecoinAmount, int tradeTimeout) { + if (dogecoinPublicKeyHash.length != 20) + throw new IllegalArgumentException("Dogecoin 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 addrDogecoinPublicKeyHash = addrCounter; + addrCounter += 4; + + final int addrQortAmount = addrCounter++; + final int addrDogecoinAmount = 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 addrTradeMessagePartnerDogecoinPKHOffset = addrCounter++; + final int addrPartnerDogecoinPKHPointer = 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 addrPartnerDogecoinPKH = 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)); + + // Dogecoin public key hash + assert dataByteBuffer.position() == addrDogecoinPublicKeyHash * MachineState.VALUE_SIZE : "addrDogecoinPublicKeyHash incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(dogecoinPublicKeyHash, 32, 0)); + + // Redeem Qort amount + assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; + dataByteBuffer.putLong(qortAmount); + + // Expected Dogecoin amount + assert dataByteBuffer.position() == addrDogecoinAmount * MachineState.VALUE_SIZE : "addrDogecoinAmount incorrect"; + dataByteBuffer.putLong(dogecoinAmount); + + // 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 Dogecoin PKH + assert dataByteBuffer.position() == addrTradeMessagePartnerDogecoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerDogecoinPKHOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Index into data segment of partner's Dogecoin PKH, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerDogecoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerDogecoinPKHPointer incorrect"; + dataByteBuffer.putLong(addrPartnerDogecoinPKH); + + // 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 */ + + /* NOP - to ensure DOGECOIN ACCT is unique */ + codeByteBuffer.put(OpCode.NOP.compile()); + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxnTimestamp)); + + // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4 + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */ + + /* Transaction processing loop */ + labelTradeTxnLoop = codeByteBuffer.position(); + + // 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 Dogecoin 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, addrTradeMessagePartnerDogecoinPKHOffset)); + // Store partner's Dogecoin PKH (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerDogecoinPKHPointer)); + // 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 DOGE-QORT ACCT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + assert Arrays.equals(Crypto.digest(codeBytes), DogecoinACCTv1.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.DOGECOIN.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 Dogecoin/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 DOGE 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 Dogecoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's Dogecoin 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 Dogecoin PKH + byte[] partnerDogecoinPKH = new byte[20]; + dataByteBuffer.get(partnerDogecoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerDogecoinPKH.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 = partnerDogecoinPKH; + 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.partnerDogecoinPKH = 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/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index b34aa199..8f41ed86 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -33,6 +33,7 @@ import org.qortal.crypto.TrustlessSSLSocketFactory; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; +import org.qortal.utils.BitTwiddling; /** ElectrumX network support for querying Bitcoiny-related info like block headers, transaction outputs, etc. */ public class ElectrumX extends BitcoinyBlockchainProvider { @@ -171,13 +172,41 @@ public class ElectrumX extends BitcoinyBlockchainProvider { Long returnedCount = (Long) countObj; String hex = (String) hexObj; - byte[] raw = HashCode.fromString(hex).asBytes(); - if (raw.length != returnedCount * BLOCK_HEADER_LENGTH) - throw new ForeignBlockchainException.NetworkException("Unexpected raw header length in JSON from ElectrumX blockchain.block.headers RPC"); - List rawBlockHeaders = new ArrayList<>(returnedCount.intValue()); - for (int i = 0; i < returnedCount; ++i) - rawBlockHeaders.add(Arrays.copyOfRange(raw, i * BLOCK_HEADER_LENGTH, (i + 1) * BLOCK_HEADER_LENGTH)); + + byte[] raw = HashCode.fromString(hex).asBytes(); + + // Most chains use a fixed length 80 byte header, so block headers can be split up by dividing the hex into + // 80-byte segments. However, some chains such as DOGE use variable length headers due to AuxPoW or other + // reasons. In these cases we can identify the start of each block header by the location of the block version + // numbers. Each block starts with a version number, and for DOGE this is easily identifiable (6422788) at the + // time of writing (Jul 2021). If we encounter a chain that is using more generic version numbers (e.g. 1) + // and can't be used to accurately identify block indexes, then there are sufficient checks to ensure an + // exception is thrown. + + if (raw.length == returnedCount * BLOCK_HEADER_LENGTH) { + // Fixed-length header (BTC, LTC, etc) + for (int i = 0; i < returnedCount; ++i) { + rawBlockHeaders.add(Arrays.copyOfRange(raw, i * BLOCK_HEADER_LENGTH, (i + 1) * BLOCK_HEADER_LENGTH)); + } + } + else if (raw.length > returnedCount * BLOCK_HEADER_LENGTH) { + // Assume AuxPoW variable length header (DOGE) + int referenceVersion = BitTwiddling.intFromLEBytes(raw, 0); // DOGE uses 6422788 at time of commit (Jul 2021) + for (int i = 0; i < raw.length - 4; ++i) { + // Locate the start of each block by its version number + if (BitTwiddling.intFromLEBytes(raw, i) == referenceVersion) { + rawBlockHeaders.add(Arrays.copyOfRange(raw, i, i + BLOCK_HEADER_LENGTH)); + } + } + // Ensure that we found the correct number of block headers + if (rawBlockHeaders.size() != count) { + throw new ForeignBlockchainException.NetworkException("Unexpected raw header contents in JSON from ElectrumX blockchain.block.headers RPC."); + } + } + else if (raw.length != returnedCount * BLOCK_HEADER_LENGTH) { + throw new ForeignBlockchainException.NetworkException("Unexpected raw header length in JSON from ElectrumX blockchain.block.headers RPC"); + } return rawBlockHeaders; } @@ -518,6 +547,7 @@ public class ElectrumX extends BitcoinyBlockchainProvider { } // Failed to perform RPC - maybe lack of servers? + LOGGER.info("Error: No connected Electrum servers when trying to make RPC call"); throw new ForeignBlockchainException.NetworkException(String.format("Failed to perform ElectrumX RPC %s", method)); } } diff --git a/src/main/java/org/qortal/crosschain/ForeignBlockchain.java b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java index 0a71e9d9..fe64ab83 100644 --- a/src/main/java/org/qortal/crosschain/ForeignBlockchain.java +++ b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java @@ -6,4 +6,6 @@ public interface ForeignBlockchain { public boolean isValidWalletKey(String walletKey); + public long getMinimumOrderAmount(); + } diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 5cbe4044..0c04243c 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -51,7 +51,10 @@ public class Litecoin extends Bitcoiny { new Server("ltc.rentonisk.com", Server.ConnectionType.TCP, 50001), new Server("ltc.rentonisk.com", Server.ConnectionType.SSL, 50002), new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002), - new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022)); + new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022), + new Server("electrum-ltc-bysh.me", Server.ConnectionType.TCP, 50002), + new Server("electrum.jochen-hoenicke.de", Server.ConnectionType.TCP, 50005), + new Server("node.ispol.sk", Server.ConnectionType.TCP, 50004)); } @Override diff --git a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java index 454e80c2..efd7043e 100644 --- a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java +++ b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java @@ -810,7 +810,8 @@ public class LitecoinACCTv1 implements ACCT { return (int) ((lockTimeA - (offerMessageTimestamp / 1000L)) / 2L / 60L); } - public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + @Override + public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { String atAddress = crossChainTradeData.qortalAtAddress; String redeemerAddress = crossChainTradeData.qortalPartnerAddress; diff --git a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java index 7b6f91f5..1fc8d149 100644 --- a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java +++ b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java @@ -39,6 +39,20 @@ public enum SupportedBlockchain { public ACCT getLatestAcct() { return LitecoinACCTv1.getInstance(); } + }, + + DOGECOIN(Arrays.asList( + Triple.valueOf(DogecoinACCTv1.NAME, DogecoinACCTv1.CODE_BYTES_HASH, DogecoinACCTv1::getInstance) + )) { + @Override + public ForeignBlockchain getInstance() { + return Dogecoin.getInstance(); + } + + @Override + public ACCT getLatestAcct() { + return DogecoinACCTv1.getInstance(); + } }; private static final Map> supportedAcctsByCodeHash = Arrays.stream(SupportedBlockchain.values()) @@ -110,4 +124,4 @@ public enum SupportedBlockchain { return acctInstanceSupplier.get(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/qortal/gui/SplashFrame.java b/src/main/java/org/qortal/gui/SplashFrame.java index e0859030..37d20ec5 100644 --- a/src/main/java/org/qortal/gui/SplashFrame.java +++ b/src/main/java/org/qortal/gui/SplashFrame.java @@ -1,15 +1,11 @@ package org.qortal.gui; -import java.awt.BorderLayout; -import java.awt.Image; +import java.awt.*; import java.util.ArrayList; import java.util.List; -import java.awt.Dimension; -import java.awt.Graphics; import java.awt.image.BufferedImage; -import javax.swing.JDialog; -import javax.swing.JPanel; +import javax.swing.*; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -19,46 +15,53 @@ public class SplashFrame { protected static final Logger LOGGER = LogManager.getLogger(SplashFrame.class); private static SplashFrame instance; - private JDialog splashDialog; + private JFrame splashDialog; @SuppressWarnings("serial") public static class SplashPanel extends JPanel { private BufferedImage image; + private String defaultSplash = "Qlogo_512.png"; + public SplashPanel() { - image = Gui.loadImage("splash.png"); - this.setPreferredSize(new Dimension(image.getWidth(), image.getHeight())); - this.setLayout(new BorderLayout()); + image = Gui.loadImage(defaultSplash); + + setOpaque(false); + setLayout(new GridBagLayout()); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); - g.drawImage(image, 0, 0, null); + g.drawImage(image, 0, 0, getWidth(), getHeight(), this); + } + + @Override + public Dimension getPreferredSize() { + return new Dimension(500, 500); } } private SplashFrame() { - this.splashDialog = new JDialog(); + this.splashDialog = new JFrame(); List icons = new ArrayList<>(); icons.add(Gui.loadImage("icons/icon16.png")); - icons.add(Gui.loadImage("icons/icon32.png")); + icons.add(Gui.loadImage("icons/qortal_ui_tray_synced.png")); + icons.add(Gui.loadImage("icons/qortal_ui_tray_syncing_time-alt.png")); + icons.add(Gui.loadImage("icons/qortal_ui_tray_minting.png")); + icons.add(Gui.loadImage("icons/qortal_ui_tray_syncing.png")); icons.add(Gui.loadImage("icons/icon64.png")); - icons.add(Gui.loadImage("icons/icon128.png")); + icons.add(Gui.loadImage("icons/Qlogo_128.png")); this.splashDialog.setIconImages(icons); - this.splashDialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE); - this.splashDialog.setTitle("qortal"); - this.splashDialog.setContentPane(new SplashPanel()); - + this.splashDialog.getContentPane().add(new SplashPanel()); + this.splashDialog.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); this.splashDialog.setUndecorated(true); - this.splashDialog.setModal(false); this.splashDialog.pack(); this.splashDialog.setLocationRelativeTo(null); - this.splashDialog.toFront(); + this.splashDialog.setBackground(new Color(0,0,0,0)); this.splashDialog.setVisible(true); - this.splashDialog.repaint(); } public static SplashFrame getInstance() { diff --git a/src/main/java/org/qortal/gui/SysTray.java b/src/main/java/org/qortal/gui/SysTray.java index c456d6fe..6fc994bf 100644 --- a/src/main/java/org/qortal/gui/SysTray.java +++ b/src/main/java/org/qortal/gui/SysTray.java @@ -61,7 +61,7 @@ public class SysTray { this.popupMenu = createJPopupMenu(); // Build TrayIcon without AWT PopupMenu (which doesn't support Unicode)... - this.trayIcon = new TrayIcon(Gui.loadImage("icons/icon32.png"), "qortal", null); + this.trayIcon = new TrayIcon(Gui.loadImage("icons/qortal_ui_tray_synced.png"), "qortal", null); // ...and attach mouse listener instead so we can use JPopupMenu (which does support Unicode) this.trayIcon.addMouseListener(new MouseAdapter() { @Override @@ -289,6 +289,25 @@ public class SysTray { this.trayIcon.setToolTip(text); } + public void setTrayIcon(int iconid) { + if (trayIcon != null) { + switch (iconid) { + case 1: + this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_syncing_time-alt.png")); + break; + case 2: + this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_minting.png")); + break; + case 3: + this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_syncing.png")); + break; + case 4: + this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_synced.png")); + break; + } + } + } + public void dispose() { if (trayIcon != null) SystemTray.getSystemTray().remove(this.trayIcon); diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index 772a96d5..c4fd10d9 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -72,7 +72,8 @@ public class Network { private static final String[] INITIAL_PEERS = new String[]{ "node1.qortal.org", "node2.qortal.org", "node3.qortal.org", "node4.qortal.org", "node5.qortal.org", "node6.qortal.org", "node7.qortal.org", "node8.qortal.org", "node9.qortal.org", "node10.qortal.org", - "node.qortal.ru", "node2.qortal.ru", "node3.qortal.ru", "node.qortal.uk" + "node.qortal.ru", "node2.qortal.ru", "node3.qortal.ru", "node.qortal.uk", "node22.qortal.org", + "cinfu1.crowetic.com", "node.cwd.systems" }; private static final long NETWORK_EPC_KEEPALIVE = 10L; // seconds @@ -80,6 +81,8 @@ public class Network { public static final int MAX_SIGNATURES_PER_REPLY = 500; public static final int MAX_BLOCK_SUMMARIES_PER_REPLY = 500; + private static final long DISCONNECTION_CHECK_INTERVAL = 10 * 1000L; // milliseconds + // Generate our node keys / ID private final Ed25519PrivateKeyParameters edPrivateKeyParams = new Ed25519PrivateKeyParameters(new SecureRandom()); private final Ed25519PublicKeyParameters edPublicKeyParams = edPrivateKeyParams.generatePublicKey(); @@ -89,6 +92,8 @@ public class Network { private final int minOutboundPeers; private final int maxPeers; + private long nextDisconnectionCheck = 0L; + private final List allKnownPeers = new ArrayList<>(); private final List connectedPeers = new ArrayList<>(); private final List selfPeers = new ArrayList<>(); @@ -611,6 +616,8 @@ public class Network { // 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); + + this.checkLongestConnection(now); } // Any left? @@ -668,6 +675,29 @@ public class Network { return null; } + private void checkLongestConnection(Long now) { + if (now == null || now < nextDisconnectionCheck) { + return; + } + + // Find peers that have reached their maximum connection age, and disconnect them + List peersToDisconnect = this.connectedPeers.stream() + .filter(peer -> !peer.isSyncInProgress()) + .filter(peer -> peer.hasReachedMaxConnectionAge()) + .collect(Collectors.toList()); + + if (peersToDisconnect != null && peersToDisconnect.size() > 0) { + for (Peer peer : peersToDisconnect) { + LOGGER.info("Forcing disconnection of peer {} because connection age ({} ms) " + + "has reached the maximum ({} ms)", peer, peer.getConnectionAge(), peer.getMaxConnectionAge()); + peer.disconnect("Connection age too old"); + } + } + + // Check again after a minimum fixed interval + nextDisconnectionCheck = now + DISCONNECTION_CHECK_INTERVAL; + } + // Peer callbacks protected void wakeupChannelSelector() { diff --git a/src/main/java/org/qortal/network/Peer.java b/src/main/java/org/qortal/network/Peer.java index bb6dd148..8763c114 100644 --- a/src/main/java/org/qortal/network/Peer.java +++ b/src/main/java/org/qortal/network/Peer.java @@ -84,6 +84,7 @@ public class Peer { private Handshake handshakeStatus = Handshake.STARTED; private volatile boolean handshakeMessagePending = false; private long handshakeComplete = -1L; + private long maxConnectionAge = 0L; /** * Timestamp of when socket was accepted, or connected. @@ -101,6 +102,8 @@ public class Peer { byte[] ourChallenge; + private boolean syncInProgress = false; + // Versioning public static final Pattern VERSION_PATTERN = Pattern.compile(Controller.VERSION_PREFIX + "(\\d{1,3})\\.(\\d{1,5})\\.(\\d{1,5})"); @@ -197,10 +200,24 @@ public class Peer { this.handshakeStatus = handshakeStatus; if (handshakeStatus.equals(Handshake.COMPLETED)) { this.handshakeComplete = System.currentTimeMillis(); + this.generateRandomMaxConnectionAge(); } } } + private void generateRandomMaxConnectionAge() { + // Retrieve the min and max connection time from the settings, and calculate the range + final int minPeerConnectionTime = Settings.getInstance().getMinPeerConnectionTime(); + final int maxPeerConnectionTime = Settings.getInstance().getMaxPeerConnectionTime(); + final int peerConnectionTimeRange = maxPeerConnectionTime - minPeerConnectionTime; + + // Generate a random number between the min and the max + Random random = new Random(); + this.maxConnectionAge = (random.nextInt(peerConnectionTimeRange) + minPeerConnectionTime) * 1000L; + LOGGER.debug(String.format("[%s] Generated max connection age for peer %s. Min: %ds, max: %ds, range: %ds, random max: %dms", this.peerConnectionId, this, minPeerConnectionTime, maxPeerConnectionTime, peerConnectionTimeRange, this.maxConnectionAge)); + + } + protected void resetHandshakeMessagePending() { this.handshakeMessagePending = false; } @@ -330,6 +347,14 @@ public class Peer { } } + public boolean isSyncInProgress() { + return this.syncInProgress; + } + + public void setSyncInProgress(boolean syncInProgress) { + this.syncInProgress = syncInProgress; + } + @Override public String toString() { // Easier, and nicer output, than peer.getRemoteSocketAddress() @@ -812,4 +837,12 @@ public class Peer { } return handshakeComplete; } + + public long getMaxConnectionAge() { + return maxConnectionAge; + } + + public boolean hasReachedMaxConnectionAge() { + return this.getConnectionAge() > this.getMaxConnectionAge(); + } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 09c6a6d4..4d8e5043 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -55,7 +55,7 @@ public class HSQLDBRepository implements Repository { private static final Logger LOGGER = LogManager.getLogger(HSQLDBRepository.class); - private static final Object CHECKPOINT_LOCK = new Object(); + public static final Object CHECKPOINT_LOCK = new Object(); // "serialization failure" private static final Integer DEADLOCK_ERROR_CODE = Integer.valueOf(-4861); @@ -703,8 +703,11 @@ public class HSQLDBRepository implements Repository { private ResultSet checkedExecuteResultSet(PreparedStatement preparedStatement, Object... objects) throws SQLException { bindStatementParams(preparedStatement, objects); - if (!preparedStatement.execute()) - throw new SQLException("Fetching from database produced no results"); + // synchronize to block new executions if checkpointing in progress + synchronized (CHECKPOINT_LOCK) { + if (!preparedStatement.execute()) + throw new SQLException("Fetching from database produced no results"); + } ResultSet resultSet = preparedStatement.getResultSet(); if (resultSet == null) @@ -1056,4 +1059,4 @@ public class HSQLDBRepository implements Repository { return DEADLOCK_ERROR_CODE.equals(e.getErrorCode()); } -} \ No newline at end of file +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java index c1b6ee9b..acf24c54 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java @@ -61,13 +61,15 @@ public class HSQLDBSaver { public boolean execute(HSQLDBRepository repository) throws SQLException { String sql = this.formatInsertWithPlaceholders(); - try { - PreparedStatement preparedStatement = repository.prepareStatement(sql); - this.bindValues(preparedStatement); + synchronized (HSQLDBRepository.CHECKPOINT_LOCK) { + try { + PreparedStatement preparedStatement = repository.prepareStatement(sql); + this.bindValues(preparedStatement); - return preparedStatement.execute(); - } catch (SQLException e) { - throw repository.examineException(e); + return preparedStatement.execute(); + } catch (SQLException e) { + throw repository.examineException(e); + } } } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 7c26fc22..7cd0f941 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -26,6 +26,7 @@ import org.eclipse.persistence.jaxb.UnmarshallerProperties; import org.qortal.block.BlockChain; import org.qortal.crosschain.Bitcoin.BitcoinNet; import org.qortal.crosschain.Litecoin.LitecoinNet; +import org.qortal.crosschain.Dogecoin.DogecoinNet; // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) @@ -144,6 +145,11 @@ public class Settings { * If false, sync will be blocked both ways, and they will not appear in the peers list */ private boolean allowConnectionsWithOlderPeerVersions = true; + /** Minimum time (in seconds) that we should attempt to remain connected to a peer for */ + private int minPeerConnectionTime = 2 * 60; // seconds + /** Maximum time (in seconds) that we should attempt to remain connected to a peer for */ + private int maxPeerConnectionTime = 20 * 60; // seconds + /** Whether to sync multiple blocks at once in normal operation */ private boolean fastSyncEnabled = true; /** Whether to sync multiple blocks at once when the peer has a different chain */ @@ -159,6 +165,7 @@ public class Settings { private String blockchainConfig = null; // use default from resources private BitcoinNet bitcoinNet = BitcoinNet.MAIN; private LitecoinNet litecoinNet = LitecoinNet.MAIN; + private DogecoinNet dogecoinNet = DogecoinNet.MAIN; // Also crosschain-related: /** Whether to show SysTray pop-up notifications when trade-bot entries change state */ private boolean tradebotSystrayEnabled = false; @@ -507,6 +514,10 @@ public class Settings { public boolean getAllowConnectionsWithOlderPeerVersions() { return this.allowConnectionsWithOlderPeerVersions; } + public int getMinPeerConnectionTime() { return this.minPeerConnectionTime; } + + public int getMaxPeerConnectionTime() { return this.maxPeerConnectionTime; } + public String getBlockchainConfig() { return this.blockchainConfig; } @@ -519,6 +530,10 @@ public class Settings { return this.litecoinNet; } + public DogecoinNet getDogecoinNet() { + return this.dogecoinNet; + } + public boolean isTradebotSystrayEnabled() { return this.tradebotSystrayEnabled; } diff --git a/src/main/java/org/qortal/utils/BIP39.java b/src/main/java/org/qortal/utils/BIP39.java deleted file mode 100644 index 488396eb..00000000 --- a/src/main/java/org/qortal/utils/BIP39.java +++ /dev/null @@ -1,86 +0,0 @@ -package org.qortal.utils; - -import java.util.ArrayList; -import java.util.List; - -import org.qortal.globalization.BIP39WordList; - -public class BIP39 { - - private static final int BITS_PER_WORD = 11; - - /** Convert BIP39 mnemonic to binary 'entropy' */ - public static byte[] decode(String[] phraseWords, String lang) { - if (lang == null) - lang = "en"; - - List wordList = BIP39WordList.INSTANCE.getByLang(lang); - if (wordList == null) - throw new IllegalStateException("BIP39 word list for lang '" + lang + "' unavailable"); - - byte[] entropy = new byte[(phraseWords.length * BITS_PER_WORD + 7) / 8]; - int byteIndex = 0; - int bitShift = 3; - - for (int i = 0; i < phraseWords.length; ++i) { - int wordListIndex = wordList.indexOf(phraseWords[i]); - if (wordListIndex == -1) - // Word not found - return null; - - entropy[byteIndex++] |= (byte) (wordListIndex >> bitShift); - - bitShift = 8 - bitShift; - if (bitShift >= 0) { - // Leftover fits inside one byte - entropy[byteIndex] |= (byte) ((wordListIndex << bitShift)); - bitShift = BITS_PER_WORD - bitShift; - } else { - // Leftover spread over next two bytes - bitShift = - bitShift; - entropy[byteIndex++] |= (byte) (wordListIndex >> bitShift); - - entropy[byteIndex] |= (byte) (wordListIndex << (8 - bitShift)); - bitShift = bitShift + BITS_PER_WORD - 8; - } - } - - return entropy; - } - - /** Convert binary entropy to BIP39 mnemonic */ - public static String encode(byte[] entropy, String lang) { - if (lang == null) - lang = "en"; - - List wordList = BIP39WordList.INSTANCE.getByLang(lang); - if (wordList == null) - throw new IllegalStateException("BIP39 word list for lang '" + lang + "' unavailable"); - - List phraseWords = new ArrayList<>(); - - int bitMask = 128; // MSB first - int byteIndex = 0; - while (true) { - int wordListIndex = 0; - for (int bitCount = 0; bitCount < BITS_PER_WORD; ++bitCount) { - wordListIndex <<= 1; - - if ((entropy[byteIndex] & bitMask) != 0) - ++wordListIndex; - - bitMask >>= 1; - if (bitMask == 0) { - bitMask = 128; - ++byteIndex; - - if (byteIndex >= entropy.length) - return String.join(" ", phraseWords); - } - } - - phraseWords.add(wordList.get(wordListIndex)); - } - } - -} diff --git a/src/main/resources/BIP39/wordlist_en.txt b/src/main/resources/BIP39/wordlist_en.txt deleted file mode 100644 index 942040ed..00000000 --- a/src/main/resources/BIP39/wordlist_en.txt +++ /dev/null @@ -1,2048 +0,0 @@ -abandon -ability -able -about -above -absent -absorb -abstract -absurd -abuse -access -accident -account -accuse -achieve -acid -acoustic -acquire -across -act -action -actor -actress -actual -adapt -add -addict -address -adjust -admit -adult -advance -advice -aerobic -affair -afford -afraid -again -age -agent -agree -ahead -aim -air -airport -aisle -alarm -album -alcohol -alert -alien -all -alley -allow -almost -alone -alpha -already -also -alter -always -amateur -amazing -among -amount -amused -analyst -anchor -ancient -anger -angle -angry -animal -ankle -announce -annual -another -answer -antenna -antique -anxiety -any -apart -apology -appear -apple -approve -april -arch -arctic -area -arena -argue -arm -armed -armor -army -around -arrange -arrest -arrive -arrow -art -artefact -artist -artwork -ask -aspect -assault -asset -assist -assume -asthma -athlete -atom -attack -attend -attitude -attract -auction -audit -august -aunt -author -auto -autumn -average -avocado -avoid -awake -aware -away -awesome -awful -awkward -axis -baby -bachelor -bacon -badge -bag -balance -balcony -ball -bamboo -banana -banner -bar -barely -bargain -barrel -base -basic -basket -battle -beach -bean -beauty -because -become -beef -before -begin -behave -behind -believe -below -belt -bench -benefit -best -betray -better -between -beyond -bicycle -bid -bike -bind -biology -bird -birth -bitter -black -blade -blame -blanket -blast -bleak -bless -blind -blood -blossom -blouse -blue -blur -blush -board -boat -body -boil -bomb -bone -bonus -book -boost -border -boring -borrow -boss -bottom -bounce -box -boy -bracket -brain -brand -brass -brave -bread -breeze -brick -bridge -brief -bright -bring -brisk -broccoli -broken -bronze -broom -brother -brown -brush -bubble -buddy -budget -buffalo -build -bulb -bulk -bullet -bundle -bunker -burden -burger -burst -bus -business -busy -butter -buyer -buzz -cabbage -cabin -cable -cactus -cage -cake -call -calm -camera -camp -can -canal -cancel -candy -cannon -canoe -canvas -canyon -capable -capital -captain -car -carbon -card -cargo -carpet -carry -cart -case -cash -casino -castle -casual -cat -catalog -catch -category -cattle -caught -cause -caution -cave -ceiling -celery -cement -census -century -cereal -certain -chair -chalk -champion -change -chaos -chapter -charge -chase -chat -cheap -check -cheese -chef -cherry -chest -chicken -chief -child -chimney -choice -choose -chronic -chuckle -chunk -churn -cigar -cinnamon -circle -citizen -city -civil -claim -clap -clarify -claw -clay -clean -clerk -clever -click -client -cliff -climb -clinic -clip -clock -clog -close -cloth -cloud -clown -club -clump -cluster -clutch -coach -coast -coconut -code -coffee -coil -coin -collect -color -column -combine -come -comfort -comic -common -company -concert -conduct -confirm -congress -connect -consider -control -convince -cook -cool -copper -copy -coral -core -corn -correct -cost -cotton -couch -country -couple -course -cousin -cover -coyote -crack -cradle -craft -cram -crane -crash -crater -crawl -crazy -cream -credit -creek -crew -cricket -crime -crisp -critic -crop -cross -crouch -crowd -crucial -cruel -cruise -crumble -crunch -crush -cry -crystal -cube -culture -cup -cupboard -curious -current -curtain -curve -cushion -custom -cute -cycle -dad -damage -damp -dance -danger -daring -dash -daughter -dawn -day -deal -debate -debris -decade -december -decide -decline -decorate -decrease -deer -defense -define -defy -degree -delay -deliver -demand -demise -denial -dentist -deny -depart -depend -deposit -depth -deputy -derive -describe -desert -design -desk -despair -destroy -detail -detect -develop -device -devote -diagram -dial -diamond -diary -dice -diesel -diet -differ -digital -dignity -dilemma -dinner -dinosaur -direct -dirt -disagree -discover -disease -dish -dismiss -disorder -display -distance -divert -divide -divorce -dizzy -doctor -document -dog -doll -dolphin -domain -donate -donkey -donor -door -dose -double -dove -draft -dragon -drama -drastic -draw -dream -dress -drift -drill -drink -drip -drive -drop -drum -dry -duck -dumb -dune -during -dust -dutch -duty -dwarf -dynamic -eager -eagle -early -earn -earth -easily -east -easy -echo -ecology -economy -edge -edit -educate -effort -egg -eight -either -elbow -elder -electric -elegant -element -elephant -elevator -elite -else -embark -embody -embrace -emerge -emotion -employ -empower -empty -enable -enact -end -endless -endorse -enemy -energy -enforce -engage -engine -enhance -enjoy -enlist -enough -enrich -enroll -ensure -enter -entire -entry -envelope -episode -equal -equip -era -erase -erode -erosion -error -erupt -escape -essay -essence -estate -eternal -ethics -evidence -evil -evoke -evolve -exact -example -excess -exchange -excite -exclude -excuse -execute -exercise -exhaust -exhibit -exile -exist -exit -exotic -expand -expect -expire -explain -expose -express -extend -extra -eye -eyebrow -fabric -face -faculty -fade -faint -faith -fall -false -fame -family -famous -fan -fancy -fantasy -farm -fashion -fat -fatal -father -fatigue -fault -favorite -feature -february -federal -fee -feed -feel -female -fence -festival -fetch -fever -few -fiber -fiction -field -figure -file -film -filter -final -find -fine -finger -finish -fire -firm -first -fiscal -fish -fit -fitness -fix -flag -flame -flash -flat -flavor -flee -flight -flip -float -flock -floor -flower -fluid -flush -fly -foam -focus -fog -foil -fold -follow -food -foot -force -forest -forget -fork -fortune -forum -forward -fossil -foster -found -fox -fragile -frame -frequent -fresh -friend -fringe -frog -front -frost -frown -frozen -fruit -fuel -fun -funny -furnace -fury -future -gadget -gain -galaxy -gallery -game -gap -garage -garbage -garden -garlic -garment -gas -gasp -gate -gather -gauge -gaze -general -genius -genre -gentle -genuine -gesture -ghost -giant -gift -giggle -ginger -giraffe -girl -give -glad -glance -glare -glass -glide -glimpse -globe -gloom -glory -glove -glow -glue -goat -goddess -gold -good -goose -gorilla -gospel -gossip -govern -gown -grab -grace -grain -grant -grape -grass -gravity -great -green -grid -grief -grit -grocery -group -grow -grunt -guard -guess -guide -guilt -guitar -gun -gym -habit -hair -half -hammer -hamster -hand -happy -harbor -hard -harsh -harvest -hat -have -hawk -hazard -head -health -heart -heavy -hedgehog -height -hello -helmet -help -hen -hero -hidden -high -hill -hint -hip -hire -history -hobby -hockey -hold -hole -holiday -hollow -home -honey -hood -hope -horn -horror -horse -hospital -host -hotel -hour -hover -hub -huge -human -humble -humor -hundred -hungry -hunt -hurdle -hurry -hurt -husband -hybrid -ice -icon -idea -identify -idle -ignore -ill -illegal -illness -image -imitate -immense -immune -impact -impose -improve -impulse -inch -include -income -increase -index -indicate -indoor -industry -infant -inflict -inform -inhale -inherit -initial -inject -injury -inmate -inner -innocent -input -inquiry -insane -insect -inside -inspire -install -intact -interest -into -invest -invite -involve -iron -island -isolate -issue -item -ivory -jacket -jaguar -jar -jazz -jealous -jeans -jelly -jewel -job -join -joke -journey -joy -judge -juice -jump -jungle -junior -junk -just -kangaroo -keen -keep -ketchup -key -kick -kid -kidney -kind -kingdom -kiss -kit -kitchen -kite -kitten -kiwi -knee -knife -knock -know -lab -label -labor -ladder -lady -lake -lamp -language -laptop -large -later -latin -laugh -laundry -lava -law -lawn -lawsuit -layer -lazy -leader -leaf -learn -leave -lecture -left -leg -legal -legend -leisure -lemon -lend -length -lens -leopard -lesson -letter -level -liar -liberty -library -license -life -lift -light -like -limb -limit -link -lion -liquid -list -little -live -lizard -load -loan -lobster -local -lock -logic -lonely -long -loop -lottery -loud -lounge -love -loyal -lucky -luggage -lumber -lunar -lunch -luxury -lyrics -machine -mad -magic -magnet -maid -mail -main -major -make -mammal -man -manage -mandate -mango -mansion -manual -maple -marble -march -margin -marine -market -marriage -mask -mass -master -match -material -math -matrix -matter -maximum -maze -meadow -mean -measure -meat -mechanic -medal -media -melody -melt -member -memory -mention -menu -mercy -merge -merit -merry -mesh -message -metal -method -middle -midnight -milk -million -mimic -mind -minimum -minor -minute -miracle -mirror -misery -miss -mistake -mix -mixed -mixture -mobile -model -modify -mom -moment -monitor -monkey -monster -month -moon -moral -more -morning -mosquito -mother -motion -motor -mountain -mouse -move -movie -much -muffin -mule -multiply -muscle -museum -mushroom -music -must -mutual -myself -mystery -myth -naive -name -napkin -narrow -nasty -nation -nature -near -neck -need -negative -neglect -neither -nephew -nerve -nest -net -network -neutral -never -news -next -nice -night -noble -noise -nominee -noodle -normal -north -nose -notable -note -nothing -notice -novel -now -nuclear -number -nurse -nut -oak -obey -object -oblige -obscure -observe -obtain -obvious -occur -ocean -october -odor -off -offer -office -often -oil -okay -old -olive -olympic -omit -once -one -onion -online -only -open -opera -opinion -oppose -option -orange -orbit -orchard -order -ordinary -organ -orient -original -orphan -ostrich -other -outdoor -outer -output -outside -oval -oven -over -own -owner -oxygen -oyster -ozone -pact -paddle -page -pair -palace -palm -panda -panel -panic -panther -paper -parade -parent -park -parrot -party -pass -patch -path -patient -patrol -pattern -pause -pave -payment -peace -peanut -pear -peasant -pelican -pen -penalty -pencil -people -pepper -perfect -permit -person -pet -phone -photo -phrase -physical -piano -picnic -picture -piece -pig -pigeon -pill -pilot -pink -pioneer -pipe -pistol -pitch -pizza -place -planet -plastic -plate -play -please -pledge -pluck -plug -plunge -poem -poet -point -polar -pole -police -pond -pony -pool -popular -portion -position -possible -post -potato -pottery -poverty -powder -power -practice -praise -predict -prefer -prepare -present -pretty -prevent -price -pride -primary -print -priority -prison -private -prize -problem -process -produce -profit -program -project -promote -proof -property -prosper -protect -proud -provide -public -pudding -pull -pulp -pulse -pumpkin -punch -pupil -puppy -purchase -purity -purpose -purse -push -put -puzzle -pyramid -quality -quantum -quarter -question -quick -quit -quiz -quote -rabbit -raccoon -race -rack -radar -radio -rail -rain -raise -rally -ramp -ranch -random -range -rapid -rare -rate -rather -raven -raw -razor -ready -real -reason -rebel -rebuild -recall -receive -recipe -record -recycle -reduce -reflect -reform -refuse -region -regret -regular -reject -relax -release -relief -rely -remain -remember -remind -remove -render -renew -rent -reopen -repair -repeat -replace -report -require -rescue -resemble -resist -resource -response -result -retire -retreat -return -reunion -reveal -review -reward -rhythm -rib -ribbon -rice -rich -ride -ridge -rifle -right -rigid -ring -riot -ripple -risk -ritual -rival -river -road -roast -robot -robust -rocket -romance -roof -rookie -room -rose -rotate -rough -round -route -royal -rubber -rude -rug -rule -run -runway -rural -sad -saddle -sadness -safe -sail -salad -salmon -salon -salt -salute -same -sample -sand -satisfy -satoshi -sauce -sausage -save -say -scale -scan -scare -scatter -scene -scheme -school -science -scissors -scorpion -scout -scrap -screen -script -scrub -sea -search -season -seat -second -secret -section -security -seed -seek -segment -select -sell -seminar -senior -sense -sentence -series -service -session -settle -setup -seven -shadow -shaft -shallow -share -shed -shell -sheriff -shield -shift -shine -ship -shiver -shock -shoe -shoot -shop -short -shoulder -shove -shrimp -shrug -shuffle -shy -sibling -sick -side -siege -sight -sign -silent -silk -silly -silver -similar -simple -since -sing -siren -sister -situate -six -size -skate -sketch -ski -skill -skin -skirt -skull -slab -slam -sleep -slender -slice -slide -slight -slim -slogan -slot -slow -slush -small -smart -smile -smoke -smooth -snack -snake -snap -sniff -snow -soap -soccer -social -sock -soda -soft -solar -soldier -solid -solution -solve -someone -song -soon -sorry -sort -soul -sound -soup -source -south -space -spare -spatial -spawn -speak -special -speed -spell -spend -sphere -spice -spider -spike -spin -spirit -split -spoil -sponsor -spoon -sport -spot -spray -spread -spring -spy -square -squeeze -squirrel -stable -stadium -staff -stage -stairs -stamp -stand -start -state -stay -steak -steel -stem -step -stereo -stick -still -sting -stock -stomach -stone -stool -story -stove -strategy -street -strike -strong -struggle -student -stuff -stumble -style -subject -submit -subway -success -such -sudden -suffer -sugar -suggest -suit -summer -sun -sunny -sunset -super -supply -supreme -sure -surface -surge -surprise -surround -survey -suspect -sustain -swallow -swamp -swap -swarm -swear -sweet -swift -swim -swing -switch -sword -symbol -symptom -syrup -system -table -tackle -tag -tail -talent -talk -tank -tape -target -task -taste -tattoo -taxi -teach -team -tell -ten -tenant -tennis -tent -term -test -text -thank -that -theme -then -theory -there -they -thing -this -thought -three -thrive -throw -thumb -thunder -ticket -tide -tiger -tilt -timber -time -tiny -tip -tired -tissue -title -toast -tobacco -today -toddler -toe -together -toilet -token -tomato -tomorrow -tone -tongue -tonight -tool -tooth -top -topic -topple -torch -tornado -tortoise -toss -total -tourist -toward -tower -town -toy -track -trade -traffic -tragic -train -transfer -trap -trash -travel -tray -treat -tree -trend -trial -tribe -trick -trigger -trim -trip -trophy -trouble -truck -true -truly -trumpet -trust -truth -try -tube -tuition -tumble -tuna -tunnel -turkey -turn -turtle -twelve -twenty -twice -twin -twist -two -type -typical -ugly -umbrella -unable -unaware -uncle -uncover -under -undo -unfair -unfold -unhappy -uniform -unique -unit -universe -unknown -unlock -until -unusual -unveil -update -upgrade -uphold -upon -upper -upset -urban -urge -usage -use -used -useful -useless -usual -utility -vacant -vacuum -vague -valid -valley -valve -van -vanish -vapor -various -vast -vault -vehicle -velvet -vendor -venture -venue -verb -verify -version -very -vessel -veteran -viable -vibrant -vicious -victory -video -view -village -vintage -violin -virtual -virus -visa -visit -visual -vital -vivid -vocal -voice -void -volcano -volume -vote -voyage -wage -wagon -wait -walk -wall -walnut -want -warfare -warm -warrior -wash -wasp -waste -water -wave -way -wealth -weapon -wear -weasel -weather -web -wedding -weekend -weird -welcome -west -wet -whale -what -wheat -wheel -when -where -whip -whisper -wide -width -wife -wild -will -win -window -wine -wing -wink -winner -winter -wire -wisdom -wise -wish -witness -wolf -woman -wonder -wood -wool -word -work -world -worry -worth -wrap -wreck -wrestle -wrist -write -wrong -yard -year -yellow -you -young -youth -zebra -zero -zone -zoo diff --git a/src/main/resources/i18n/ApiError_en.properties b/src/main/resources/i18n/ApiError_en.properties index 2a6ec002..6b083ae7 100644 --- a/src/main/resources/i18n/ApiError_en.properties +++ b/src/main/resources/i18n/ApiError_en.properties @@ -65,6 +65,8 @@ TRANSFORMATION_ERROR = could not transform JSON into transaction UNAUTHORIZED = API call unauthorized +ORDER_SIZE_TOO_SMALL = order size too small + FILE_NOT_FOUND = file not found NO_REPLY = peer didn't reply within the allowed time diff --git a/src/main/resources/i18n/ApiError_nl.properties b/src/main/resources/i18n/ApiError_nl.properties new file mode 100644 index 00000000..60faa0f6 --- /dev/null +++ b/src/main/resources/i18n/ApiError_nl.properties @@ -0,0 +1,66 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# Keys are from api.ApiError enum + +ADDRESS_UNKNOWN = account adres onbekend + +BLOCKCHAIN_NEEDS_SYNC = blockchain dient eerst gesynchronizeerd te worden + +# Blocks +BLOCK_UNKNOWN = blok onbekend + +BTC_BALANCE_ISSUE = onvoldoende Bitcoin balans + +BTC_NETWORK_ISSUE = Bitcoin/ElectrumX netwerk probleem + +BTC_TOO_SOON = te vroeg om Bitcoin transactie te versturen (vergrendelingstijd/gemiddelde bloktijd) + +CANNOT_MINT = account kan niet munten + +GROUP_UNKNOWN = onbekende groep + +INVALID_ADDRESS = ongeldig adres + +# Assets +INVALID_ASSET_ID = ongeldige asset ID + +INVALID_CRITERIA = ongeldige zoekcriteria + +INVALID_DATA = ongeldige gegevens + +INVALID_HEIGHT = ongeldige blokhoogte + +INVALID_NETWORK_ADDRESS = ongeldig netwerkadres + +INVALID_ORDER_ID = ongeldige asset order ID + +INVALID_PRIVATE_KEY = ongeldige private key + +INVALID_PUBLIC_KEY = ongeldige public key + +INVALID_REFERENCE = ongeldige verwijzing + +# Validation +INVALID_SIGNATURE = ongeldige handtekening + +JSON = lezen van JSON bericht gefaald + +NAME_UNKNOWN = onbekende naam + +NON_PRODUCTION = deze API call is niet toegestaan voor productiesystemen + +NO_TIME_SYNC = klok nog niet gesynchronizeerd + +ORDER_UNKNOWN = onbekende asset order ID + +PUBLIC_KEY_NOT_FOUND = public key niet gevonden + +REPOSITORY_ISSUE = repository fout + +# This one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = ongeldige transactie: %s (%s) + +TRANSACTION_UNKNOWN = onbekende transactie + +TRANSFORMATION_ERROR = JSON kon niet omgezet worden in transactie + +UNAUTHORIZED = ongeautoriseerde API call diff --git a/src/main/resources/i18n/SysTray_nl.properties b/src/main/resources/i18n/SysTray_nl.properties new file mode 100644 index 00000000..4e3e48ec --- /dev/null +++ b/src/main/resources/i18n/SysTray_nl.properties @@ -0,0 +1,45 @@ +Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# SysTray pop-up menu + +APPLYING_UPDATE_AND_RESTARTING = Automatische update en herstart worden uitgevoerd... + +AUTO_UPDATE = Automatische Update + +BLOCK_HEIGHT = hoogte + +CHECK_TIME_ACCURACY = Controleer accuraatheid van de tijd + +CONNECTING = Verbinden + +CONNECTION = verbinding + +CONNECTIONS = verbindingen + +CREATING_BACKUP_OF_DB_FILES = Backup van databasebestanden wordt gemaakt... + +DB_BACKUP = Database Backup + +DB_CHECKPOINT = Database Controlepunt + +EXIT = Verlaten + +MINTING_DISABLED = NIET muntend + +MINTING_ENABLED = \u2714 Muntend + +# Nagging about lack of NTP time sync +NTP_NAG_CAPTION = Klok van de computer is inaccuraat! + +NTP_NAG_TEXT_UNIX = Installeer NTP service voor een accurate klok. + +NTP_NAG_TEXT_WINDOWS = Selecteer "Synchronizeer klok" uit het menu om op te lossen. + +OPEN_UI = Open UI + +PERFORMING_DB_CHECKPOINT = Nieuwe veranderingen aan database worden opgeslagen... + +SYNCHRONIZE_CLOCK = Synchronizeer klok + +SYNCHRONIZING_BLOCKCHAIN = Aan het synchronizeren + +SYNCHRONIZING_CLOCK = Klok wordt gesynchronizeerd diff --git a/src/main/resources/i18n/SysTray_zh.properties b/src/main/resources/i18n/SysTray_zh_SC.properties similarity index 53% rename from src/main/resources/i18n/SysTray_zh.properties rename to src/main/resources/i18n/SysTray_zh_SC.properties index 0aaa2e33..caba49cf 100644 --- a/src/main/resources/i18n/SysTray_zh.properties +++ b/src/main/resources/i18n/SysTray_zh_SC.properties @@ -1,31 +1,31 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -BLOCK_HEIGHT = 块高度 +BLOCK_HEIGHT = 区块高度 CHECK_TIME_ACCURACY = 检查时间准确性 -CONNECTION = 个连接 +CONNECTION = 个链接 -CONNECTIONS = 个连接 +CONNECTIONS = 个链接 -EXIT = 退出软件 +EXIT = 退出核心 MINTING_DISABLED = 没有铸币 MINTING_ENABLED = ✔ 铸币 # Nagging about lack of NTP time sync -NTP_NAG_CAPTION = 电脑的时钟不准确! +NTP_NAG_CAPTION = 电脑的时间不准确! -NTP_NAG_TEXT_UNIX = 安装NTP服务以获得准确的时钟。 +NTP_NAG_TEXT_UNIX = 安装NTP服务以获取准确的时间。 NTP_NAG_TEXT_WINDOWS = 从菜单中选择“同步时钟”进行修复。 -OPEN_UI = 开启界面 +OPEN_UI = 开启Qortal界面 SYNCHRONIZE_CLOCK = 同步时钟 -SYNCHRONIZING_BLOCKCHAIN = 同步区块链 +SYNCHRONIZING_BLOCKCHAIN = 正在同步区块链 -SYNCHRONIZING_CLOCK = 同步着时钟 +SYNCHRONIZING_CLOCK = 正在同步时钟 diff --git a/src/main/resources/i18n/SysTray_zh_TC.properties b/src/main/resources/i18n/SysTray_zh_TC.properties new file mode 100644 index 00000000..ac768846 --- /dev/null +++ b/src/main/resources/i18n/SysTray_zh_TC.properties @@ -0,0 +1,31 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# SysTray pop-up menu + +BLOCK_HEIGHT = 區塊高度 + +CHECK_TIME_ACCURACY = 檢查時間準確性 + +CONNECTION = 個鏈接 + +CONNECTIONS = 個鏈接 + +EXIT = 退出核心 + +MINTING_DISABLED = 沒有鑄幣 + +MINTING_ENABLED = ✔ 鑄幣 + +# Nagging about lack of NTP time sync +NTP_NAG_CAPTION = 電腦的時間不準確! + +NTP_NAG_TEXT_UNIX = 安装NTP服務以獲取準確的時間。 + +NTP_NAG_TEXT_WINDOWS = 從菜單中選擇“同步時鐘”進行修復。 + +OPEN_UI = 開啓Qortal界面 + +SYNCHRONIZE_CLOCK = 同步時鐘 + +SYNCHRONIZING_BLOCKCHAIN = 正在同步區塊鏈 + +SYNCHRONIZING_CLOCK = 正在同步時鐘 diff --git a/src/main/resources/i18n/TransactionValidity_nl.properties b/src/main/resources/i18n/TransactionValidity_nl.properties new file mode 100644 index 00000000..7afaad89 --- /dev/null +++ b/src/main/resources/i18n/TransactionValidity_nl.properties @@ -0,0 +1,184 @@ + +ACCOUNT_ALREADY_EXISTS = account bestaat al + +ACCOUNT_CANNOT_REWARD_SHARE = account kan geen beloningen delen + +ALREADY_GROUP_ADMIN = reeds groepsadministrator + +ALREADY_GROUP_MEMBER = reeds groepslid + +ALREADY_VOTED_FOR_THAT_OPTION = reeds gestemd voor die optie + +ASSET_ALREADY_EXISTS = asset bestaat al + +ASSET_DOES_NOT_EXIST = asset bestaat niet + +ASSET_DOES_NOT_MATCH_AT = asset matcht niet met de asset van de AT + +ASSET_NOT_SPENDABLE = asset is niet uitgeefbaar + +AT_ALREADY_EXISTS = AT bestaat al + +AT_IS_FINISHED = AT is afgelopen + +AT_UNKNOWN = AT onbekend + +BANNED_FROM_GROUP = verbannen uit groep + +BAN_EXISTS = ban bestaat al + +BAN_UNKNOWN = ban onbekend + +BUYER_ALREADY_OWNER = koper is al eigenaar + +CHAT = CHAT transacties zijn nooit geldig voor opname in blokken + +CLOCK_NOT_SYNCED = klok is niet gesynchronizeerd + +DUPLICATE_OPTION = dubbele optie + +GROUP_ALREADY_EXISTS = groep bestaat reeds + +GROUP_APPROVAL_DECIDED = groepsgoedkeuring reeds afgewezen + +GROUP_APPROVAL_NOT_REQUIRED = groepsgoedkeuring niet vereist + +GROUP_DOES_NOT_EXIST = groep bestaat niet + +GROUP_ID_MISMATCH = ongeldige match met groep-ID + +GROUP_OWNER_CANNOT_LEAVE = groepseigenaar kan de groep niet verlaten + +HAVE_EQUALS_WANT = have-asset is gelijk aan want-asset + +INCORRECT_NONCE = incorrecte PoW nonce + +INSUFFICIENT_FEE = vergoeding te laag + +INVALID_ADDRESS = ongeldig adres + +INVALID_AMOUNT = ongeldige hoeveelheid + +INVALID_ASSET_OWNER = ongeldige asset-eigenaar + +INVALID_AT_TRANSACTION = ongeldige AT-transactie + +INVALID_AT_TYPE_LENGTH = ongeldige lengte voor AT 'type' + +INVALID_CREATION_BYTES = ongeldige creation bytes + +INVALID_DATA_LENGTH = ongeldige lengte voor data + +INVALID_DESCRIPTION_LENGTH = ongeldige lengte voor beschrijving + +INVALID_GROUP_APPROVAL_THRESHOLD = ongeldige drempelwaarde voor groepsgoedkeuring + +INVALID_GROUP_BLOCK_DELAY = ongeldige groepsgoedkeuring voor blokvertraging + +INVALID_GROUP_ID = ongeldige groep-ID + +INVALID_GROUP_OWNER = ongeldige groepseigenaar + +INVALID_LIFETIME = ongeldige levensduur + +INVALID_NAME_LENGTH = ongeldige lengte voor naam + +INVALID_NAME_OWNER = ongeldige naam voor eigenaar + +INVALID_OPTIONS_COUNT = ongeldige hoeveelheid opties + +INVALID_OPTION_LENGTH = ongeldige lengte voor opties + +INVALID_ORDER_CREATOR = ongeldige aanmaker voor order + +INVALID_PAYMENTS_COUNT = ongeldige hoeveelheid betalingen + +INVALID_PUBLIC_KEY = ongeldige public key + +INVALID_QUANTITY = ongeldige hoeveelheid + +INVALID_REFERENCE = ongeldige verwijzing + +INVALID_RETURN = ongeldige return + +INVALID_REWARD_SHARE_PERCENT = ongeldig percentage voor beloningsdeling + +INVALID_SELLER = ongeldige verkoper + +INVALID_TAGS_LENGTH = ongeldige lengte voor 'tags' + +INVALID_TX_GROUP_ID = ongeldige transactiegroep-ID + +INVALID_VALUE_LENGTH = ongeldige lengte voor 'waarde' + +INVITE_UNKNOWN = onbekende groepsuitnodiging + +JOIN_REQUEST_EXISTS = aanvraag om lid van groep te worden bestaat al + +MAXIMUM_REWARD_SHARES = limiet aan beloningsdelingen voor dit account is bereikt + +MISSING_CREATOR = ontbrekende aanmaker + +MULTIPLE_NAMES_FORBIDDEN = het registreren van meerdere namen op een account is niet toegestaan + +NAME_ALREADY_FOR_SALE = naam reeds te koop + +NAME_ALREADY_REGISTERED = naam reeds geregistreerd + +NAME_DOES_NOT_EXIST = naam bestaat niet + +NAME_NOT_FOR_SALE = naam is niet te koop + +NAME_NOT_NORMALIZED = naam is niet in 'genormalizeerde' Unicode-vorm + +NEGATIVE_AMOUNT = ongeldige/negatieve hoeveelheid + +NEGATIVE_FEE = ongeldige/negatieve vergoeding + +NEGATIVE_PRICE = ongeldige/negatieve prijs + +NOT_GROUP_ADMIN = account is geen groepsadministrator + +NOT_GROUP_MEMBER = account is geen groepslid + +NOT_MINTING_ACCOUNT = account kan niet munten + +NOT_YET_RELEASED = functie nog niet uitgebracht + +NO_BALANCE = onvoldoende balans + +NO_BLOCKCHAIN_LOCK = blockchain van node is momenteel bezig + +NO_FLAG_PERMISSION = account heeft hier geen toestemming voor + +OK = Oke + +ORDER_ALREADY_CLOSED = asset handelsorder is al gesloten + +ORDER_DOES_NOT_EXIST = asset handelsorder bestaat niet + +POLL_ALREADY_EXISTS = peiling bestaat al + +POLL_DOES_NOT_EXIST = peiling bestaat niet + +POLL_OPTION_DOES_NOT_EXIST = peilingsoptie bestaat niet + +PUBLIC_KEY_UNKNOWN = public key onbekend + +REWARD_SHARE_UNKNOWN = beloningsdeling onbekend + +SELF_SHARE_EXISTS = zelfdeling (beloningsdeling) bestaat reeds + +TIMESTAMP_TOO_NEW = tijdstempel te nieuw + +TIMESTAMP_TOO_OLD = tijdstempel te oud + +TOO_MANY_UNCONFIRMED = account heeft te veel onbevestigde transacties in afwachting + +TRANSACTION_ALREADY_CONFIRMED = transactie is reeds bevestigd + +TRANSACTION_ALREADY_EXISTS = transactie bestaat al + +TRANSACTION_UNKNOWN = transactie onbekend + +TX_GROUP_ID_MISMATCH = groep-ID van transactie matcht niet diff --git a/src/main/resources/images/Qlogo_512.png b/src/main/resources/images/Qlogo_512.png new file mode 100644 index 00000000..81508bb7 Binary files /dev/null and b/src/main/resources/images/Qlogo_512.png differ diff --git a/src/main/resources/images/icons/Qlogo_128.png b/src/main/resources/images/icons/Qlogo_128.png new file mode 100644 index 00000000..463bb527 Binary files /dev/null and b/src/main/resources/images/icons/Qlogo_128.png differ diff --git a/src/main/resources/images/icons/icon128.png b/src/main/resources/images/icons/icon128.png deleted file mode 100644 index ddb869bd..00000000 Binary files a/src/main/resources/images/icons/icon128.png and /dev/null differ diff --git a/src/main/resources/images/icons/icon32.png b/src/main/resources/images/icons/icon32.png deleted file mode 100644 index 43a37510..00000000 Binary files a/src/main/resources/images/icons/icon32.png and /dev/null differ diff --git a/src/main/resources/images/icons/qortal_ui_tray_minting.png b/src/main/resources/images/icons/qortal_ui_tray_minting.png new file mode 100644 index 00000000..567e784b Binary files /dev/null and b/src/main/resources/images/icons/qortal_ui_tray_minting.png differ diff --git a/src/main/resources/images/icons/qortal_ui_tray_synced.png b/src/main/resources/images/icons/qortal_ui_tray_synced.png new file mode 100644 index 00000000..f944bad9 Binary files /dev/null and b/src/main/resources/images/icons/qortal_ui_tray_synced.png differ diff --git a/src/main/resources/images/icons/qortal_ui_tray_syncing.png b/src/main/resources/images/icons/qortal_ui_tray_syncing.png new file mode 100644 index 00000000..82d39bbb Binary files /dev/null and b/src/main/resources/images/icons/qortal_ui_tray_syncing.png differ diff --git a/src/main/resources/images/icons/qortal_ui_tray_syncing_time-alt.png b/src/main/resources/images/icons/qortal_ui_tray_syncing_time-alt.png new file mode 100644 index 00000000..608be51e Binary files /dev/null and b/src/main/resources/images/icons/qortal_ui_tray_syncing_time-alt.png differ diff --git a/src/main/resources/images/splash.png b/src/main/resources/images/splash.png old mode 100755 new mode 100644 diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index 434e03f0..91dd03c2 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -261,11 +261,11 @@ public class RepositoryTests extends Common { /** Check that the sub-query used to fetch highest block height is optimized by HSQLDB. */ @Test public void testBlockHeightSpeed() throws DataException, SQLException { - final int mintBlockCount = 30000; + final int mintBlockCount = 10000; try (final Repository repository = RepositoryManager.getRepository()) { // Mint some blocks - System.out.println(String.format("Minting %d test blocks - should take approx. 30 seconds...", mintBlockCount)); + System.out.println(String.format("Minting %d test blocks - should take approx. 10 seconds...", mintBlockCount)); long beforeBigMint = System.currentTimeMillis(); for (int i = 0; i < mintBlockCount; ++i) diff --git a/src/test/java/org/qortal/test/apps/VanityGen.java b/src/test/java/org/qortal/test/apps/VanityGen.java index f697087f..2c22ea0b 100644 --- a/src/test/java/org/qortal/test/apps/VanityGen.java +++ b/src/test/java/org/qortal/test/apps/VanityGen.java @@ -10,7 +10,6 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.qortal.account.PrivateKeyAccount; import org.qortal.crypto.Crypto; -import org.qortal.utils.BIP39; import org.qortal.utils.Base58; import com.google.common.primitives.Bytes; @@ -44,15 +43,13 @@ public class VanityGen { byte checksum = (byte) (hash[0] & 0xf0); byte[] entropy132 = Bytes.concat(entropy, new byte[] { checksum }); - String mnemonic = BIP39.encode(entropy132, "en"); - PrivateKeyAccount account = new PrivateKeyAccount(null, hash); if (!account.getAddress().startsWith(prefix)) continue; - System.out.println(String.format("Address: %s, public key: %s, private key: %s, mnemonic: %s", - account.getAddress(), Base58.encode(account.getPublicKey()), Base58.encode(hash), mnemonic)); + System.out.println(String.format("Address: %s, public key: %s, private key: %s", + account.getAddress(), Base58.encode(account.getPublicKey()), Base58.encode(hash))); System.out.flush(); } } diff --git a/src/test/java/org/qortal/test/crosschain/DogecoinTests.java b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java new file mode 100644 index 00000000..b6d21315 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java @@ -0,0 +1,114 @@ +package org.qortal.test.crosschain; + +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.BitcoinyHTLC; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Dogecoin; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +import java.util.Arrays; + +import static org.junit.Assert.*; + +public class DogecoinTests extends Common { + + private Dogecoin dogecoin; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + dogecoin = Dogecoin.getInstance(); + } + + @After + public void afterTest() { + Dogecoin.resetForTesting(); + dogecoin = null; + } + + @Test + public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { + long before = System.currentTimeMillis(); + System.out.println(String.format("Bitcoin median blocktime: %d", dogecoin.getMedianBlockTime())); + long afterFirst = System.currentTimeMillis(); + + System.out.println(String.format("Bitcoin median blocktime: %d", dogecoin.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(dogecoin, p2shAddress); + + assertNotNull("secret not found", secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + + @Test + public void testBuildSpend() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + long amount = 1000L; + + Transaction transaction = dogecoin.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + + // Check spent key caching doesn't affect outcome + + transaction = dogecoin.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + } + + @Test + public void testGetWalletBalance() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + Long balance = dogecoin.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(dogecoin.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = dogecoin.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(dogecoin.format(repeatBalance)); + + assertEquals(balance, repeatBalance); + } + + @Test + public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String address = dogecoin.getUnusedReceiveAddress(xprv58); + + assertNotNull(address); + + System.out.println(address); + } + +} diff --git a/tools/build-release.sh b/tools/build-release.sh index f78ec1b0..28b289f7 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -67,7 +67,7 @@ git_url=https://github.com/${git_url##*:} git_url=${git_url%%.git} # Check for EXE -exe=${project}-${git_tag#v}.exe +exe=${project}.exe exe_src="${WINDOWS_INSTALLER_DIR}/${exe}" if [ ! -r "${exe_src}" ]; then echo "Cannot find EXE installer at ${exe_src}" @@ -75,7 +75,7 @@ if [ ! -r "${exe_src}" ]; then fi # Check for ZIP -zip_filename=${project}-${git_tag#v}.zip +zip_filename=${project}.zip zip_src=${saved_pwd}/${zip_filename} if [ ! -r "${zip_src}" ]; then echo "Cannot find ZIP at ${zip_src}" diff --git a/tools/build-zip.sh b/tools/build-zip.sh index 47fdd373..b52b5da7 100755 --- a/tools/build-zip.sh +++ b/tools/build-zip.sh @@ -63,4 +63,4 @@ printf "{\n}\n" > ${build_dir}/settings.json gtouch -d ${commit_ts%%+??:??} ${build_dir} ${build_dir}/* rm -f ${saved_pwd}/${project}.zip -(cd ${build_dir}/..; 7z a -r -tzip ${saved_pwd}/${project}-${git_tag#v}.zip ${project}/) +(cd ${build_dir}/..; 7z a -r -tzip ${saved_pwd}/${project}.zip ${project}/)