More work on Bitcoin-side of cross-chain trading.

Tidied up duplicated cross-chain API code that
fetched Qortal AT info.

Added Bitcoin-related cross-chain API calls
for building, checking, refunding and redeeming
P2SH.

Added new Bitcoin-related API error codes.

Controller now starts up, and shuts down, bitcoinj.

Speed-up in BTC class so bitcoinj doesn't have
to throw away all peers and rediscover & reconnect
to them with every chain-related call.
This commit is contained in:
catbref 2020-04-22 16:18:21 +01:00
parent 94d18538d8
commit 833a785996
19 changed files with 792 additions and 114 deletions

View File

@ -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<Integer, ApiError> map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError));

View File

@ -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() {
}
}

View File

@ -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() {
}
}

View File

@ -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() {
}
}

View File

@ -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() {
}
}

View File

@ -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() {

View File

@ -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")

View File

@ -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() {

View File

@ -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<CrossChainTradeData> getTradeOffers(
@Parameter( ref = "limit") @QueryParam("limit") Integer limit,
@Parameter( ref = "offset" ) @QueryParam("offset") Integer offset,
@ -104,21 +112,7 @@ public class CrossChainResource {
List<CrossChainTradeData> 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<TransactionOutput> fundingOutputs = new ArrayList<>();
List<WalletTransaction> 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<TransactionOutput> 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<TransactionOutput> 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)

View File

@ -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();

View File

@ -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<StoredBlock> 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<TransactionOutput> getOutputs(String base58Address, long startTime) {
public List<TransactionOutput> 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<TransactionOutput> getOutputs(byte[] txId, long startTime) {
public Coin getBalanceAndOtherInfo(String base58Address, int startTime, List<TransactionOutput> unspentOutputs, List<WalletTransaction> 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<TransactionOutput> 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;
}
}
}

View File

@ -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<WalletTransaction> 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<ScriptChunk> 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;
}
}

View File

@ -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

View File

@ -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<Long, String> 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));
}
}

View File

@ -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<WalletTransaction> 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));
}
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -1,4 +1,5 @@
{
"bitcoinNet": "TEST3",
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2.json",
"wipeUnconfirmedOnStart": false,