From 654dc5bff3cb42a61c479dc97bb69b7c22be635c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 17 May 2021 17:02:38 +0100 Subject: [PATCH 01/16] Bump version to 1.5.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 526ed35d..0bc2c495 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 1.5.1 + 1.5.2 jar true From ee5a132eb278d1cf343f34b8c461ea6c3f3a16fb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 17 May 2021 20:31:28 +0100 Subject: [PATCH 02/16] Updated AdvancedInstaller project for v1.5.2 --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 443e483f..2c181933 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - + From 518f02472f9fbb722b6c58fa1763a2afcac7b70e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 20 May 2021 07:59:19 +0100 Subject: [PATCH 03/16] Added POST /crosschain/LitecoinACCTv1/redeemmessage API This is similar to the BTC equivalent, but removes secretB as an input parameter. It also signs and broadcasts the transaction, because the wallet isn't needed for this. These transactions have to be signed using the tradePrivateKey from the tradebot data rather than any of the wallet's keys. There are two other LitecoinACCTv1 APIs still to implement, but I will leave these until they are needed. --- .../model/CrossChainDualSecretRequest.java | 29 ++++ .../api/model/CrossChainSecretRequest.java | 11 +- .../CrossChainBitcoinACCTv1Resource.java | 6 +- .../CrossChainLitecoinACCTv1Resource.java | 145 ++++++++++++++++++ 4 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/CrossChainDualSecretRequest.java create mode 100644 src/main/java/org/qortal/api/resource/CrossChainLitecoinACCTv1Resource.java diff --git a/src/main/java/org/qortal/api/model/CrossChainDualSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainDualSecretRequest.java new file mode 100644 index 00000000..b6705d5d --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainDualSecretRequest.java @@ -0,0 +1,29 @@ +package org.qortal.api.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainDualSecretRequest { + + @Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] partnerPublicKey; + + @Schema(description = "Qortal AT address") + public String atAddress; + + @Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1") + public byte[] secretA; + + @Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx") + public byte[] secretB; + + @Schema(description = "Qortal address for receiving QORT from AT") + public String receivingAddress; + + public CrossChainDualSecretRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java index 7ad825d4..2db475e5 100644 --- a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java @@ -8,17 +8,14 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainSecretRequest { - @Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") - public byte[] partnerPublicKey; + @Schema(description = "Private key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] partnerPrivateKey; @Schema(description = "Qortal AT address") public String atAddress; - @Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1") - public byte[] secretA; - - @Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx") - public byte[] secretB; + @Schema(description = "Secret (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1") + public byte[] secret; @Schema(description = "Qortal address for receiving QORT from AT") public String receivingAddress; diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java index 6125974f..20a27241 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java @@ -22,7 +22,7 @@ import org.qortal.api.ApiErrors; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.CrossChainBuildRequest; -import org.qortal.api.model.CrossChainSecretRequest; +import org.qortal.api.model.CrossChainDualSecretRequest; import org.qortal.api.model.CrossChainTradeRequest; import org.qortal.asset.Asset; import org.qortal.crosschain.BitcoinACCTv1; @@ -242,7 +242,7 @@ public class CrossChainBitcoinACCTv1Resource { content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema( - implementation = CrossChainSecretRequest.class + implementation = CrossChainDualSecretRequest.class ) ) ), @@ -257,7 +257,7 @@ public class CrossChainBitcoinACCTv1Resource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String buildRedeemMessage(CrossChainSecretRequest secretRequest) { + public String buildRedeemMessage(CrossChainDualSecretRequest secretRequest) { Security.checkApiCallAllowed(request); byte[] partnerPublicKey = secretRequest.partnerPublicKey; diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinACCTv1Resource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinACCTv1Resource.java new file mode 100644 index 00000000..04923133 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinACCTv1Resource.java @@ -0,0 +1,145 @@ +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.LitecoinACCTv1; +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.TransformationException; +import org.qortal.transform.Transformer; +import org.qortal.transform.transaction.MessageTransactionTransformer; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +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; +import java.util.Random; + +@Path("/crosschain/LitecoinACCTv1") +@Tag(name = "Cross-Chain (LitecoinACCTv1)") +public class CrossChainLitecoinACCTv1Resource { + + @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 != LitecoinACCTv1.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 = LitecoinACCTv1.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 = LitecoinACCTv1.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(), LitecoinACCTv1.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; + } + +} From 5148bad82ee0b37f44f932f2ceb09e313a615cf9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 20 May 2021 09:20:14 +0100 Subject: [PATCH 04/16] /crosschain/htlc APIs now take base58 encoded params instead of hex. This makes them more compatible with the output of the /crosschain/tradebot and /crosschain/trade/{ataddress} APIs which is likely where most people will be retrieving data from, rather than the database itself. --- .../api/resource/CrossChainHtlcResource.java | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 8bd2dc8b..47788b62 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -26,6 +26,7 @@ import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.ForeignBlockchainException; import org.qortal.crosschain.SupportedBlockchain; import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.utils.Base58; import org.qortal.utils.NTP; import com.google.common.hash.HashCode; @@ -41,7 +42,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 (hex). Locktime is seconds since epoch.", + 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.", responses = { @ApiResponse( content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) @@ -50,21 +51,21 @@ public class CrossChainHtlcResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_CRITERIA}) public String deriveHtlcAddress(@PathParam("blockchain") String blockchainName, - @PathParam("refundPKH") String refundHex, + @PathParam("refundPKH") String refundPKH, @PathParam("locktime") int lockTime, - @PathParam("redeemPKH") String redeemHex, - @PathParam("hashOfSecret") String hashOfSecretHex) { + @PathParam("redeemPKH") String redeemPKH, + @PathParam("hashOfSecret") String hashOfSecret) { SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName); if (blockchain == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); byte[] refunderPubKeyHash; byte[] redeemerPubKeyHash; - byte[] hashOfSecret; + byte[] decodedHashOfSecret; try { - refunderPubKeyHash = HashCode.fromString(refundHex).asBytes(); - redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes(); + refunderPubKeyHash = Base58.decode(refundPKH); + redeemerPubKeyHash = Base58.decode(redeemPKH); if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); @@ -73,14 +74,14 @@ public class CrossChainHtlcResource { } try { - hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes(); - if (hashOfSecret.length != 20) + decodedHashOfSecret = Base58.decode(hashOfSecret); + if (decodedHashOfSecret.length != 20) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); } catch (IllegalArgumentException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); } - byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret); + byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, decodedHashOfSecret); Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance(); @@ -91,7 +92,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 (hex). Locktime is seconds since epoch.", + 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.", responses = { @ApiResponse( content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class)) @@ -100,10 +101,10 @@ public class CrossChainHtlcResource { ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) public CrossChainBitcoinyHTLCStatus checkHtlcStatus(@PathParam("blockchain") String blockchainName, - @PathParam("refundPKH") String refundHex, + @PathParam("refundPKH") String refundPKH, @PathParam("locktime") int lockTime, - @PathParam("redeemPKH") String redeemHex, - @PathParam("hashOfSecret") String hashOfSecretHex) { + @PathParam("redeemPKH") String redeemPKH, + @PathParam("hashOfSecret") String hashOfSecret) { Security.checkApiCallAllowed(request); SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName); @@ -112,11 +113,11 @@ public class CrossChainHtlcResource { byte[] refunderPubKeyHash; byte[] redeemerPubKeyHash; - byte[] hashOfSecret; + byte[] decodedHashOfSecret; try { - refunderPubKeyHash = HashCode.fromString(refundHex).asBytes(); - redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes(); + refunderPubKeyHash = Base58.decode(refundPKH); + redeemerPubKeyHash = Base58.decode(redeemPKH); if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); @@ -125,14 +126,14 @@ public class CrossChainHtlcResource { } try { - hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes(); - if (hashOfSecret.length != 20) + decodedHashOfSecret = Base58.decode(hashOfSecret); + if (decodedHashOfSecret.length != 20) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); } catch (IllegalArgumentException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); } - byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret); + byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, decodedHashOfSecret); Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance(); From 326ef498b0cd13cf8134be6ff210652513b03159 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 May 2021 09:51:57 +0100 Subject: [PATCH 05/16] Added /crosschain/htlc/redeem/LITECOIN/{ataddress}/{tradePrivateKey}/{secret}/{receivingAddress} API This can currently be used by either the buyer or the seller, but it requires the seller's trade private key & receiving address to be specified, along with the buyer's secret. Currently hardcoded to LITECOIN but I will aim to make this generic as we start adding more coins. --- .../api/resource/CrossChainHtlcResource.java | 129 ++++++++++++++++-- 1 file changed, 121 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 47788b62..415a7f3a 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -16,25 +16,30 @@ import javax.ws.rs.PathParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; -import org.bitcoinj.core.TransactionOutput; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.*; +import org.bitcoinj.script.Script; 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.CrossChainBitcoinyHTLCStatus; -import org.qortal.crosschain.Bitcoiny; -import org.qortal.crosschain.ForeignBlockchainException; -import org.qortal.crosschain.SupportedBlockchain; -import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crosschain.*; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.utils.Base58; import org.qortal.utils.NTP; -import com.google.common.hash.HashCode; - @Path("/crosschain/htlc") @Tag(name = "Cross-Chain (Hash time-locked contracts)") public class CrossChainHtlcResource { + private static final Logger LOGGER = LogManager.getLogger(CrossChainHtlcResource.class); + @Context HttpServletRequest request; @@ -171,6 +176,114 @@ public class CrossChainHtlcResource { // TODO: refund - // TODO: redeem + @GET + @Path("/redeem/LITECOIN/{ataddress}/{tradePrivateKey}/{secret}/{receivingAddress}") + @Operation( + summary = "Redeems HTLC associated with supplied AT", + description = "Secret should be 32 bytes (base58 encoded).", + 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); + + try (final Repository repository = RepositoryManager.getRepository()) { + 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); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] decodedSecret = Base58.decode(secret); + if (decodedSecret.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] decodedPrivateKey = Base58.decode(tradePrivateKey); + if (decodedPrivateKey.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Convert 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(); + if (litecoinReceivingAccountInfo.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + + // Use secret-A to redeem P2SH-A + + Litecoin litecoin = Litecoin.getInstance(); + + int lockTime = crossChainTradeData.lockTimeA; + byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTime, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); + long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.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 false; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Double-check that we have redeemed P2SH-A... + return false; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // Wait for AT to auto-refund + return false; + + case FUNDED: { + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + ECKey redeemKey = ECKey.fromPrivate(decodedPrivateKey); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, decodedSecret, litecoinReceivingAccountInfo); + + litecoin.broadcastTransaction(p2shRedeemTransaction); + return true; // TODO: validate? + } + } + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e); + } + + return false; + } + + private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) { + return (lockTimeA - tradeTimeout * 60) * 1000L; + } } \ No newline at end of file From 39575e85420bd57bc3a7a4aa7c9f09902ec08b97 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 May 2021 10:09:28 +0100 Subject: [PATCH 06/16] Added /refund/LITECOIN/{ataddress} API This is designed to be called by the buyer, and will force refund their P2SH transaction associated with the supplied AT. The tradebot responsible for this trade must be present in the user's db for this API access the necessary data. It must be called after lockTime has passed, which for LTC is currently 60 minutes from the time that the P2SH was funded. Trying to refund before this time will result in a FOREIGN_BLOCKCHAIN_TOO_SOON error. --- .../api/resource/CrossChainHtlcResource.java | 97 ++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 415a7f3a..92091896 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -28,6 +28,7 @@ import org.qortal.api.model.CrossChainBitcoinyHTLCStatus; import org.qortal.crosschain.*; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -174,8 +175,6 @@ public class CrossChainHtlcResource { } } - // TODO: refund - @GET @Path("/redeem/LITECOIN/{ataddress}/{tradePrivateKey}/{secret}/{receivingAddress}") @Operation( @@ -282,6 +281,100 @@ public class CrossChainHtlcResource { return false; } + @GET + @Path("/refund/LITECOIN/{ataddress}") + @Operation( + summary = "Refunds HTLC associated with supplied AT", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) + public boolean refundHtlc(@PathParam("ataddress") String atAddress) { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + 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); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); + if (tradeBotData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + + int lockTime = tradeBotData.getLockTimeA(); + + // We can't refund P2SH-A until lockTime-A has passed + 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(); + 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); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); + long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-A to be funded... + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + + case REDEEM_IN_PROGRESS: + case REDEEMED: + case REFUND_IN_PROGRESS: + case REFUNDED: + // Too late! + return false; + + case FUNDED:{ + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + + // Determine receive address for refund + String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(litecoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(litecoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTime, receiving.getHash()); + + litecoin.broadcastTransaction(p2shRefundTransaction); + return true; // TODO: validate? + } + } + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e); + } + + return false; + } + private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) { return (lockTimeA - tradeTimeout * 60) * 1000L; } From 0b36b650a4ceb8567866ce8c1b9f1551f7f14aba Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 May 2021 13:59:00 +0100 Subject: [PATCH 07/16] Added /redeem/LITECOIN/{ataddress} API This is the equivalent of the refund API but can be used by the seller to redeem LTC from a stuck transaction, by supplying the associated AT address, There are no lockTime requirements; it is redeemable as soon as the buyer has redeemed the QORT and sent the secret to the seller. --- .../api/resource/CrossChainHtlcResource.java | 112 +++++++++++++++--- 1 file changed, 93 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 92091896..a2dcd470 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -178,8 +178,10 @@ public class CrossChainHtlcResource { @GET @Path("/redeem/LITECOIN/{ataddress}/{tradePrivateKey}/{secret}/{receivingAddress}") @Operation( - summary = "Redeems HTLC associated with supplied AT", - description = "Secret should be 32 bytes (base58 encoded).", + 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")) @@ -193,6 +195,46 @@ public class CrossChainHtlcResource { @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}") + @Operation( + summary = "Redeems HTLC associated with supplied AT", + description = "To be used by a seller (Bob) who needs to redeem LTC proceeds that are stuck in a P2SH.", + 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) { + Security.checkApiCallAllowed(request); + try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = repository.getATRepository().fromATAddress(atAddress); if (atData == null) @@ -206,26 +248,58 @@ public class CrossChainHtlcResource { if (crossChainTradeData == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] decodedSecret = Base58.decode(secret); - if (decodedSecret.length != 32) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - byte[] decodedPrivateKey = Base58.decode(tradePrivateKey); - if (decodedPrivateKey.length != 32) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - // Convert 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) { + // Attempt to find secret from the buyer's message to AT + byte[] decodedSecret = LitecoinACCTv1.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); } - if (litecoinReceivingAddress.getOutputScriptType() != Script.ScriptType.P2PKH) + + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); + + // Search for the tradePrivateKey in the tradebot data + byte[] decodedPrivateKey = null; + if (tradeBotData != null) + decodedPrivateKey = tradeBotData.getTradePrivateKey(); + + // Search for the litecoin receiving address in the tradebot data + byte[] litecoinReceivingAccountInfo = null; + if (tradeBotData != null) + // Use receiving address PKH from tradebot data + litecoinReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + + return this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, litecoinReceivingAccountInfo); + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + private boolean doRedeemHtlc(String atAddress, byte[] decodedTradePrivateKey, byte[] decodedSecret, byte[] litecoinReceivingAccountInfo) { + try (final Repository repository = RepositoryManager.getRepository()) { + 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); - byte[] litecoinReceivingAccountInfo = litecoinReceivingAddress.getHash(); - if (litecoinReceivingAccountInfo.length != 20) + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Validate trade private key + if (decodedTradePrivateKey == null || decodedTradePrivateKey.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Validate secret + if (decodedSecret == null || decodedSecret.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Validate receiving address + if (litecoinReceivingAccountInfo == null || litecoinReceivingAccountInfo.length != 20) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -261,7 +335,7 @@ public class CrossChainHtlcResource { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); - ECKey redeemKey = ECKey.fromPrivate(decodedPrivateKey); + ECKey redeemKey = ECKey.fromPrivate(decodedTradePrivateKey); List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey, From 39d1590acee262ab1cb8126a3ccecb1354027390 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 May 2021 14:16:14 +0100 Subject: [PATCH 08/16] Improved descriptions of the new API endpoints. --- .../org/qortal/api/resource/CrossChainHtlcResource.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index a2dcd470..bbac65a4 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -224,7 +224,9 @@ public class CrossChainHtlcResource { @Path("/redeem/LITECOIN/{ataddress}") @Operation( summary = "Redeems HTLC associated with supplied AT", - description = "To be used by a 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 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 = { @ApiResponse( content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) @@ -359,6 +361,9 @@ public class CrossChainHtlcResource { @Path("/refund/LITECOIN/{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.
" + + "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 = { @ApiResponse( content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) From 80311355ae84b1628c292523d75a6664be981f88 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 May 2021 13:10:47 +0100 Subject: [PATCH 09/16] Added /blocks/signature/{signature}/data API This returns serialized, base58 encoded data for the entire block. It is the same format as the data sent between nodes when synchronizing, with base58 encoding added so that it can be outputted cleanly in the API response. --- .../qortal/api/resource/BlocksResource.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index b2f29305..8920ecc1 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -1,5 +1,6 @@ package org.qortal.api.resource; +import com.google.common.primitives.Ints; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -8,6 +9,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.math.BigDecimal; import java.math.BigInteger; import java.math.RoundingMode; @@ -38,6 +41,8 @@ import org.qortal.data.transaction.TransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformer; import org.qortal.utils.Base58; @Path("/blocks") @@ -86,6 +91,48 @@ public class BlocksResource { } } + @GET + @Path("/signature/{signature}/data") + @Operation( + summary = "Fetch serialized, base58 encoded block data using base58 signature", + description = "Returns serialized data for the block that matches the given signature", + responses = { + @ApiResponse( + description = "the block data", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ + ApiError.INVALID_SIGNATURE, ApiError.BLOCK_UNKNOWN, ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE + }) + public String getSerializedBlockData(@PathParam("signature") String signature58) { + // Decode signature + byte[] signature; + try { + signature = Base58.decode(signature58); + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_SIGNATURE, e); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + + Block block = new Block(repository, blockData); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); + bytes.write(BlockTransformer.toBytes(block)); + return Base58.encode(bytes.toByteArray()); + + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e); + } catch (DataException | IOException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @GET @Path("/signature/{signature}/transactions") @Operation( From eb2c7268eaf058a76b67e27e4794cf394a2e5d56 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 May 2021 15:31:26 +0100 Subject: [PATCH 10/16] Removed .DS_Store files. --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 2 +- src/.DS_Store | Bin 6148 -> 0 bytes src/main/.DS_Store | Bin 6148 -> 0 bytes src/main/resources/.DS_Store | Bin 6148 -> 0 bytes 5 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .DS_Store delete mode 100644 src/.DS_Store delete mode 100644 src/main/.DS_Store delete mode 100644 src/main/resources/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 527db761f6a5c426dabb26fc7d6e785186ce1b5f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}&BV5Z)Ek1!Lr3BFDXW;{am7i%Elc@Mcn@2Q|(R%`XS~m24Tz;T059-n~8b$ANzf zBYAWlMMw+~1H=F^uv83~V?b;yRn=51F+dFbgaO6wvW4 zfhY}H1~ZM|0pU6oP^WV9#Nav|#-)j~3}za2I^$|(n8&P~KVG<69mb^!XWY_AJuyHG z%rj8XW(CjxWB6qjKJw>N$RY-afq%vTFLv#22Z}Oh>$mdotd-FE&`>ZgLj?r%xk~^T ixQ|qoQ~M?A5N8?8G~z5cuF?VNBA^JNju`j_20j411y9%j diff --git a/.gitignore b/.gitignore index db0a997c..9f147c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ /run-testnet.sh /.idea /qortal.iml -*.DS_Store +.DS_Store diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 0c7ee143bd5fb1f88be672d993ba6be20b5a0c82..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK!A=4(5N!dqi!pLAkz+63IDi=NVzOEB;LT)>9@M~YmdGZsYhZDsh}qxJKk^Iw z9cS7?BxvHv7@0|?Z#tc6)3=*W%NXO%IH)mJVvGq;#GD1q7lPxcOOi7lM2>U#>FCby z$3FDmmZHh=7a72BS6~C?v5-Zdzn_2C4@XJ0`p$Ft!rJ*8IK%q zc!j+)FX{QlK9A%6-1hv7u$#6^JI8U9cwy8Xs^l=}Ldf-H7zJ_a#N#L!sd-#IAcQ5X zcBwp>)EfsCS*z_&D{|6oRx7emKb%f2vAb70I_(S|9w$%J=NHW?haXVMw!s;^f-&FV zy*rHhadZp5S^O-HkQg8ah=CPiz#M^MbA|RyTO|gFfuAve=Ys@Av<+q&)zJYBULSG1 zfQSM%z9kT)LEB)a5k^3`P6gDd+&nS3P6xj#VHb%aW?w_!$S3f1 zoM{V@fEN$OkeOuqP3Nagzi!$sV~jV3oeEpy7K}l9B`;(F!*X!kytW^&u6G!asRgO;E_fOB`m&xm!;g`dwsAb#Y z6yCvDveZX!5cO4b2hnNew1SWrAO?tm6=lF4cVcrz*GJ1D28e;*Gl1uV1VwZ$rUv!V z0S*2>Vmyb40ye%S5T!-eVrmc~Al#G!no@3`7~GVDU)ns^VrtNoGp=WbaqP_8zEHTH z9sE+KGwvFsmKY!g<{8MEZUOKAlb_%J^F`Dn28e-`Vu0tGUb6v9vUlsk;_$AOplwhT o%u5ZP>i_@% diff --git a/src/main/resources/.DS_Store b/src/main/resources/.DS_Store deleted file mode 100644 index 33653dbd1c5aa56503e34fa38cb61df10793e80c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}N6?5Kh{v*^1bMpx$!vR-x-p+l#Q&dhjN!=s~63rNu7P-O_Gdv{v>x^o@K0 zU&omwEtV>H60tKd`6iQ@gnYYX62=&>4O=#21;&^FMa)>xd?7fFIwu+9LF71xpA33_ zH}awXHWy8fzsLZ7yBrIc&8jT?{Qdl1HyFgF(mT&&b4$xBLgYn$Eq~*7qQs58csQte z{VVL9d2z=#_IVU_r?%%`1ns0z*f@^D*bBmTUnK{vHiTSX24O2oYSA!k4b(iY9uUG3 zR->>z8kOzCqTJo99E^){RH>ATvbw?LJEU!no%7)&*S2ZSyHiUw+kfnR0d14T_x4gdfE From 35176f955078faa11e9e82a3093eb74918a2d752 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 May 2021 16:57:09 +0100 Subject: [PATCH 11/16] Added other files to .gitignore --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 9f147c4d..d983d858 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ /.idea /qortal.iml .DS_Store +/src/main/resources/resources +/src/main/resources/log*.properties +/*.jar +/run.pid +/run.log From 3eaa4d5b3817405371ca40f52988e729ed8139a2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 May 2021 18:52:03 +0100 Subject: [PATCH 12/16] Added /crosschain/htlc/refund/LITECOIN/{ataddress}/{receivingAddress} API This is the same as the /crosschain/htlc/refund/LITECOIN/{ataddress} API, but allows a custom destination address to be specified. --- .../api/resource/CrossChainHtlcResource.java | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index bbac65a4..e024c8e3 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -374,6 +374,50 @@ public class CrossChainHtlcResource { public boolean refundHtlc(@PathParam("ataddress") String atAddress) { Security.checkApiCallAllowed(request); + try (final Repository repository = RepositoryManager.getRepository()) { + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); + if (tradeBotData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + 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()); + + return this.doRefundHtlc(atAddress, receiveAddress); + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e); + } + } + + @GET + @Path("/refund/LITECOIN/{ataddress}/{receivingAddress}") + @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.
" + + "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 = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) + public boolean refundHtlc(@PathParam("ataddress") String atAddress, + @PathParam("receivingAddress") String receivingAddress) { + Security.checkApiCallAllowed(request); + return this.doRefundHtlc(atAddress, receivingAddress); + } + + + private boolean doRefundHtlc(String atAddress, String receiveAddress) { try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = repository.getATRepository().fromATAddress(atAddress); if (atData == null) @@ -433,9 +477,10 @@ public class CrossChainHtlcResource { ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); - // Determine receive address for refund - String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + // Validate the destination LTC address Address receiving = Address.fromString(litecoin.getNetworkParameters(), receiveAddress); + if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(litecoin.getNetworkParameters(), refundAmount, refundKey, fundingOutputs, redeemScriptA, lockTime, receiving.getHash()); @@ -458,4 +503,4 @@ public class CrossChainHtlcResource { return (lockTimeA - tradeTimeout * 60) * 1000L; } -} \ No newline at end of file +} From 41ad78750eb886b29c035c799577a2525529f434 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 24 May 2021 18:59:41 +0100 Subject: [PATCH 13/16] Don't allow QORT addresses to be used as the receiving address when redeeming LTC This is probably more validation than is actually needed, but given that we use the same field for LTC and QORT receiving addresses in the database, it is best to be extra careful. --- .../qortal/api/resource/CrossChainHtlcResource.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index e024c8e3..119ac2f8 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -26,6 +26,7 @@ import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.CrossChainBitcoinyHTLCStatus; 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; @@ -267,9 +268,9 @@ public class CrossChainHtlcResource { // Search for the litecoin receiving address in the tradebot data byte[] litecoinReceivingAccountInfo = null; - if (tradeBotData != null) - // Use receiving address PKH from tradebot data - litecoinReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + if (tradeBotData != null) + // Use receiving address PKH from tradebot data + litecoinReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); return this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, litecoinReceivingAccountInfo); @@ -304,6 +305,12 @@ public class CrossChainHtlcResource { if (litecoinReceivingAccountInfo == null || litecoinReceivingAccountInfo.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 + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // Use secret-A to redeem P2SH-A From 36c1cfae519028df0f051528c4b7072870568c10 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 24 May 2021 19:00:04 +0100 Subject: [PATCH 14/16] Log the P2SH address when redeeming or refunding LTC via the API. --- .../java/org/qortal/api/resource/CrossChainHtlcResource.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 119ac2f8..0442b274 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -319,6 +319,7 @@ public class CrossChainHtlcResource { int lockTime = crossChainTradeData.lockTimeA; byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTime, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); String p2shAddressA = litecoin.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); @@ -459,6 +460,7 @@ public class CrossChainHtlcResource { byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); String p2shAddressA = litecoin.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); From 2c8b94d469615f2465178b511ef714c1ada7e383 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 24 May 2021 19:38:01 +0100 Subject: [PATCH 15/16] Always use the org.qortal.utils.Base58 implementation A couple of classes were using the bitcoinj alternative, which is twice as slow. This mostly affected the API on port 12392, as byte arrays were automatically encoded as base58 strings via the Base58TypeAdapter / JAXB package-info. --- src/main/java/org/qortal/api/Base58TypeAdapter.java | 2 +- src/main/java/org/qortal/crosschain/BitcoinyHTLC.java | 2 +- src/test/java/org/qortal/test/CryptoTests.java | 2 +- src/test/java/org/qortal/test/apps/DecodeOnlineAccounts.java | 2 +- src/test/java/org/qortal/test/minting/RewardTests.java | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/api/Base58TypeAdapter.java b/src/main/java/org/qortal/api/Base58TypeAdapter.java index 4b292a2a..d7561031 100644 --- a/src/main/java/org/qortal/api/Base58TypeAdapter.java +++ b/src/main/java/org/qortal/api/Base58TypeAdapter.java @@ -2,7 +2,7 @@ package org.qortal.api; import javax.xml.bind.annotation.adapters.XmlAdapter; -import org.bitcoinj.core.Base58; +import org.qortal.utils.Base58; public class Base58TypeAdapter extends XmlAdapter { diff --git a/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java b/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java index af93091f..8ebfffa2 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java +++ b/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java @@ -10,7 +10,6 @@ import java.util.Map; import java.util.function.Function; import org.bitcoinj.core.Address; -import org.bitcoinj.core.Base58; import org.bitcoinj.core.Coin; import org.bitcoinj.core.ECKey; import org.bitcoinj.core.LegacyAddress; @@ -25,6 +24,7 @@ import org.bitcoinj.script.ScriptBuilder; import org.bitcoinj.script.ScriptChunk; import org.bitcoinj.script.ScriptOpCodes; import org.qortal.crypto.Crypto; +import org.qortal.utils.Base58; import org.qortal.utils.BitTwiddling; import com.google.common.hash.HashCode; diff --git a/src/test/java/org/qortal/test/CryptoTests.java b/src/test/java/org/qortal/test/CryptoTests.java index 0e294c63..46edc698 100644 --- a/src/test/java/org/qortal/test/CryptoTests.java +++ b/src/test/java/org/qortal/test/CryptoTests.java @@ -6,12 +6,12 @@ import org.qortal.block.BlockChain; import org.qortal.crypto.BouncyCastle25519; import org.qortal.crypto.Crypto; import org.qortal.test.common.Common; +import org.qortal.utils.Base58; import static org.junit.Assert.*; import java.security.SecureRandom; -import org.bitcoinj.core.Base58; import org.bouncycastle.crypto.agreement.X25519Agreement; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; diff --git a/src/test/java/org/qortal/test/apps/DecodeOnlineAccounts.java b/src/test/java/org/qortal/test/apps/DecodeOnlineAccounts.java index 1781f719..9242c422 100644 --- a/src/test/java/org/qortal/test/apps/DecodeOnlineAccounts.java +++ b/src/test/java/org/qortal/test/apps/DecodeOnlineAccounts.java @@ -3,7 +3,6 @@ package org.qortal.test.apps; import java.math.BigDecimal; import java.security.Security; -import org.bitcoinj.core.Base58; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.qortal.block.BlockChain; @@ -17,6 +16,7 @@ import org.qortal.repository.RepositoryManager; import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; import org.qortal.settings.Settings; import org.qortal.transform.block.BlockTransformer; +import org.qortal.utils.Base58; import org.roaringbitmap.IntIterator; import io.druid.extendedset.intset.ConciseSet; diff --git a/src/test/java/org/qortal/test/minting/RewardTests.java b/src/test/java/org/qortal/test/minting/RewardTests.java index 6c03662c..7161aa00 100644 --- a/src/test/java/org/qortal/test/minting/RewardTests.java +++ b/src/test/java/org/qortal/test/minting/RewardTests.java @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import org.bitcoinj.core.Base58; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -25,6 +24,7 @@ import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; import org.qortal.test.common.TestAccount; import org.qortal.utils.Amounts; +import org.qortal.utils.Base58; public class RewardTests extends Common { @@ -789,4 +789,4 @@ public class RewardTests extends Common { return repository.getAccountRepository().getAccount(testAccount.getAddress()).getFlags(); } -} \ No newline at end of file +} From c8897ecf9bcf80470bd318f6b5a6c5ec0f6d5943 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 24 May 2021 19:52:20 +0100 Subject: [PATCH 16/16] Rewrite of HSQLDBATRepository.getBlockATStatesAtHeight() SQL query The previous query was taking almost half a second to run each time, whereas the new version runs 10-100x faster. This was the main bottleneck with block serialization and should therefore allow for much faster syncing once rolled out to the network. Tested several thousand blocks to ensure that the results returned by the new query match those returned by the old one. --- .../org/qortal/repository/hsqldb/HSQLDBATRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 8193c5d2..c21dbf8c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -542,9 +542,9 @@ public class HSQLDBATRepository implements ATRepository { public List getBlockATStatesAtHeight(int height) throws DataException { String sql = "SELECT AT_address, state_hash, fees, is_initial " + "FROM ATs " - + "LEFT OUTER JOIN ATStates " - + "ON ATStates.AT_address = ATs.AT_address AND height = ? " - + "WHERE ATStates.AT_address IS NOT NULL " + + "JOIN ATStates " + + "ON ATStates.AT_address = ATs.AT_address " + + "WHERE height = ? " + "ORDER BY created_when ASC"; List atStates = new ArrayList<>();