diff --git a/src/main/java/org/qortal/api/ApiError.java b/src/main/java/org/qortal/api/ApiError.java index f4cfdfb5..50fa6294 100644 --- a/src/main/java/org/qortal/api/ApiError.java +++ b/src/main/java/org/qortal/api/ApiError.java @@ -117,7 +117,12 @@ public enum ApiError { // MESSAGESIZE_EXCEEDED(1004, 400), // Groups - GROUP_UNKNOWN(1101, 404); + GROUP_UNKNOWN(1101, 404), + + // Bitcoin + BTC_NETWORK_ISSUE(1201, 500), + BTC_BALANCE_ISSUE(1202, 422), + BTC_TOO_SOON(1203, 422); private static final Map map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError)); diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinP2SHStatus.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinP2SHStatus.java new file mode 100644 index 00000000..ff986e86 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinP2SHStatus.java @@ -0,0 +1,31 @@ +package org.qortal.api.model; + +import java.math.BigDecimal; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainBitcoinP2SHStatus { + + @Schema(description = "Bitcoin P2SH address", example = "3CdH27kTpV8dcFHVRYjQ8EEV5FJg9X8pSJ (mainnet), 2fMiRRXVsxhZeyfum9ifybZvaMHbQTmwdZw (testnet)") + public String bitcoinP2shAddress; + + @Schema(description = "Bitcoin P2SH balance") + public BigDecimal bitcoinP2shBalance; + + @Schema(description = "Can P2SH redeem yet?") + public boolean canRedeem; + + @Schema(description = "Can P2SH refund yet?") + public boolean canRefund; + + @Schema(description = "Secret extracted by P2SH redeemer") + public byte[] secret; + + public CrossChainBitcoinP2SHStatus() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java new file mode 100644 index 00000000..fcbf2ec4 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java @@ -0,0 +1,31 @@ +package org.qortal.api.model; + +import java.math.BigDecimal; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainBitcoinRedeemRequest { + + @Schema(description = "Bitcoin P2PKH address for refund", example = "1BwG6aG2GapFX5b4JT4ohbsYvj1xZ8d2EJ (mainnet), mrTDPdM15cFWJC4g223BXX5snicfVJBx6M (testnet)") + public String refundAddress; + + @Schema(description = "Bitcoin PRIVATE KEY for redeem", example = "cSP3zTb6bfm8GATtAcEJ8LqYtNQmzZ9jE2wQUVnZGiBzojDdrwKV") + public byte[] redeemPrivateKey; + + @Schema(description = "Qortal AT address") + public String atAddress; + + @Schema(description = "Bitcoin miner fee", example = "0.00001000") + public BigDecimal bitcoinMinerFee; + + @Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG") + public byte[] secret; + + public CrossChainBitcoinRedeemRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java new file mode 100644 index 00000000..490ee935 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java @@ -0,0 +1,28 @@ +package org.qortal.api.model; + +import java.math.BigDecimal; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainBitcoinRefundRequest { + + @Schema(description = "Bitcoin PRIVATE KEY for refund", example = "cSP3zTb6bfm8GATtAcEJ8LqYtNQmzZ9jE2wQUVnZGiBzojDdrwKV") + public byte[] refundPrivateKey; + + @Schema(description = "Bitcoin P2PKH address for redeem", example = "1BwG6aG2GapFX5b4JT4ohbsYvj1xZ8d2EJ (mainnet), mrTDPdM15cFWJC4g223BXX5snicfVJBx6M (testnet)") + public String redeemAddress; + + @Schema(description = "Qortal AT address") + public String atAddress; + + @Schema(description = "Bitcoin miner fee", example = "0.00001000") + public BigDecimal bitcoinMinerFee; + + public CrossChainBitcoinRefundRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinTemplateRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinTemplateRequest.java new file mode 100644 index 00000000..c23815bb --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinTemplateRequest.java @@ -0,0 +1,23 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainBitcoinTemplateRequest { + + @Schema(description = "Bitcoin P2PKH address for refund", example = "1BwG6aG2GapFX5b4JT4ohbsYvj1xZ8d2EJ (mainnet), mrTDPdM15cFWJC4g223BXX5snicfVJBx6M (testnet)") + public String refundAddress; + + @Schema(description = "Bitcoin P2PKH address for redeem", example = "1BwG6aG2GapFX5b4JT4ohbsYvj1xZ8d2EJ (mainnet), mrTDPdM15cFWJC4g223BXX5snicfVJBx6M (testnet)") + public String redeemAddress; + + @Schema(description = "Qortal AT address") + public String atAddress; + + public CrossChainBitcoinTemplateRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java index 8eab7f91..e1f57a7e 100644 --- a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java @@ -11,6 +11,7 @@ public class CrossChainCancelRequest { @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") public byte[] creatorPublicKey; + @Schema(description = "Qortal AT address") public String atAddress; public CrossChainCancelRequest() { diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java index 64c7bc89..99820022 100644 --- a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java @@ -8,9 +8,10 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainSecretRequest { - @Schema(description = "AT's 'recipient' public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + @Schema(description = "Public key to match AT's 'recipient'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") public byte[] recipientPublicKey; + @Schema(description = "Qortal AT address") public String atAddress; @Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG") diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java index ab53b587..32737dd5 100644 --- a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java @@ -11,8 +11,10 @@ public class CrossChainTradeRequest { @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") public byte[] creatorPublicKey; + @Schema(description = "Qortal AT address") public String atAddress; + @Schema(description = "Qortal address for trade partner/recipient") public String recipient; public CrossChainTradeRequest() { diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 7e53c53e..684e09ef 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -23,7 +23,14 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; -import org.ciyam.at.MachineState; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.script.Script.ScriptType; +import org.bitcoinj.wallet.WalletTransaction; import org.qortal.account.PublicKeyAccount; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; @@ -32,13 +39,16 @@ import org.qortal.api.ApiExceptionFactory; import org.qortal.api.model.CrossChainCancelRequest; import org.qortal.api.model.CrossChainSecretRequest; import org.qortal.api.model.CrossChainTradeRequest; +import org.qortal.api.model.CrossChainBitcoinP2SHStatus; +import org.qortal.api.model.CrossChainBitcoinRedeemRequest; +import org.qortal.api.model.CrossChainBitcoinRefundRequest; +import org.qortal.api.model.CrossChainBitcoinTemplateRequest; import org.qortal.api.model.CrossChainBuildRequest; import org.qortal.asset.Asset; -import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; @@ -85,9 +95,7 @@ public class CrossChainResource { ) } ) - @ApiErrors({ - ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE - }) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public List getTradeOffers( @Parameter( ref = "limit") @QueryParam("limit") Integer limit, @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, @@ -104,21 +112,7 @@ public class CrossChainResource { List crossChainTradesData = new ArrayList<>(); for (ATData atData : atsData) { - String atAddress = atData.getATAddress(); - - ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); - - QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, atStateData.getStateData()); - - CrossChainTradeData crossChainTradeData = new CrossChainTradeData(); - crossChainTradeData.qortalAddress = atAddress; - crossChainTradeData.qortalCreator = Crypto.toAddress(atData.getCreatorPublicKey()); - crossChainTradeData.creationTimestamp = atData.getCreation(); - crossChainTradeData.qortBalance = repository.getAccountRepository().getBalance(atAddress, Asset.QORT).getBalance(); - - BTCACCT.populateTradeData(crossChainTradeData, dataBytes); - + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); crossChainTradesData.add(crossChainTradeData); } @@ -150,14 +144,14 @@ public class CrossChainResource { ) } ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.REPOSITORY_ISSUE}) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_DATA, ApiError.INVALID_REFERENCE, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) public String buildTrade(CrossChainBuildRequest tradeRequest) { byte[] creatorPublicKey = tradeRequest.creatorPublicKey; if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - if (tradeRequest.secretHash == null || tradeRequest.secretHash.length != 20) + if (tradeRequest.secretHash == null || tradeRequest.secretHash.length != BTC.HASH160_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); if (tradeRequest.tradeTimeout == null) @@ -194,7 +188,7 @@ public class CrossChainResource { BigDecimal fee = BigDecimal.ZERO; String name = "QORT-BTC cross-chain trade"; - String description = String.format("Qortal-Bitcoin cross-chain trade"); + String description = "Qortal-Bitcoin cross-chain trade"; String atType = "ACCT"; String tags = "QORT-BTC ACCT"; @@ -245,9 +239,7 @@ public class CrossChainResource { ) } ) - @ApiErrors({ - ApiError.REPOSITORY_ISSUE - }) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) { byte[] creatorPublicKey = tradeRequest.creatorPublicKey; @@ -262,15 +254,7 @@ public class CrossChainResource { try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, tradeRequest.atAddress); - - // Determine state of AT - ATStateData atStateData = repository.getATRepository().getLatestATState(tradeRequest.atAddress); - - QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, atStateData.getStateData()); - - CrossChainTradeData crossChainTradeData = new CrossChainTradeData(); - BTCACCT.populateTradeData(crossChainTradeData, dataBytes); + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -312,9 +296,7 @@ public class CrossChainResource { ) } ) - @ApiErrors({ - ApiError.REPOSITORY_ISSUE - }) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public String sendSecret(CrossChainSecretRequest secretRequest) { byte[] recipientPublicKey = secretRequest.recipientPublicKey; @@ -324,20 +306,12 @@ public class CrossChainResource { if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - if (secretRequest.secret == null || secretRequest.secret.length != 32) + if (secretRequest.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, null, secretRequest.atAddress); // null to skip creator check - - // Determine state of AT - ATStateData atStateData = repository.getATRepository().getLatestATState(secretRequest.atAddress); - - QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, atStateData.getStateData()); - - CrossChainTradeData crossChainTradeData = new CrossChainTradeData(); - BTCACCT.populateTradeData(crossChainTradeData, dataBytes); + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == CrossChainTradeData.Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -385,9 +359,7 @@ public class CrossChainResource { ) } ) - @ApiErrors({ - ApiError.REPOSITORY_ISSUE - }) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) { byte[] creatorPublicKey = cancelRequest.creatorPublicKey; @@ -399,15 +371,7 @@ public class CrossChainResource { try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, cancelRequest.atAddress); - - // Determine state of AT - ATStateData atStateData = repository.getATRepository().getLatestATState(cancelRequest.atAddress); - - QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, atStateData.getStateData()); - - CrossChainTradeData crossChainTradeData = new CrossChainTradeData(); - BTCACCT.populateTradeData(crossChainTradeData, dataBytes); + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -426,6 +390,384 @@ public class CrossChainResource { } } + @POST + @Path("/p2sh") + @Operation( + summary = "Returns Bitcoin P2SH address based on trade info", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinTemplateRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest) { + BTC btc = BTC.getInstance(); + NetworkParameters params = btc.getNetworkParameters(); + + Address refundBitcoinAddress = null; + Address redeemBitcoinAddress = null; + + try { + if (templateRequest.refundAddress == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + refundBitcoinAddress = Address.fromString(params, templateRequest.refundAddress); + if (refundBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + + try { + if (templateRequest.redeemAddress == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + redeemBitcoinAddress = Address.fromString(params, templateRequest.redeemAddress); + if (redeemBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + + if (templateRequest.atAddress == null || !Crypto.isValidAtAddress(templateRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Extract data from cross-chain trading AT + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, null, templateRequest.atAddress); // null to skip creator check + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), crossChainTradeData.lockTime, redeemBitcoinAddress.getHash(), crossChainTradeData.secretHash); + byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + + Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + return p2shAddress.toString(); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/p2sh/check") + @Operation( + summary = "Checks Bitcoin P2SH address based on trade info", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinTemplateRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinP2SHStatus.class)) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest) { + BTC btc = BTC.getInstance(); + NetworkParameters params = btc.getNetworkParameters(); + + Address refundBitcoinAddress = null; + Address redeemBitcoinAddress = null; + + try { + if (templateRequest.refundAddress == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + refundBitcoinAddress = Address.fromString(params, templateRequest.refundAddress); + if (refundBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + + try { + if (templateRequest.redeemAddress == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + redeemBitcoinAddress = Address.fromString(params, templateRequest.redeemAddress); + if (redeemBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + + if (templateRequest.atAddress == null || !Crypto.isValidAtAddress(templateRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Extract data from cross-chain trading AT + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, null, templateRequest.atAddress); // null to skip creator check + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), crossChainTradeData.lockTime, redeemBitcoinAddress.getHash(), crossChainTradeData.secretHash); + byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + + Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + + long medianBlockTime = BTC.getInstance().getMedianBlockTime(); + long now = NTP.getTime(); + + // Check P2SH is funded + final int startTime = (int) (crossChainTradeData.tradeModeTimestamp / 1000L); + List fundingOutputs = new ArrayList<>(); + List walletTransactions = new ArrayList<>(); + + Coin p2shBalance = BTC.getInstance().getBalanceAndOtherInfo(p2shAddress.toString(), startTime, fundingOutputs, walletTransactions); + if (p2shBalance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + CrossChainBitcoinP2SHStatus p2shStatus = new CrossChainBitcoinP2SHStatus(); + p2shStatus.bitcoinP2shAddress = p2shAddress.toString(); + p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance.value, 8); + + long unscaledExpectedBitcoin = crossChainTradeData.expectedBitcoin.unscaledValue().longValue(); + if (p2shBalance.value >= unscaledExpectedBitcoin && fundingOutputs.size() == 1) { + p2shStatus.canRedeem = now >= medianBlockTime * 1000L; + p2shStatus.canRefund = now >= crossChainTradeData.lockTime * 1000L; + } + + if (now >= medianBlockTime * 1000L) { + // See if we can extract secret + p2shStatus.secret = BTCACCT.findP2shSecret(p2shStatus.bitcoinP2shAddress, walletTransactions); + } + + return p2shStatus; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/p2sh/refund") + @Operation( + summary = "Returns serialized Bitcoin transaction attempting refund from P2SH address", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinRefundRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, + ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + public String refundP2sh(CrossChainBitcoinRefundRequest refundRequest) { + BTC btc = BTC.getInstance(); + NetworkParameters params = btc.getNetworkParameters(); + + byte[] refundPrivateKey = refundRequest.refundPrivateKey; + if (refundPrivateKey == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + ECKey refundKey = null; + Address redeemBitcoinAddress = null; + + try { + // Auto-trim + if (refundPrivateKey.length >= 37 && refundPrivateKey.length <= 38) + refundPrivateKey = Arrays.copyOfRange(refundPrivateKey, 1, 33); + if (refundPrivateKey.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + refundKey = ECKey.fromPrivate(refundPrivateKey); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + + try { + if (refundRequest.redeemAddress == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + redeemBitcoinAddress = Address.fromString(params, refundRequest.redeemAddress); + if (redeemBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + + if (refundRequest.atAddress == null || !Crypto.isValidAtAddress(refundRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + Address refundAddress = Address.fromKey(params, refundKey, ScriptType.P2PKH); + + // Extract data from cross-chain trading AT + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, null, refundRequest.atAddress); // null to skip creator check + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCACCT.buildScript(refundAddress.getHash(), crossChainTradeData.lockTime, redeemBitcoinAddress.getHash(), crossChainTradeData.secretHash); + byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + + Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + + long now = NTP.getTime(); + + // Check P2SH is funded + final int startTime = (int) (crossChainTradeData.tradeModeTimestamp / 1000L); + List fundingOutputs = new ArrayList<>(); + + Coin p2shBalance = BTC.getInstance().getBalanceAndOtherInfo(p2shAddress.toString(), startTime, fundingOutputs, null); + if (p2shBalance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + if (fundingOutputs.size() != 1) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + TransactionOutput fundingOutput = fundingOutputs.get(0); + boolean canRefund = now >= crossChainTradeData.lockTime * 1000L; + if (!canRefund) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); + + long unscaledExpectedBitcoin = crossChainTradeData.expectedBitcoin.unscaledValue().longValue(); + if (p2shBalance.value < unscaledExpectedBitcoin) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); + + Coin refundAmount = p2shBalance.subtract(Coin.valueOf(refundRequest.bitcoinMinerFee.unscaledValue().longValue())); + + org.bitcoinj.core.Transaction refundTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutput, redeemScriptBytes, crossChainTradeData.lockTime); + boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction); + + if (!wasBroadcast) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + + return refundTransaction.getTxId().toString(); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/p2sh/redeem") + @Operation( + summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH address", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinRedeemRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, + ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + public String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest) { + BTC btc = BTC.getInstance(); + NetworkParameters params = btc.getNetworkParameters(); + + byte[] redeemPrivateKey = redeemRequest.redeemPrivateKey; + if (redeemPrivateKey == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + ECKey redeemKey = null; + Address refundBitcoinAddress = null; + + try { + // Auto-trim + if (redeemPrivateKey.length >= 37 && redeemPrivateKey.length <= 38) + redeemPrivateKey = Arrays.copyOfRange(redeemPrivateKey, 1, 33); + if (redeemPrivateKey.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + redeemKey = ECKey.fromPrivate(redeemPrivateKey); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + + try { + if (redeemRequest.refundAddress == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + refundBitcoinAddress = Address.fromString(params, redeemRequest.refundAddress); + if (refundBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + + if (redeemRequest.atAddress == null || !Crypto.isValidAtAddress(redeemRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (redeemRequest.secret == null || redeemRequest.secret.length != BTCACCT.SECRET_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + Address redeemAddress = Address.fromKey(params, redeemKey, ScriptType.P2PKH); + + // Extract data from cross-chain trading AT + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, null, redeemRequest.atAddress); // null to skip creator check + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), crossChainTradeData.lockTime, redeemAddress.getHash(), crossChainTradeData.secretHash); + byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes); + + Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + + long medianBlockTime = BTC.getInstance().getMedianBlockTime(); + long now = NTP.getTime(); + + // Check P2SH is funded + final int startTime = (int) (crossChainTradeData.tradeModeTimestamp / 1000L); + List fundingOutputs = new ArrayList<>(); + + Coin p2shBalance = BTC.getInstance().getBalanceAndOtherInfo(p2shAddress.toString(), startTime, fundingOutputs, null); + if (p2shBalance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + if (fundingOutputs.size() != 1) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + TransactionOutput fundingOutput = fundingOutputs.get(0); + boolean canRedeem = now >= medianBlockTime * 1000L; + if (!canRedeem) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); + + long unscaledExpectedBitcoin = crossChainTradeData.expectedBitcoin.unscaledValue().longValue(); + if (p2shBalance.value < unscaledExpectedBitcoin) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); + + Coin redeemAmount = p2shBalance.subtract(Coin.valueOf(redeemRequest.bitcoinMinerFee.unscaledValue().longValue())); + + org.bitcoinj.core.Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutput, redeemScriptBytes, redeemRequest.secret); + boolean wasBroadcast = BTC.getInstance().broadcastTransaction(redeemTransaction); + + if (!wasBroadcast) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + + return redeemTransaction.getTxId().toString(); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + private ATData fetchAtDataWithChecking(Repository repository, byte[] creatorPublicKey, String atAddress) throws DataException { ATData atData = repository.getATRepository().fromATAddress(atAddress); if (atData == null) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 08fdc953..4106e5b0 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -35,6 +35,7 @@ import org.qortal.block.BlockChain; import org.qortal.block.BlockMinter; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.crosschain.BTC; import org.qortal.crypto.Crypto; import org.qortal.data.account.MintingAccountData; import org.qortal.data.account.RewardShareData; @@ -377,6 +378,9 @@ public class Controller extends Thread { return; // Not System.exit() so that GUI can display error } + LOGGER.info(String.format("Starting Bitcoin support using %s", Settings.getInstance().getBitcoinNet().name())); + BTC.getInstance(); + // If GUI is enabled, we're no longer starting up but actually running now Gui.getInstance().notifyRunning(); } @@ -687,6 +691,9 @@ public class Controller extends Thread { if (!isStopping) { isStopping = true; + LOGGER.info("Shutting down Bitcoin support"); + BTC.getInstance().shutdown(); + LOGGER.info("Shutting down API"); ApiService.getInstance().stop(); diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 2ad069f7..caa10c36 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.TreeMap; +import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicReference; import org.apache.logging.log4j.LogManager; @@ -35,11 +36,13 @@ import org.bitcoinj.core.CheckpointManager; import org.bitcoinj.core.Coin; import org.bitcoinj.core.ECKey; import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Peer; import org.bitcoinj.core.PeerAddress; import org.bitcoinj.core.PeerGroup; import org.bitcoinj.core.Sha256Hash; import org.bitcoinj.core.StoredBlock; import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionBroadcast; import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.core.listeners.BlocksDownloadedEventListener; import org.bitcoinj.core.listeners.NewBestBlockListener; @@ -54,6 +57,7 @@ import org.bitcoinj.store.MemoryBlockStore; import org.bitcoinj.utils.MonetaryFormat; import org.bitcoinj.utils.Threading; import org.bitcoinj.wallet.Wallet; +import org.bitcoinj.wallet.WalletTransaction; import org.bitcoinj.wallet.listeners.WalletCoinsReceivedEventListener; import org.bitcoinj.wallet.listeners.WalletCoinsSentEventListener; import org.qortal.settings.Settings; @@ -63,6 +67,7 @@ public class BTC { public static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode(); public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL; public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1; + public static final int HASH160_LENGTH = 20; private static final MessageDigest RIPE_MD160_DIGESTER; private static final MessageDigest SHA256_DIGESTER; @@ -100,16 +105,6 @@ public class BTC { public abstract NetworkParameters getParams(); } - private static BTC instance; - - private final NetworkParameters params; - private final String checkpointsFileName; - private final File directory; - - private PeerGroup peerGroup; - private BlockStore blockStore; - private BlockChain chain; - private static class UpdateableCheckpointManager extends CheckpointManager implements NewBestBlockListener { private static final long CHECKPOINT_THRESHOLD = 7 * 24 * 60 * 60; // seconds @@ -235,6 +230,27 @@ public class BTC { } } } + + private static class ResettableBlockChain extends BlockChain { + public ResettableBlockChain(NetworkParameters params, BlockStore blockStore) throws BlockStoreException { + super(params, blockStore); + } + + public void setChainHead(StoredBlock chainHead) throws BlockStoreException { + super.setChainHead(chainHead); + } + } + + private static BTC instance; + + private final NetworkParameters params; + private final String checkpointsFileName; + private final File directory; + + private PeerGroup peerGroup; + private BlockStore blockStore; + private ResettableBlockChain chain; + private UpdateableCheckpointManager manager; // Constructors and instance @@ -278,6 +294,13 @@ public class BTC { } catch (IOException e) { throw new RuntimeException("Failed to load BTC checkpoints", e); } + + try { + this.start(System.currentTimeMillis() / 1000L); + // this.peerGroup.waitForPeers(this.peerGroup.getMaxConnections()).get(); + } catch (BlockStoreException e) { + throw new RuntimeException("Failed to start BTC instance", e); + } } public static synchronized BTC getInstance() { @@ -315,10 +338,12 @@ public class BTC { this.blockStore.put(checkpoint); this.blockStore.setChainHead(checkpoint); - this.chain = new BlockChain(this.params, this.blockStore); + this.chain = new ResettableBlockChain(this.params, this.blockStore); this.peerGroup = new PeerGroup(this.params, this.chain); this.peerGroup.setUserAgent("qortal", "1.0"); + this.peerGroup.setPingIntervalMsec(1000L); + this.peerGroup.setMaxConnections(20); if (this.params != RegTestParams.get()) { this.peerGroup.addPeerDiscovery(new DnsDiscovery(this.params)); @@ -329,7 +354,7 @@ public class BTC { this.peerGroup.start(); } - private void stop() { + public void shutdown() { this.peerGroup.stop(); } @@ -357,8 +382,11 @@ public class BTC { } } - private void replayChain(long startTime, Wallet wallet, ReplayHooks replayHooks) throws BlockStoreException { - this.start(startTime); + private void replayChain(int startTime, Wallet wallet, ReplayHooks replayHooks) throws BlockStoreException { + StoredBlock checkpoint = this.manager.getCheckpointBefore(startTime - 1); + this.blockStore.put(checkpoint); + this.blockStore.setChainHead(checkpoint); + this.chain.setChainHead(checkpoint); final WalletCoinsReceivedEventListener coinsReceivedListener = (someWallet, tx, prevBalance, newBalance) -> { LOGGER.debug(String.format("Wallet-related transaction %s", tx.getTxId())); @@ -398,24 +426,23 @@ public class BTC { this.chain.removeWallet(wallet); } - this.stop(); + // For safety, disconnect download peer just in case + Peer downloadPeer = this.peerGroup.getDownloadPeer(); + if (downloadPeer != null) + downloadPeer.close(); } } - private void replayChain(long startTime) throws BlockStoreException { - this.replayChain(startTime, null, null); - } - // Actual useful methods for use by other classes /** Returns median timestamp from latest 11 blocks, in seconds. */ public Long getMedianBlockTime() { // 11 blocks, at roughly 10 minutes per block, means we should go back at least 110 minutes // but some blocks have been way longer than 10 minutes, so be massively pessimistic - long startTime = (System.currentTimeMillis() / 1000L) - 11 * 60 * 60; // 11 hours before now, in seconds + int startTime = (int) (System.currentTimeMillis() / 1000L) - 110 * 60; // 110 minutes before now, in seconds try { - replayChain(startTime); + this.replayChain(startTime, null, null); List latestBlocks = new ArrayList<>(11); StoredBlock block = this.blockStore.getChainHead(); @@ -434,7 +461,7 @@ public class BTC { } } - public Coin getBalance(String base58Address, long startTime) { + public Coin getBalance(String base58Address, int startTime) { // Create new wallet containing only the address we're interested in, ignoring anything prior to startTime Wallet wallet = createEmptyWallet(); Address address = Address.fromString(this.params, base58Address); @@ -451,7 +478,7 @@ public class BTC { } } - public List getOutputs(String base58Address, long startTime) { + public List getOutputs(String base58Address, int startTime) { Wallet wallet = createEmptyWallet(); Address address = Address.fromString(this.params, base58Address); wallet.addWatchedAddress(address, startTime); @@ -467,7 +494,30 @@ public class BTC { } } - public List getOutputs(byte[] txId, long startTime) { + public Coin getBalanceAndOtherInfo(String base58Address, int startTime, List unspentOutputs, List walletTransactions) { + // Create new wallet containing only the address we're interested in, ignoring anything prior to startTime + Wallet wallet = createEmptyWallet(); + Address address = Address.fromString(this.params, base58Address); + wallet.addWatchedAddress(address, startTime); + + try { + replayChain(startTime, wallet, null); + + if (unspentOutputs != null) + unspentOutputs.addAll(wallet.getWatchedOutputs(true)); + + if (walletTransactions != null) + for (WalletTransaction walletTransaction : wallet.getWalletTransactions()) + walletTransactions.add(walletTransaction); + + return wallet.getBalance(); + } catch (BlockStoreException e) { + LOGGER.error(String.format("BTC blockstore issue: %s", e.getMessage())); + return null; + } + } + + public List getOutputs(byte[] txId, int startTime) { Wallet wallet = createEmptyWallet(); // Add random address to wallet @@ -505,4 +555,15 @@ public class BTC { } } + public boolean broadcastTransaction(Transaction transaction) { + TransactionBroadcast transactionBroadcast = this.peerGroup.broadcastTransaction(transaction); + + try { + transactionBroadcast.future().get(); + return true; + } catch (InterruptedException | ExecutionException e) { + return false; + } + } + } diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 81a8c66d..943ad519 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -5,8 +5,10 @@ import static org.ciyam.at.OpCode.calcOffset; import java.math.BigDecimal; import java.nio.ByteBuffer; import java.util.Arrays; +import java.util.List; import java.util.function.Function; +import org.bitcoinj.core.Address; import org.bitcoinj.core.Coin; import org.bitcoinj.core.ECKey; import org.bitcoinj.core.NetworkParameters; @@ -19,6 +21,8 @@ import org.bitcoinj.script.Script; import org.bitcoinj.script.ScriptBuilder; import org.bitcoinj.script.ScriptChunk; import org.bitcoinj.script.ScriptOpCodes; +import org.bitcoinj.script.Script.ScriptType; +import org.bitcoinj.wallet.WalletTransaction; import org.ciyam.at.API; import org.ciyam.at.CompilationException; import org.ciyam.at.FunctionCode; @@ -26,8 +30,17 @@ import org.ciyam.at.MachineState; import org.ciyam.at.OpCode; import org.ciyam.at.Timestamp; import org.qortal.account.Account; +import org.qortal.asset.Asset; +import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.block.BlockChain; +import org.qortal.block.BlockChain.CiyamAtSettings; import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; import org.qortal.utils.Base58; import org.qortal.utils.BitTwiddling; @@ -57,6 +70,8 @@ import com.google.common.primitives.Bytes; public class BTCACCT { + public static final int SECRET_LENGTH = 32; + public static final int MIN_LOCKTIME = 1500000000; public static final byte[] CODE_BYTES_HASH = HashCode.fromString("edcdb1feb36e079c5f956faff2f24219b12e5fbaaa05654335e615e33218282f").asBytes(); // SHA256 of AT code bytes /* @@ -511,12 +526,27 @@ public class BTCACCT { } /** - * Populates passed CrossChainTradeData with useful info extracted from AT data segment. + * Returns CrossChainTradeData with useful info extracted from AT. * - * @param tradeData - * @param dataBytes + * @param repository + * @param atAddress + * @throws DataException */ - public static void populateTradeData(CrossChainTradeData tradeData, byte[] dataBytes) { + public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { + String atAddress = atData.getATAddress(); + + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + CrossChainTradeData tradeData = new CrossChainTradeData(); + tradeData.qortalAtAddress = atAddress; + tradeData.qortalCreator = Crypto.toAddress(atData.getCreatorPublicKey()); + tradeData.creationTimestamp = atData.getCreation(); + tradeData.qortBalance = repository.getAccountRepository().getBalance(atAddress, Asset.QORT).getBalance(); + ByteBuffer dataByteBuffer = ByteBuffer.wrap(dataBytes); byte[] addressBytes = new byte[32]; @@ -524,8 +554,9 @@ public class BTCACCT { dataByteBuffer.position(dataByteBuffer.position() + 32); // Hash of secret - tradeData.secretHash = new byte[32]; + tradeData.secretHash = new byte[20]; dataByteBuffer.get(tradeData.secretHash); + dataByteBuffer.position(dataByteBuffer.position() + 32 - 20); // skip to 32 bytes // Trade timeout tradeData.tradeRefundTimeout = dataByteBuffer.getLong(); @@ -569,9 +600,65 @@ public class BTCACCT { if (addressBytes[0] != 0) tradeData.qortalRecipient = Base58.encode(Arrays.copyOf(addressBytes, Account.ADDRESS_LENGTH)); + + // We'll suggest half of trade timeout + CiyamAtSettings ciyamAtSettings = BlockChain.getInstance().getCiyamAtSettings(); + + int tradeModeSwitchHeight = (int) (tradeData.tradeRefundHeight - tradeData.tradeRefundTimeout / ciyamAtSettings.minutesPerBlock); + + BlockData blockData = repository.getBlockRepository().fromHeight(tradeModeSwitchHeight); + if (blockData != null) { + tradeData.tradeModeTimestamp = blockData.getTimestamp(); // NOTE: milliseconds from epoch + tradeData.lockTime = (int) (tradeData.tradeModeTimestamp / 1000L + tradeData.tradeRefundTimeout / 2 * 60); + } } else { tradeData.mode = CrossChainTradeData.Mode.OFFER; } + + return tradeData; + } + + public static byte[] findP2shSecret(String p2shAddress, List walletTransactions) { + NetworkParameters params = BTC.getInstance().getNetworkParameters(); + + for (WalletTransaction walletTransaction : walletTransactions) { + Transaction transaction = walletTransaction.getTransaction(); + + // Cycle through inputs, looking for one that spends our P2SH + for (TransactionInput input : transaction.getInputs()) { + TransactionOutput connectedOutput = input.getConnectedOutput(); + if (connectedOutput == null) + // We don't know about this transaction that this input is spending, so won't be our P2SH + continue; + + Script scriptPubKey = connectedOutput.getScriptPubKey(); + ScriptType scriptType = scriptPubKey.getScriptType(); + if (scriptType != ScriptType.P2SH) + // Input isn't spending our P2SH + continue; + + Address inputAddress = scriptPubKey.getToAddress(params); + if (!inputAddress.toString().equals(p2shAddress)) + // Input isn't spending our P2SH + continue; + + Script scriptSig = input.getScriptSig(); + List scriptChunks = scriptSig.getChunks(); + + // Expected number of script chunks + int expectedChunkCount = 1 /* secret */ + 1 /* sig */ + 1 /* pubkey */ + 1 /* redeemScript */; + if (scriptChunks.size() != expectedChunkCount) + continue; + + byte[] secret = scriptChunks.get(0).data; + if (secret.length != BTCACCT.SECRET_LENGTH) + continue; + + return secret; + } + } + + return null; } } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index 961b9519..60d9f28d 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -11,12 +11,12 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainTradeData { - public static enum Mode { OFFER, TRADE }; + public enum Mode { OFFER, TRADE }; // Properties @Schema(description = "AT's Qortal address") - public String qortalAddress; + public String qortalAtAddress; @Schema(description = "AT creator's Qortal address") public String qortalCreator; @@ -39,6 +39,9 @@ public class CrossChainTradeData { @Schema(description = "Trade partner's Qortal address (trade begins when this is set)") public String qortalRecipient; + @Schema(description = "Timestamp when AT switched to trade mode") + public Long tradeModeTimestamp; + @Schema(description = "How long from beginning trade until AT triggers automatic refund to AT creator (minutes)") public long tradeRefundTimeout; @@ -50,6 +53,9 @@ public class CrossChainTradeData { public Mode mode; + @Schema(description = "Suggested Bitcoin P2SH nLockTime based on trade timeout") + public Integer lockTime; + // Constructors // Necessary for JAXB diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 00ab6107..20b2d8b7 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -13,13 +13,11 @@ import java.util.List; import java.util.function.Function; import org.bitcoinj.core.Base58; -import org.ciyam.at.MachineState; import org.junit.Before; import org.junit.Test; import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; -import org.qortal.at.QortalAtLoggerFactory; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -508,20 +506,7 @@ public class AtTests extends Common { private void describeAt(Repository repository, String atAddress) throws DataException { ATData atData = repository.getATRepository().fromATAddress(atAddress); - - ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); - byte[] stateData = atStateData.getStateData(); - - QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); - - CrossChainTradeData tradeData = new CrossChainTradeData(); - tradeData.qortalAddress = atAddress; - tradeData.qortalCreator = Crypto.toAddress(atData.getCreatorPublicKey()); - tradeData.creationTimestamp = atData.getCreation(); - tradeData.qortBalance = repository.getAccountRepository().getBalance(atAddress, Asset.QORT).getBalance(); - - BTCACCT.populateTradeData(tradeData, dataBytes); + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); @@ -536,7 +521,7 @@ public class AtTests extends Common { + "\texpected bitcoin: %s BTC,\n" + "\ttrade timeout: %d minutes (from trade start),\n" + "\tcurrent block height: %d,\n", - tradeData.qortalAddress, + tradeData.qortalAtAddress, tradeData.qortalCreator, epochMilliFormatter.apply(tradeData.creationTimestamp), tradeData.qortBalance.toPlainString(), @@ -555,8 +540,10 @@ public class AtTests extends Common { // Trade System.out.println(String.format("\tstatus: 'trade mode',\n" + "\ttrade timeout: block %d,\n" + + "\tBitcoin P2SH nLockTime: %d (%s),\n" + "\ttrade recipient: %s", tradeData.tradeRefundHeight, + tradeData.lockTime, epochMilliFormatter.apply(tradeData.lockTime * 1000L), tradeData.qortalRecipient)); } } diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java new file mode 100644 index 00000000..8a25b937 --- /dev/null +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -0,0 +1,65 @@ +package org.qortal.test.btcacct; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.wallet.WalletTransaction; +import org.junit.Before; +import org.junit.Test; +import org.qortal.crosschain.BTC; +import org.qortal.crosschain.BTCACCT; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +public class BtcTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testGetMedianBlockTime() throws BlockStoreException { + System.out.println(String.format("Starting BTC instance...")); + BTC btc = BTC.getInstance(); + System.out.println(String.format("BTC instance started")); + + long before = System.currentTimeMillis(); + System.out.println(String.format("Bitcoin median blocktime: %d", btc.getMedianBlockTime())); + long afterFirst = System.currentTimeMillis(); + + System.out.println(String.format("Bitcoin median blocktime: %d", btc.getMedianBlockTime())); + long afterSecond = System.currentTimeMillis(); + + long firstPeriod = afterFirst - before; + long secondPeriod = afterSecond - afterFirst; + + System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); + + assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + } + + @Test + public void testFindP2shSecret() { + // This actually exists on TEST3 + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + int startTime = 1587510000; + + List walletTransactions = new ArrayList<>(); + + BTC.getInstance().getBalanceAndOtherInfo(p2shAddress, startTime, null, walletTransactions); + + byte[] expectedSecret = AtTests.secret; + byte[] secret = BTCACCT.findP2shSecret(p2shAddress, walletTransactions); + + assertNotNull(secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + +} diff --git a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java index 7803e0e6..ec2ccb86 100644 --- a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java @@ -134,7 +134,7 @@ public class CheckP2SH { System.out.println(String.format("Too soon (%s) to redeem based on median block time %s", LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC))); // Check P2SH is funded - final long startTime = lockTime - 86400; + final int startTime = lockTime - 86400; Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString(), startTime); if (p2shBalance == null) { diff --git a/src/test/java/org/qortal/test/btcacct/Redeem.java b/src/test/java/org/qortal/test/btcacct/Redeem.java index 3249e070..4c5b9fb7 100644 --- a/src/test/java/org/qortal/test/btcacct/Redeem.java +++ b/src/test/java/org/qortal/test/btcacct/Redeem.java @@ -146,7 +146,7 @@ public class Redeem { } // Check P2SH is funded - final long startTime = ((int) (System.currentTimeMillis() / 1000L)) - 86400; + final int startTime = ((int) (System.currentTimeMillis() / 1000L)) - 86400; Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString(), startTime); if (p2shBalance == null) { diff --git a/src/test/java/org/qortal/test/btcacct/Refund.java b/src/test/java/org/qortal/test/btcacct/Refund.java index ffeb0080..3393f8bb 100644 --- a/src/test/java/org/qortal/test/btcacct/Refund.java +++ b/src/test/java/org/qortal/test/btcacct/Refund.java @@ -150,7 +150,7 @@ public class Refund { } // Check P2SH is funded - final long startTime = ((int) (System.currentTimeMillis() / 1000L)) - 86400; + final int startTime = ((int) (System.currentTimeMillis() / 1000L)) - 86400; Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString(), startTime); if (p2shBalance == null) { diff --git a/src/test/resources/test-settings-v2.json b/src/test/resources/test-settings-v2.json index 57eb22a5..1cefddee 100644 --- a/src/test/resources/test-settings-v2.json +++ b/src/test/resources/test-settings-v2.json @@ -1,4 +1,5 @@ { + "bitcoinNet": "TEST3", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2.json", "wipeUnconfirmedOnStart": false,