diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 527db761..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index db0a997c..d983d858 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,9 @@ /run-testnet.sh /.idea /qortal.iml -*.DS_Store +.DS_Store +/src/main/resources/resources +/src/main/resources/log*.properties +/*.jar +/run.pid +/run.log 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 @@ - + 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 diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 0c7ee143..00000000 Binary files a/src/.DS_Store and /dev/null differ diff --git a/src/main/.DS_Store b/src/main/.DS_Store deleted file mode 100644 index b5507e38..00000000 Binary files a/src/main/.DS_Store and /dev/null differ 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/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/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( 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/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 8bd2dc8b..0442b274 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -16,24 +16,32 @@ 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.crypto.Crypto; +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; +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; @@ -41,7 +49,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 +58,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 +81,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 +99,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 +108,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 +120,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 +133,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(); @@ -168,8 +176,340 @@ public class CrossChainHtlcResource { } } - // TODO: refund + @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); - // TODO: redeem + // base58 decode the trade private key + byte[] decodedTradePrivateKey = null; + if (tradePrivateKey != null) + decodedTradePrivateKey = Base58.decode(tradePrivateKey); -} \ No newline at end of file + // 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 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")) + ) + } + ) + @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) + 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); + + // 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); + } + + 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); + + 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); + + // 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 + + Litecoin litecoin = Litecoin.getInstance(); + + 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); + 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(decodedTradePrivateKey); + 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; + } + + @GET + @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")) + ) + } + ) + @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()) { + 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) + 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); + 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 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); + + // 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()); + + 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; + } + +} 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; + } + +} 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/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<>(); diff --git a/src/main/resources/.DS_Store b/src/main/resources/.DS_Store deleted file mode 100644 index 33653dbd..00000000 Binary files a/src/main/resources/.DS_Store and /dev/null differ 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 +}