3
0
mirror of https://github.com/Qortal/qortal.git synced 2025-02-11 17:55:50 +00:00

Merge branch 'BTC-ACCT' into launch

This commit is contained in:
catbref 2020-05-01 10:09:54 +01:00
commit d03cca2e76
66 changed files with 6963 additions and 559 deletions

Binary file not shown.

View File

@ -3,7 +3,7 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>org.ciyam</groupId>
<artifactId>at</artifactId>
<version>1.0</version>
<artifactId>AT</artifactId>
<version>1.3.4</version>
<description>POM was created from install:install-file</description>
</project>

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>org.ciyam</groupId>
<artifactId>at</artifactId>
<artifactId>AT</artifactId>
<versioning>
<release>1.0</release>
<release>1.3.4</release>
<versions>
<version>1.0</version>
<version>1.3.4</version>
</versions>
<lastUpdated>20181105100741</lastUpdated>
<lastUpdated>20200414162728</lastUpdated>
</versioning>
</metadata>

Binary file not shown.

View File

@ -6,9 +6,10 @@
<version>1.0.7</version>
<packaging>jar</packaging>
<properties>
<bitcoin.version>0.15.4</bitcoin.version>
<bitcoinj.version>0.15.5</bitcoinj.version>
<bouncycastle.version>1.64</bouncycastle.version>
<build.timestamp>${maven.build.timestamp}</build.timestamp>
<ciyam-at.version>1.3.4</ciyam-at.version>
<commons-net.version>3.6</commons-net.version>
<commons-text.version>1.8</commons-text.version>
<dagger.version>1.2.2</dagger.version>
@ -405,14 +406,14 @@
<!-- CIYAM AT (automated transactions) -->
<dependency>
<groupId>org.ciyam</groupId>
<artifactId>at</artifactId>
<version>1.0</version>
<artifactId>AT</artifactId>
<version>${ciyam-at.version}</version>
</dependency>
<!-- Bitcoin support -->
<dependency>
<groupId>org.bitcoinj</groupId>
<artifactId>bitcoinj-core</artifactId>
<version>${bitcoin.version}</version>
<version>${bitcoinj.version}</version>
</dependency>
<!-- Utilities -->
<dependency>

View File

@ -118,7 +118,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

@ -0,0 +1,40 @@
package org.qortal.api.model;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainBuildRequest {
@Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
public byte[] creatorPublicKey;
@Schema(description = "Initial QORT amount paid when trade agreed", example = "0.00100000")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long initialQortAmount;
@Schema(description = "Final QORT amount paid out on successful trade", example = "80.40200000")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long finalQortAmount;
@Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "123.45670000")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long fundingQortAmount;
@Schema(description = "HASH160 of secret", example = "43vnftqkjxrhb5kJdkU1ZFQLEnWV")
public byte[] secretHash;
@Schema(description = "Bitcoin P2SH BTC balance for release of secret", example = "0.00864200")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long bitcoinAmount;
@Schema(description = "Trade time window (minutes) from trade agreement to automatic refund", example = "10080")
public Integer tradeTimeout;
public CrossChainBuildRequest() {
}
}

View File

@ -0,0 +1,20 @@
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 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

@ -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 CrossChainSecretRequest {
@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")
public byte[] secret;
public CrossChainSecretRequest() {
}
}

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

@ -13,7 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
@Tag(name = "Admin"),
@Tag(name = "Arbitrary"),
@Tag(name = "Assets"),
@Tag(name = "Automated Transactions"),
@Tag(name = "Blocks"),
@Tag(name = "Cross-Chain"),
@Tag(name = "Groups"),
@Tag(name = "Names"),
@Tag(name = "Payments"),

View File

@ -0,0 +1,206 @@
package org.qortal.api.resource;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import org.ciyam.at.MachineState;
import org.qortal.api.ApiError;
import org.qortal.api.ApiErrors;
import org.qortal.api.ApiException;
import org.qortal.api.ApiExceptionFactory;
import org.qortal.at.QortalAtLoggerFactory;
import org.qortal.data.at.ATData;
import org.qortal.data.at.ATStateData;
import org.qortal.data.transaction.DeployAtTransactionData;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.settings.Settings;
import org.qortal.transaction.Transaction;
import org.qortal.transaction.Transaction.ValidationResult;
import org.qortal.transform.TransformationException;
import org.qortal.transform.transaction.DeployAtTransactionTransformer;
import org.qortal.utils.Base58;
@Path("/at")
@Tag(name = "Automated Transactions")
public class AtResource {
@Context
HttpServletRequest request;
@GET
@Path("/byfunction/{codehash}")
@Operation(
summary = "Find automated transactions with matching functionality (code hash)",
responses = {
@ApiResponse(
description = "automated transactions",
content = @Content(
array = @ArraySchema(
schema = @Schema(
implementation = ATData.class
)
)
)
)
}
)
@ApiErrors({
ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE
})
public List<ATData> getByFunctionality(
@PathParam("codehash")
String codeHash58,
@Parameter(description = "whether to include ATs that can run, or not, or both (if omitted)")
@QueryParam("isExecutable")
Boolean isExecutable,
@Parameter( ref = "limit") @QueryParam("limit") Integer limit,
@Parameter( ref = "offset" ) @QueryParam("offset") Integer offset,
@Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) {
// Decode codeHash
byte[] codeHash;
try {
codeHash = Base58.decode(codeHash58);
} catch (NumberFormatException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e);
}
// codeHash must be present and have correct length
if (codeHash == null || codeHash.length != 32)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
// Impose a limit on 'limit'
if (limit != null && limit > 100)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
try (final Repository repository = RepositoryManager.getRepository()) {
return repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse);
} catch (ApiException e) {
throw e;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@GET
@Path("/{ataddress}")
@Operation(
summary = "Fetch info associated with AT address",
responses = {
@ApiResponse(
description = "automated transaction",
content = @Content(
schema = @Schema(implementation = ATData.class)
)
)
}
)
@ApiErrors({
ApiError.REPOSITORY_ISSUE
})
public ATData getByAddress(@PathParam("ataddress") String atAddress) {
try (final Repository repository = RepositoryManager.getRepository()) {
return repository.getATRepository().fromATAddress(atAddress);
} catch (ApiException e) {
throw e;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@GET
@Path("/{ataddress}/data")
@Operation(
summary = "Fetch data segment associated with AT address",
responses = {
@ApiResponse(
description = "automated transaction",
content = @Content(
schema = @Schema(implementation = byte[].class)
)
)
}
)
@ApiErrors({
ApiError.REPOSITORY_ISSUE
})
public byte[] getDataByAddress(@PathParam("ataddress") String atAddress) {
try (final Repository repository = RepositoryManager.getRepository()) {
ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress);
byte[] stateData = atStateData.getStateData();
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData);
return dataBytes;
} catch (ApiException e) {
throw e;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@POST
@Operation(
summary = "Build raw, unsigned, DEPLOY_AT transaction",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(
implementation = DeployAtTransactionData.class
)
)
),
responses = {
@ApiResponse(
description = "raw, unsigned, DEPLOY_AT transaction encoded in Base58",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string"
)
)
)
}
)
@ApiErrors({ApiError.NON_PRODUCTION, ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
public String createDeployAt(DeployAtTransactionData transactionData) {
if (Settings.getInstance().isApiRestricted())
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION);
try (final Repository repository = RepositoryManager.getRepository()) {
Transaction transaction = Transaction.fromData(repository, transactionData);
ValidationResult result = transaction.isValidUnconfirmed();
if (result != ValidationResult.OK)
throw TransactionsResource.createTransactionInvalidException(request, result);
byte[] bytes = DeployAtTransactionTransformer.toBytes(transactionData);
return Base58.encode(bytes);
} catch (TransformationException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
}

View File

@ -0,0 +1,819 @@
package org.qortal.api.resource;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
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;
import org.qortal.api.ApiException;
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.crosschain.BTC;
import org.qortal.crosschain.BTCACCT;
import org.qortal.crypto.Crypto;
import org.qortal.data.at.ATData;
import org.qortal.data.crosschain.CrossChainTradeData;
import org.qortal.data.transaction.BaseTransactionData;
import org.qortal.data.transaction.DeployAtTransactionData;
import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.group.Group;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.transaction.DeployAtTransaction;
import org.qortal.transaction.MessageTransaction;
import org.qortal.transaction.Transaction;
import org.qortal.transaction.Transaction.ValidationResult;
import org.qortal.transform.TransformationException;
import org.qortal.transform.Transformer;
import org.qortal.transform.transaction.DeployAtTransactionTransformer;
import org.qortal.transform.transaction.MessageTransactionTransformer;
import org.qortal.utils.Base58;
import org.qortal.utils.NTP;
import com.google.common.primitives.Bytes;
@Path("/crosschain")
@Tag(name = "Cross-Chain")
public class CrossChainResource {
@Context
HttpServletRequest request;
@GET
@Path("/tradeoffers")
@Operation(
summary = "Find cross-chain trade offers",
responses = {
@ApiResponse(
description = "automated transactions",
content = @Content(
array = @ArraySchema(
schema = @Schema(
implementation = CrossChainTradeData.class
)
)
)
)
}
)
@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,
@Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) {
// Impose a limit on 'limit'
if (limit != null && limit > 100)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
byte[] codeHash = BTCACCT.CODE_BYTES_HASH;
boolean isExecutable = true;
try (final Repository repository = RepositoryManager.getRepository()) {
List<ATData> atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse);
List<CrossChainTradeData> crossChainTradesData = new ArrayList<>();
for (ATData atData : atsData) {
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
crossChainTradesData.add(crossChainTradeData);
}
return crossChainTradesData;
} catch (ApiException e) {
throw e;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@POST
@Path("/build")
@Operation(
summary = "Build cross-chain trading AT",
description = "Returns raw, unsigned DEPLOY_AT transaction",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(
implementation = CrossChainBuildRequest.class
)
)
),
responses = {
@ApiResponse(
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
)
}
)
@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 != BTC.HASH160_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
if (tradeRequest.tradeTimeout == null)
tradeRequest.tradeTimeout = 7 * 24 * 60; // 7 days
else
if (tradeRequest.tradeTimeout < 10 || tradeRequest.tradeTimeout > 50000)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
if (tradeRequest.initialQortAmount < 0)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
if (tradeRequest.finalQortAmount <= 0)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
if (tradeRequest.fundingQortAmount <= 0)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
// funding amount must exceed initial + final
if (tradeRequest.fundingQortAmount <= tradeRequest.initialQortAmount + tradeRequest.finalQortAmount)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
if (tradeRequest.bitcoinAmount <= 0)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
try (final Repository repository = RepositoryManager.getRepository()) {
PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey);
byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.secretHash, tradeRequest.tradeTimeout, tradeRequest.initialQortAmount, tradeRequest.finalQortAmount, tradeRequest.bitcoinAmount);
long txTimestamp = NTP.getTime();
byte[] lastReference = creatorAccount.getLastReference();
if (lastReference == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE);
long fee = 0;
String name = "QORT-BTC cross-chain trade";
String description = "Qortal-Bitcoin cross-chain trade";
String atType = "ACCT";
String tags = "QORT-BTC ACCT";
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, creatorAccount.getPublicKey(), fee, null);
TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, tradeRequest.fundingQortAmount, Asset.QORT);
Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
fee = deployAtTransaction.calcRecommendedFee();
deployAtTransactionData.setFee(fee);
ValidationResult result = deployAtTransaction.isValidUnconfirmed();
if (result != ValidationResult.OK)
throw TransactionsResource.createTransactionInvalidException(request, result);
byte[] bytes = DeployAtTransactionTransformer.toBytes(deployAtTransactionData);
return Base58.encode(bytes);
} catch (TransformationException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@POST
@Path("/tradeoffer/recipient")
@Operation(
summary = "Builds raw, unsigned MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode",
description = "Specify address of cross-chain AT that needs to be messaged, and address of Qortal recipient.<br>"
+ "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
+ "You need to sign output with same account as the AT creator otherwise the MESSAGE transaction will be invalid.",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(
implementation = CrossChainTradeRequest.class
)
)
),
responses = {
@ApiResponse(
content = @Content(
schema = @Schema(
type = "string"
)
)
)
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
public String sendTradeRecipient(CrossChainTradeRequest 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.atAddress == null || !Crypto.isValidAtAddress(tradeRequest.atAddress))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
if (tradeRequest.recipient == null || !Crypto.isValidAddress(tradeRequest.recipient))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
try (final Repository repository = RepositoryManager.getRepository()) {
ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, tradeRequest.atAddress);
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
// Good to make MESSAGE
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(tradeRequest.recipient), 32, 0);
byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, tradeRequest.atAddress, recipientAddressBytes);
return Base58.encode(messageTransactionBytes);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@POST
@Path("/tradeoffer/secret")
@Operation(
summary = "Builds raw, unsigned MESSAGE transaction that sends secret to AT, releasing funds to recipient",
description = "Specify address of cross-chain AT that needs to be messaged, and 32-byte secret.<br>"
+ "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
+ "You need to sign output with account the AT considers the 'recipient' otherwise the MESSAGE transaction will be invalid.",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(
implementation = CrossChainSecretRequest.class
)
)
),
responses = {
@ApiResponse(
content = @Content(
schema = @Schema(
type = "string"
)
)
)
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
public String sendSecret(CrossChainSecretRequest secretRequest) {
byte[] recipientPublicKey = secretRequest.recipientPublicKey;
if (recipientPublicKey == null || recipientPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
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
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
if (crossChainTradeData.mode == CrossChainTradeData.Mode.OFFER)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
PublicKeyAccount recipientAccount = new PublicKeyAccount(repository, recipientPublicKey);
String recipientAddress = recipientAccount.getAddress();
// MESSAGE must come from address that AT considers trade partner / 'recipient'
if (!crossChainTradeData.qortalRecipient.equals(recipientAddress))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
// Good to make MESSAGE
byte[] messageTransactionBytes = buildAtMessage(repository, recipientPublicKey, secretRequest.atAddress, secretRequest.secret);
return Base58.encode(messageTransactionBytes);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@DELETE
@Path("/tradeoffer")
@Operation(
summary = "Builds raw, unsigned MESSAGE transaction that cancels cross-chain trade offer",
description = "Specify address of cross-chain AT that needs to be cancelled.<br>"
+ "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
+ "You need to sign output with same account as the AT creator otherwise the MESSAGE transaction will be invalid.",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(
implementation = CrossChainCancelRequest.class
)
)
),
responses = {
@ApiResponse(
content = @Content(
schema = @Schema(
type = "string"
)
)
)
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) {
byte[] creatorPublicKey = cancelRequest.creatorPublicKey;
if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
if (cancelRequest.atAddress == null || !Crypto.isValidAtAddress(cancelRequest.atAddress))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
try (final Repository repository = RepositoryManager.getRepository()) {
ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, cancelRequest.atAddress);
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
// Good to make MESSAGE
PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey);
String creatorAddress = creatorAccount.getAddress();
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(creatorAddress), 32, 0);
byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, recipientAddressBytes);
return Base58.encode(messageTransactionBytes);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@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);
if (p2shBalance.value >= crossChainTradeData.expectedBitcoin && 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);
if (p2shBalance.value < crossChainTradeData.expectedBitcoin)
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);
if (p2shBalance.value < crossChainTradeData.expectedBitcoin)
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)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
// Does supplied public key match that of AT?
if (creatorPublicKey != null && !Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey()))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
// Must be correct AT - check functionality using code hash
if (!Arrays.equals(atData.getCodeHash(), BTCACCT.CODE_BYTES_HASH))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
// No point sending message to AT that's finished
if (atData.getIsFinished())
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
return atData;
}
private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException {
PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, senderPublicKey);
long txTimestamp = NTP.getTime();
byte[] lastReference = creatorAccount.getLastReference();
if (lastReference == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE);
Long fee = null;
long amount = 0L;
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, senderPublicKey, fee, null);
TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, 4, atAddress, Asset.QORT, amount, messageData, false, false);
MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData);
fee = messageTransaction.calcRecommendedFee();
messageTransactionData.setFee(fee);
ValidationResult result = messageTransaction.isValidUnconfirmed();
if (result != ValidationResult.OK)
throw TransactionsResource.createTransactionInvalidException(request, result);
try {
return MessageTransactionTransformer.toBytes(messageTransactionData);
} catch (TransformationException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
}
}
}

View File

@ -3,6 +3,7 @@ package org.qortal.at;
import java.util.List;
import org.ciyam.at.MachineState;
import org.ciyam.at.Timestamp;
import org.qortal.crypto.Crypto;
import org.qortal.data.at.ATData;
import org.qortal.data.at.ATStateData;
@ -41,16 +42,25 @@ public class AT {
long creation = deployATTransactionData.getTimestamp();
long assetId = deployATTransactionData.getAssetId();
MachineState machineState = new MachineState(deployATTransactionData.getCreationBytes());
// Just enough AT data to allow API to query initial balances, etc.
ATData skeletonAtData = new ATData(atAddress, creatorPublicKey, creation, assetId);
this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, machineState.getCodeBytes(),
machineState.getIsSleeping(), machineState.getSleepUntilHeight(), machineState.getIsFinished(), machineState.getHadFatalError(),
machineState.getIsFrozen(), machineState.getFrozenBalance());
long blockTimestamp = Timestamp.toLong(height, 0);
QortalATAPI api = new QortalATAPI(repository, skeletonAtData, blockTimestamp);
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
MachineState machineState = new MachineState(api, loggerFactory, deployATTransactionData.getCreationBytes());
byte[] codeHash = Crypto.digest(machineState.getCodeBytes());
this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, machineState.getCodeBytes(), codeHash,
machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(),
machineState.isFrozen(), machineState.getFrozenBalance());
byte[] stateData = machineState.toBytes();
byte[] stateHash = Crypto.digest(stateData);
this.atStateData = new ATStateData(atAddress, height, creation, stateData, stateHash, 0L);
this.atStateData = new ATStateData(atAddress, height, creation, stateData, stateHash, 0L, true);
}
// Getters / setters
@ -73,34 +83,85 @@ public class AT {
this.repository.getATRepository().delete(this.atData.getATAddress());
}
public List<AtTransaction> run(long blockTimestamp) throws DataException {
public List<AtTransaction> run(int blockHeight, long blockTimestamp) throws DataException {
String atAddress = this.atData.getATAddress();
QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp);
QortalATLogger logger = new QortalATLogger();
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
byte[] codeBytes = this.atData.getCodeBytes();
// Fetch latest ATStateData for this AT (if any)
// Fetch latest ATStateData for this AT
ATStateData latestAtStateData = this.repository.getATRepository().getLatestATState(atAddress);
// There should be at least initial AT state data
// There should be at least initial deployment AT state data
if (latestAtStateData == null)
throw new IllegalStateException("No initial AT state data found");
throw new IllegalStateException("No previous AT state data found");
// [Re]create AT machine state using AT state data or from scratch as applicable
MachineState state = MachineState.fromBytes(api, logger, latestAtStateData.getStateData(), codeBytes);
MachineState state = MachineState.fromBytes(api, loggerFactory, latestAtStateData.getStateData(), codeBytes);
state.execute();
int height = this.repository.getBlockRepository().getBlockchainHeight() + 1;
long creation = this.atData.getCreation();
byte[] stateData = state.toBytes();
byte[] stateHash = Crypto.digest(stateData);
long atFees = api.calcFinalFees(state);
this.atStateData = new ATStateData(atAddress, height, creation, stateData, stateHash, atFees);
this.atStateData = new ATStateData(atAddress, blockHeight, creation, stateData, stateHash, atFees, false);
return api.getTransactions();
}
public void update(int blockHeight, long blockTimestamp) throws DataException {
// [Re]create AT machine state using AT state data or from scratch as applicable
QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp);
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
byte[] codeBytes = this.atData.getCodeBytes();
MachineState state = MachineState.fromBytes(api, loggerFactory, this.atStateData.getStateData(), codeBytes);
// Save latest AT state data
this.repository.getATRepository().save(this.atStateData);
// Update AT info in repository too
this.atData.setIsSleeping(state.isSleeping());
this.atData.setSleepUntilHeight(state.getSleepUntilHeight());
this.atData.setIsFinished(state.isFinished());
this.atData.setHadFatalError(state.hadFatalError());
this.atData.setIsFrozen(state.isFrozen());
this.atData.setFrozenBalance(state.getFrozenBalance());
this.repository.getATRepository().save(this.atData);
}
public void revert(int blockHeight, long blockTimestamp) throws DataException {
String atAddress = this.atData.getATAddress();
// Delete old AT state data from repository
this.repository.getATRepository().delete(atAddress, blockHeight);
if (this.atStateData.isInitial())
return;
// Load previous state data
ATStateData previousStateData = this.repository.getATRepository().getLatestATState(atAddress);
if (previousStateData == null)
throw new DataException("Can't find previous AT state data for " + atAddress);
// [Re]create AT machine state using AT state data or from scratch as applicable
QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp);
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
byte[] codeBytes = this.atData.getCodeBytes();
MachineState state = MachineState.fromBytes(api, loggerFactory, previousStateData.getStateData(), codeBytes);
// Update AT info in repository
this.atData.setIsSleeping(state.isSleeping());
this.atData.setSleepUntilHeight(state.getSleepUntilHeight());
this.atData.setIsFinished(state.isFinished());
this.atData.setHadFatalError(state.hadFatalError());
this.atData.setIsFrozen(state.isFrozen());
this.atData.setFrozenBalance(state.getFrozenBalance());
this.repository.getATRepository().save(this.atData);
}
}

View File

@ -1,133 +0,0 @@
package org.qortal.at;
import static java.util.Arrays.stream;
import static java.util.stream.Collectors.toMap;
import java.util.List;
import java.util.Map;
import org.ciyam.at.MachineState;
import org.ciyam.at.Timestamp;
import org.qortal.account.Account;
import org.qortal.block.Block;
import org.qortal.data.block.BlockData;
import org.qortal.data.transaction.ATTransactionData;
import org.qortal.data.transaction.PaymentTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.repository.BlockRepository;
import org.qortal.repository.DataException;
import org.qortal.transaction.Transaction;
public enum BlockchainAPI {
QORTAL(0) {
@Override
public void putTransactionFromRecipientAfterTimestampInA(String recipient, Timestamp timestamp, MachineState state) {
int height = timestamp.blockHeight;
int sequence = timestamp.transactionSequence + 1;
QortalATAPI api = (QortalATAPI) state.getAPI();
BlockRepository blockRepository = api.repository.getBlockRepository();
try {
Account recipientAccount = new Account(api.repository, recipient);
while (height <= blockRepository.getBlockchainHeight()) {
BlockData blockData = blockRepository.fromHeight(height);
if (blockData == null)
throw new DataException("Unable to fetch block " + height + " from repository?");
Block block = new Block(api.repository, blockData);
List<Transaction> transactions = block.getTransactions();
// No more transactions in this block? Try next block
if (sequence >= transactions.size()) {
++height;
sequence = 0;
continue;
}
Transaction transaction = transactions.get(sequence);
// Transaction needs to be sent to specified recipient
if (transaction.getRecipientAccounts().contains(recipientAccount)) {
// Found a transaction
api.setA1(state, new Timestamp(height, timestamp.blockchainId, sequence).longValue());
// Hash transaction's signature into other three A fields for future verification that it's the same transaction
byte[] hash = QortalATAPI.sha192(transaction.getTransactionData().getSignature());
api.setA2(state, QortalATAPI.fromBytes(hash, 0));
api.setA3(state, QortalATAPI.fromBytes(hash, 8));
api.setA4(state, QortalATAPI.fromBytes(hash, 16));
return;
}
// Transaction wasn't for us - keep going
++sequence;
}
// No more transactions - zero A and exit
api.zeroA(state);
} catch (DataException e) {
throw new RuntimeException("AT API unable to fetch next transaction?", e);
}
}
@Override
public long getAmountFromTransactionInA(Timestamp timestamp, MachineState state) {
QortalATAPI api = (QortalATAPI) state.getAPI();
TransactionData transactionData = api.fetchTransaction(state);
switch (transactionData.getType()) {
case PAYMENT:
return ((PaymentTransactionData) transactionData).getAmount();
case AT:
Long amount = ((ATTransactionData) transactionData).getAmount();
if (amount == null)
return 0xffffffffffffffffL;
return amount;
default:
return 0xffffffffffffffffL;
}
}
},
BTC(1) {
@Override
public void putTransactionFromRecipientAfterTimestampInA(String recipient, Timestamp timestamp, MachineState state) {
// TODO BTC transaction support for ATv2
}
@Override
public long getAmountFromTransactionInA(Timestamp timestamp, MachineState state) {
// TODO BTC transaction support for ATv2
return 0;
}
};
public final int value;
private static final Map<Integer, BlockchainAPI> map = stream(BlockchainAPI.values()).collect(toMap(type -> type.value, type -> type));
BlockchainAPI(int value) {
this.value = value;
}
public static BlockchainAPI valueOf(int value) {
return map.get(value);
}
// Blockchain-specific API methods
public abstract void putTransactionFromRecipientAfterTimestampInA(String recipient, Timestamp timestamp, MachineState state);
public abstract long getAmountFromTransactionInA(Timestamp timestamp, MachineState state);
}

View File

@ -1,10 +1,12 @@
package org.qortal.at;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ciyam.at.API;
import org.ciyam.at.ExecutionException;
import org.ciyam.at.FunctionData;
@ -16,33 +18,39 @@ import org.qortal.account.Account;
import org.qortal.account.NullAccount;
import org.qortal.account.PublicKeyAccount;
import org.qortal.asset.Asset;
import org.qortal.block.Block;
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.block.BlockData;
import org.qortal.data.block.BlockSummaryData;
import org.qortal.data.transaction.ATTransactionData;
import org.qortal.data.transaction.BaseTransactionData;
import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.PaymentTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.group.Group;
import org.qortal.repository.BlockRepository;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.transaction.AtTransaction;
import org.qortal.utils.Amounts;
import org.qortal.transaction.Transaction;
import org.qortal.transaction.Transaction.TransactionType;
import org.qortal.utils.Base58;
import com.google.common.primitives.Bytes;
public class QortalATAPI extends API {
// Useful constants
private static final long FEE_PER_STEP = 1 * Amounts.MULTIPLIER; // 1 QORT per "step"
private static final int MAX_STEPS_PER_ROUND = 500;
private static final int STEPS_PER_FUNCTION_CALL = 10;
private static final int MINUTES_PER_BLOCK = 10;
private static final byte[] ADDRESS_PADDING = new byte[32 - Account.ADDRESS_LENGTH];
private static final Logger LOGGER = LogManager.getLogger(QortalATAPI.class);
// Properties
Repository repository;
ATData atData;
long blockTimestamp;
private Repository repository;
private ATData atData;
private long blockTimestamp;
private final CiyamAtSettings ciyamAtSettings;
/** List of generated AT transactions */
List<AtTransaction> transactions;
@ -54,36 +62,42 @@ public class QortalATAPI extends API {
this.atData = atData;
this.transactions = new ArrayList<>();
this.blockTimestamp = blockTimestamp;
this.ciyamAtSettings = BlockChain.getInstance().getCiyamAtSettings();
}
// Methods specific to Qortal AT processing, not inherited
public Repository getRepository() {
return this.repository;
}
public List<AtTransaction> getTransactions() {
return this.transactions;
}
public long calcFinalFees(MachineState state) {
return state.getSteps() * FEE_PER_STEP;
return state.getSteps() * this.ciyamAtSettings.feePerStep;
}
// Inherited methods from CIYAM AT API
@Override
public int getMaxStepsPerRound() {
return MAX_STEPS_PER_ROUND;
return this.ciyamAtSettings.maxStepsPerRound;
}
@Override
public int getOpCodeSteps(OpCode opcode) {
if (opcode.value >= OpCode.EXT_FUN.value && opcode.value <= OpCode.EXT_FUN_RET_DAT_2.value)
return STEPS_PER_FUNCTION_CALL;
return this.ciyamAtSettings.stepsPerFunctionCall;
return 1;
}
@Override
public long getFeePerStep() {
return FEE_PER_STEP;
return this.ciyamAtSettings.feePerStep;
}
@Override
@ -105,31 +119,96 @@ public class QortalATAPI extends API {
}
@Override
public void putPreviousBlockHashInA(MachineState state) {
public void putPreviousBlockHashIntoA(MachineState state) {
try {
BlockData blockData = this.repository.getBlockRepository().fromHeight(this.getPreviousBlockHeight());
int previousBlockHeight = this.repository.getBlockRepository().getBlockchainHeight() - 1;
// We only need signature, so only request a block summary
List<BlockSummaryData> blockSummaries = this.repository.getBlockRepository().getBlockSummaries(previousBlockHeight, previousBlockHeight);
if (blockSummaries == null || blockSummaries.size() != 1)
throw new RuntimeException("AT API unable to fetch previous block hash?");
// Block's signature is 128 bytes so we need to reduce this to 4 longs (32 bytes)
byte[] blockHash = Crypto.digest(blockData.getSignature());
// To be able to use hash to look up block, save height (8 bytes) and partial signature (24 bytes)
this.setA1(state, previousBlockHeight);
this.setA(state, blockHash);
byte[] signature = blockSummaries.get(0).getSignature();
// Save some of minter's signature and transactions signature, so middle 24 bytes of the full 128 byte signature.
this.setA2(state, fromBytes(signature, 52));
this.setA3(state, fromBytes(signature, 60));
this.setA4(state, fromBytes(signature, 68));
} catch (DataException e) {
throw new RuntimeException("AT API unable to fetch previous block?", e);
}
}
@Override
public void putTransactionAfterTimestampInA(Timestamp timestamp, MachineState state) {
public void putTransactionAfterTimestampIntoA(Timestamp timestamp, MachineState state) {
// Recipient is this AT
String recipient = this.atData.getATAddress();
String atAddress = this.atData.getATAddress();
BlockchainAPI blockchainAPI = BlockchainAPI.valueOf(timestamp.blockchainId);
blockchainAPI.putTransactionFromRecipientAfterTimestampInA(recipient, timestamp, state);
int height = timestamp.blockHeight;
int sequence = timestamp.transactionSequence + 1;
BlockRepository blockRepository = this.getRepository().getBlockRepository();
try {
int currentHeight = blockRepository.getBlockchainHeight();
List<Transaction> blockTransactions = null;
while (height <= currentHeight) {
if (blockTransactions == null) {
BlockData blockData = blockRepository.fromHeight(height);
if (blockData == null)
throw new DataException("Unable to fetch block " + height + " from repository?");
Block block = new Block(this.getRepository(), blockData);
blockTransactions = block.getTransactions();
}
// No more transactions in this block? Try next block
if (sequence >= blockTransactions.size()) {
++height;
sequence = 0;
blockTransactions = null;
continue;
}
Transaction transaction = blockTransactions.get(sequence);
// Transaction needs to be sent to specified recipient
List<Account> recipientAccounts = transaction.getRecipientAccounts();
List<String> recipientAddresses = recipientAccounts.stream().map(Account::getAddress).collect(Collectors.toList());
if (recipientAddresses.contains(atAddress)) {
// Found a transaction
this.setA1(state, new Timestamp(height, timestamp.blockchainId, sequence).longValue());
// Copy transaction's partial signature into the other three A fields for future verification that it's the same transaction
byte[] signature = transaction.getTransactionData().getSignature();
this.setA2(state, fromBytes(signature, 8));
this.setA3(state, fromBytes(signature, 16));
this.setA4(state, fromBytes(signature, 24));
return;
}
// Transaction wasn't for us - keep going
++sequence;
}
// No more transactions - zero A and exit
this.zeroA(state);
} catch (DataException e) {
throw new RuntimeException("AT API unable to fetch next transaction?", e);
}
}
@Override
public long getTypeFromTransactionInA(MachineState state) {
TransactionData transactionData = this.fetchTransaction(state);
TransactionData transactionData = this.getTransactionFromA(state);
switch (transactionData.getType()) {
case PAYMENT:
@ -151,22 +230,36 @@ public class QortalATAPI extends API {
@Override
public long getAmountFromTransactionInA(MachineState state) {
Timestamp timestamp = new Timestamp(state.getA1());
BlockchainAPI blockchainAPI = BlockchainAPI.valueOf(timestamp.blockchainId);
return blockchainAPI.getAmountFromTransactionInA(timestamp, state);
TransactionData transactionData = this.getTransactionFromA(state);
switch (transactionData.getType()) {
case PAYMENT:
return ((PaymentTransactionData) transactionData).getAmount();
case AT:
Long amount = ((ATTransactionData) transactionData).getAmount();
if (amount != null)
return amount;
// fall-through to default
default:
return 0xffffffffffffffffL;
}
}
@Override
public long getTimestampFromTransactionInA(MachineState state) {
// Transaction's "timestamp" already stored in A1
Timestamp timestamp = new Timestamp(state.getA1());
Timestamp timestamp = new Timestamp(this.getA1(state));
return timestamp.longValue();
}
@Override
public long generateRandomUsingTransactionInA(MachineState state) {
// The plan here is to sleep for a block then use next block's signature and this transaction's signature to generate pseudo-random, but deterministic,
// value.
// The plan here is to sleep for a block then use next block's signature
// and this transaction's signature to generate pseudo-random, but deterministic, value.
if (!isFirstOpCodeAfterSleeping(state)) {
// First call
@ -179,7 +272,7 @@ public class QortalATAPI extends API {
// Second call
// HASH(A and new block hash)
TransactionData transactionData = this.fetchTransaction(state);
TransactionData transactionData = this.getTransactionFromA(state);
try {
BlockData blockData = this.repository.getBlockRepository().getLastBlock();
@ -203,7 +296,7 @@ public class QortalATAPI extends API {
// Zero B in case of issues or shorter-than-B message
this.zeroB(state);
TransactionData transactionData = this.fetchTransaction(state);
TransactionData transactionData = this.getTransactionFromA(state);
byte[] messageData = null;
@ -233,20 +326,36 @@ public class QortalATAPI extends API {
@Override
public void putAddressFromTransactionInAIntoB(MachineState state) {
TransactionData transactionData = this.fetchTransaction(state);
TransactionData transactionData = this.getTransactionFromA(state);
// We actually use public key as it has more potential utility (e.g. message verification) than an address
byte[] bytes = transactionData.getCreatorPublicKey();
String address;
if (transactionData.getType() == TransactionType.AT) {
// Use AT address from transaction data, as transaction's public key will always be fake
address = ((ATTransactionData) transactionData).getATAddress();
} else {
byte[] publicKey = transactionData.getCreatorPublicKey();
address = Crypto.toAddress(publicKey);
}
this.setB(state, bytes);
// Convert to byte form as this only takes 25 bytes,
// compared to string-form's 34 bytes,
// and we only have 32 bytes available.
byte[] addressBytes = Bytes.ensureCapacity(Base58.decode(address), 32, 0); // pad to 32 bytes
this.setB(state, addressBytes);
}
@Override
public void putCreatorAddressIntoB(MachineState state) {
// We actually use public key as it has more potential utility (e.g. message verification) than an address
byte[] bytes = atData.getCreatorPublicKey();
byte[] publicKey = atData.getCreatorPublicKey();
String address = Crypto.toAddress(publicKey);
this.setB(state, bytes);
// Convert to byte form as this only takes 25 bytes,
// compared to string-form's 34 bytes,
// and we only have 32 bytes available.
byte[] addressBytes = Bytes.ensureCapacity(Base58.decode(address), 32, 0); // pad to 32 bytes
this.setB(state, addressBytes);
}
@Override
@ -262,9 +371,7 @@ public class QortalATAPI extends API {
@Override
public void payAmountToB(long amount, MachineState state) {
byte[] publicKey = state.getB();
PublicKeyAccount recipient = new PublicKeyAccount(this.repository, publicKey);
Account recipient = getAccountFromB(state);
long timestamp = this.getNextTransactionTimestamp();
byte[] reference = this.getLastReference();
@ -280,10 +387,8 @@ public class QortalATAPI extends API {
@Override
public void messageAToB(MachineState state) {
byte[] message = state.getA();
byte[] publicKey = state.getB();
PublicKeyAccount recipient = new PublicKeyAccount(this.repository, publicKey);
byte[] message = this.getA(state);
Account recipient = getAccountFromB(state);
long timestamp = this.getNextTransactionTimestamp();
byte[] reference = this.getLastReference();
@ -302,13 +407,16 @@ public class QortalATAPI extends API {
int blockHeight = timestamp.blockHeight;
// At least one block in the future
blockHeight += (minutes / MINUTES_PER_BLOCK) + 1;
blockHeight += Math.max(minutes / this.ciyamAtSettings.minutesPerBlock, 1);
return new Timestamp(blockHeight, 0).longValue();
}
@Override
public void onFinished(long finalBalance, MachineState state) {
if (finalBalance <= 0)
return;
// Refund remaining balance (if any) to AT's creator
Account creator = this.getCreator();
long timestamp = this.getNextTransactionTimestamp();
@ -325,7 +433,7 @@ public class QortalATAPI extends API {
@Override
public void onFatalError(MachineState state, ExecutionException e) {
state.getLogger().error("AT " + this.atData.getATAddress() + " suffered fatal error: " + e.getMessage());
LOGGER.error("AT " + this.atData.getATAddress() + " suffered fatal error: " + e.getMessage());
}
@Override
@ -336,7 +444,7 @@ public class QortalATAPI extends API {
if (qortalFunctionCode == null)
throw new IllegalFunctionCodeException("Unknown Qortal function code 0x" + String.format("%04x", rawFunctionCode) + " encountered");
qortalFunctionCode.preExecuteCheck(2, true, state, rawFunctionCode);
qortalFunctionCode.preExecuteCheck(paramCount, returnValueExpected, rawFunctionCode);
}
@Override
@ -354,29 +462,23 @@ public class QortalATAPI extends API {
| (bytes[start + 4] & 0xffL) << 32 | (bytes[start + 5] & 0xffL) << 40 | (bytes[start + 6] & 0xffL) << 48 | (bytes[start + 7] & 0xffL) << 56;
}
/** Returns SHA2-192 digest of input - used to verify transaction signatures */
public static byte[] sha192(byte[] input) {
try {
// SHA2-192
MessageDigest sha192 = MessageDigest.getInstance("SHA-192");
return sha192.digest(input);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-192 not available");
}
/** Returns partial transaction signature, used to verify we're operating on the same transaction and not naively using block height & sequence. */
public static byte[] partialSignature(byte[] fullSignature) {
return Arrays.copyOfRange(fullSignature, 8, 32);
}
/** Verify transaction's SHA2-192 hashed signature matches A2 thru A4 */
private static void verifyTransaction(TransactionData transactionData, MachineState state) {
// Compare SHA2-192 of transaction's signature against A2 thru A4
byte[] hash = sha192(transactionData.getSignature());
/** Verify transaction's partial signature matches A2 thru A4 */
private void verifyTransaction(TransactionData transactionData, MachineState state) {
// Compare end of transaction's signature against A2 thru A4
byte[] sig = transactionData.getSignature();
if (state.getA2() != fromBytes(hash, 0) || state.getA3() != fromBytes(hash, 8) || state.getA4() != fromBytes(hash, 16))
if (this.getA2(state) != fromBytes(sig, 8) || this.getA3(state) != fromBytes(sig, 16) || this.getA4(state) != fromBytes(sig, 24))
throw new IllegalStateException("Transaction signature in A no longer matches signature from repository");
}
/** Returns transaction data from repository using block height & sequence from A1, checking the transaction signatures match too */
/* package */ TransactionData fetchTransaction(MachineState state) {
Timestamp timestamp = new Timestamp(state.getA1());
/* package */ TransactionData getTransactionFromA(MachineState state) {
Timestamp timestamp = new Timestamp(this.getA1(state));
try {
TransactionData transactionData = this.repository.getTransactionRepository().fromHeightAndSequence(timestamp.blockHeight,
@ -407,19 +509,11 @@ public class QortalATAPI extends API {
/** Returns the timestamp to use for next AT Transaction */
private long getNextTransactionTimestamp() {
/*
* Timestamp is block's timestamp + position in AT-Transactions list.
* Use block's timestamp.
*
* We need increasing timestamps to preserve transaction order and hence a correct signature-reference chain when the block is processed.
*
* As Qora blocks must share the same milliseconds component in their timestamps, this allows us to generate up to 1,000 AT-Transactions per AT without
* issue.
*
* As long as ATs are not allowed to generate that many per block, e.g. by limiting maximum steps per execution round, then we should be fine.
* This is OK because AT transactions are always generated locally and order is preserved in Transaction.getDataComparator().
*/
// XXX THE ABOVE IS NO LONGER TRUE IN QORTAL!
// return this.blockTimestamp + this.transactions.size();
throw new RuntimeException("AT timestamp code not fixed!");
return this.blockTimestamp;
}
/** Returns AT account's lastReference, taking newly generated ATTransactions into account */
@ -438,4 +532,38 @@ public class QortalATAPI extends API {
}
}
/**
* Returns Account (possibly PublicKeyAccount) based on value in B.
* <p>
* If first byte in B starts with either address version bytes,<br>
* and bytes 26 to 32 are zero, then use as an address, but only if valid.
* <p>
* Otherwise, assume B is a public key.
*/
private Account getAccountFromB(MachineState state) {
byte[] bBytes = this.getB(state);
if ((bBytes[0] == Crypto.ADDRESS_VERSION || bBytes[0] == Crypto.AT_ADDRESS_VERSION)
&& Arrays.mismatch(bBytes, Account.ADDRESS_LENGTH, 32, ADDRESS_PADDING, 0, ADDRESS_PADDING.length) == -1) {
// Extract only the bytes containing address
byte[] addressBytes = Arrays.copyOf(bBytes, Account.ADDRESS_LENGTH);
// If address (in byte form) is valid...
if (Crypto.isValidAddress(addressBytes))
// ...then return an Account using address (converted to Base58
return new Account(this.repository, Base58.encode(addressBytes));
}
return new PublicKeyAccount(this.repository, bBytes);
}
/* Convenience methods to allow QortalFunctionCode package-visibility access to A/B-get/set methods. */
protected byte[] getB(MachineState state) {
return super.getB(state);
}
protected void setB(MachineState state, byte[] bBytes) {
super.setB(state, bBytes);
}
}

View File

@ -1,26 +0,0 @@
package org.qortal.at;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class QortalATLogger implements org.ciyam.at.LoggerInterface {
// NOTE: We're logging on behalf of org.qortal.at.AT, not ourselves!
private static final Logger LOGGER = LogManager.getLogger(AT.class);
@Override
public void error(String message) {
LOGGER.error(message);
}
@Override
public void debug(String message) {
LOGGER.debug(message);
}
@Override
public void echo(String message) {
LOGGER.info(message);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
package org.qortal.at;
import org.ciyam.at.AtLogger;
public class QortalAtLoggerFactory implements org.ciyam.at.AtLoggerFactory {
private static QortalAtLoggerFactory instance;
private QortalAtLoggerFactory() {
}
public static synchronized QortalAtLoggerFactory getInstance() {
if (instance == null)
instance = new QortalAtLoggerFactory();
return instance;
}
@Override
public AtLogger create(final Class<?> loggerName) {
return QortalAtLogger.create(loggerName);
}
}

View File

@ -1,15 +1,18 @@
package org.qortal.at;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ciyam.at.ExecutionException;
import org.ciyam.at.FunctionData;
import org.ciyam.at.IllegalFunctionCodeException;
import org.ciyam.at.MachineState;
import org.ciyam.at.Timestamp;
import org.qortal.crosschain.BTC;
import org.qortal.crypto.Crypto;
import org.qortal.settings.Settings;
/**
* Qortal-specific CIYAM-AT Functions.
@ -19,28 +22,43 @@ import org.ciyam.at.Timestamp;
*/
public enum QortalFunctionCode {
/**
* <tt>0x0500</tt><br>
* Returns current BTC block's "timestamp"
* <tt>0x0510</tt><br>
* Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.
*/
GET_BTC_BLOCK_TIMESTAMP(0x0500, 0, true) {
CONVERT_B_TO_PKH(0x0510, 0, false) {
@Override
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
functionData.returnValue = Timestamp.toLong(state.getAPI().getCurrentBlockHeight(), BlockchainAPI.BTC.value, 0);
// Needs to be 'B' sized
byte[] pkh = new byte[32];
// Copy PKH part of B to last 20 bytes
System.arraycopy(getB(state), 32 - 20 - 4, pkh, 32 - 20, 20);
setB(state, pkh);
}
},
/**
* <tt>0x0501</tt><br>
* Put transaction from specific recipient after timestamp in A, or zero if none<br>
* <tt>0x0511</tt><br>
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to P2SH.<br>
* P2SH stored in lower 25 bytes of B.
*/
PUT_TX_FROM_B_RECIPIENT_AFTER_TIMESTAMP_IN_A(0x0501, 1, false) {
CONVERT_B_TO_P2SH(0x0511, 0, false) {
@Override
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
Timestamp timestamp = new Timestamp(functionData.value2);
byte addressPrefix = Settings.getInstance().getBitcoinNet() == BTC.BitcoinNet.MAIN ? 0x05 : (byte) 0xc4;
String recipient = new String(state.getB(), StandardCharsets.UTF_8);
BlockchainAPI blockchainAPI = BlockchainAPI.valueOf(timestamp.blockchainId);
blockchainAPI.putTransactionFromRecipientAfterTimestampInA(recipient, timestamp, state);
convertAddressInB(addressPrefix, state);
}
},
/**
* <tt>0x0512</tt><br>
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to Qortal address.<br>
* Qortal address stored in lower 25 bytes of B.
*/
CONVERT_B_TO_QORTAL(0x0512, 0, false) {
@Override
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
convertAddressInB(Crypto.ADDRESS_VERSION, state);
}
};
@ -48,7 +66,9 @@ public enum QortalFunctionCode {
public final int paramCount;
public final boolean returnsValue;
private final static Map<Short, QortalFunctionCode> map = Arrays.stream(QortalFunctionCode.values())
private static final Logger LOGGER = LogManager.getLogger(QortalFunctionCode.class);
private static final Map<Short, QortalFunctionCode> map = Arrays.stream(QortalFunctionCode.values())
.collect(Collectors.toMap(functionCode -> functionCode.value, functionCode -> functionCode));
private QortalFunctionCode(int value, int paramCount, boolean returnsValue) {
@ -61,7 +81,7 @@ public enum QortalFunctionCode {
return map.get((short) value);
}
public void preExecuteCheck(int paramCount, boolean returnValueExpected, MachineState state, short rawFunctionCode) throws IllegalFunctionCodeException {
public void preExecuteCheck(int paramCount, boolean returnValueExpected, short rawFunctionCode) throws IllegalFunctionCodeException {
if (paramCount != this.paramCount)
throw new IllegalFunctionCodeException(
"Passed paramCount (" + paramCount + ") does not match function's required paramCount (" + this.paramCount + ")");
@ -84,7 +104,7 @@ public enum QortalFunctionCode {
*/
public void execute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
// Check passed functionData against requirements of this function
preExecuteCheck(functionData.paramCount, functionData.returnValueExpected, state, rawFunctionCode);
preExecuteCheck(functionData.paramCount, functionData.returnValueExpected, rawFunctionCode);
if (functionData.paramCount >= 1 && functionData.value1 == null)
throw new IllegalFunctionCodeException("Passed value1 is null but function has paramCount of (" + this.paramCount + ")");
@ -92,7 +112,7 @@ public enum QortalFunctionCode {
if (functionData.paramCount == 2 && functionData.value2 == null)
throw new IllegalFunctionCodeException("Passed value2 is null but function has paramCount of (" + this.paramCount + ")");
state.getLogger().debug("Function \"" + this.name() + "\"");
LOGGER.debug(() -> String.format("Function \"%s\"", this.name()));
postCheckExecute(functionData, state, rawFunctionCode);
}
@ -100,4 +120,29 @@ public enum QortalFunctionCode {
/** Actually execute function */
protected abstract void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException;
private static void convertAddressInB(byte addressPrefix, MachineState state) {
byte[] addressNoChecksum = new byte[1 + 20];
addressNoChecksum[0] = addressPrefix;
System.arraycopy(getB(state), 0, addressNoChecksum, 1, 20);
byte[] checksum = Crypto.doubleDigest(addressNoChecksum);
// Needs to be 'B' sized
byte[] address = new byte[32];
System.arraycopy(addressNoChecksum, 0, address, 32 - 1 - 20 - 4, addressNoChecksum.length);
System.arraycopy(checksum, 0, address, 32 - 4, 4);
setB(state, address);
}
private static byte[] getB(MachineState state) {
QortalATAPI api = (QortalATAPI) state.getAPI();
return api.getB(state);
}
private static void setB(MachineState state, byte[] bBytes) {
QortalATAPI api = (QortalATAPI) state.getAPI();
api.setB(state, bBytes);
}
}

View File

@ -501,8 +501,10 @@ public class Block {
// Allocate cache for results
List<TransactionData> transactionsData = this.repository.getBlockRepository().getTransactionsFromSignature(this.blockData.getSignature());
// The number of transactions fetched from repository should correspond with Block's transactionCount
if (transactionsData.size() != this.blockData.getTransactionCount())
long nonAtTransactionCount = transactionsData.stream().filter(transactionData -> transactionData.getType() != TransactionType.AT).count();
// The number of non-AT transactions fetched from repository should correspond with Block's transactionCount
if (nonAtTransactionCount != this.blockData.getTransactionCount())
throw new IllegalStateException("Block's transactions from repository do not match block's transaction count");
this.transactions = new ArrayList<>();
@ -535,8 +537,10 @@ public class Block {
// Allocate cache for results
List<ATStateData> atStateData = this.repository.getATRepository().getBlockATStatesAtHeight(this.blockData.getHeight());
// The number of AT states fetched from repository should correspond with Block's atCount
if (atStateData.size() != this.blockData.getATCount())
// The number of non-initial AT states fetched from repository should correspond with Block's atCount.
// We exclude initial AT states created by processing DEPLOY_AT transactions as they are never serialized and so not included in block's AT count.
int nonInitialCount = (int) atStateData.stream().filter(atState -> !atState.isInitial()).count();
if (nonInitialCount != this.blockData.getATCount())
throw new IllegalStateException("Block's AT states from repository do not match block's AT count");
this.atStates = atStateData;
@ -1101,9 +1105,6 @@ public class Block {
* @throws DataException
*/
private ValidationResult areAtsValid() throws DataException {
if (this.blockData.getATCount() == 0)
return ValidationResult.OK;
// Locally generated AT states should be valid so no need to re-execute them
if (this.ourAtStates == this.getATStates()) // Note object reference compare
return ValidationResult.OK;
@ -1173,7 +1174,7 @@ public class Block {
// Run each AT, appends AT-Transactions and corresponding AT states, to our lists
for (ATData atData : executableATs) {
AT at = new AT(this.repository, atData);
List<AtTransaction> atTransactions = at.run(this.blockData.getTimestamp());
List<AtTransaction> atTransactions = at.run(this.blockData.getHeight(), this.blockData.getTimestamp());
allAtTransactions.addAll(atTransactions);
@ -1183,17 +1184,18 @@ public class Block {
this.ourAtFees += atStateData.getFees();
}
// AT Transactions never need approval
allAtTransactions.forEach(transaction -> transaction.getTransactionData().setApprovalStatus(ApprovalStatus.NOT_REQUIRED));
// Prepend our entire AT-Transactions/states to block's transactions
this.transactions.addAll(0, allAtTransactions);
// Re-sort
this.transactions.sort(Transaction.getComparator());
// Update transaction count
this.blockData.setTransactionCount(this.blockData.getTransactionCount() + 1);
// AT Transactions do not affect block's transaction count
// We've added transactions, so recalculate transactions signature
calcTransactionsSignature();
// AT Transactions do not affect block's transaction signature
}
/** Returns whether block's minter is actually allowed to mint this block. */
@ -1408,13 +1410,17 @@ public class Block {
protected void processAtFeesAndStates() throws DataException {
ATRepository atRepository = this.repository.getATRepository();
for (ATStateData atState : this.getATStates()) {
Account atAccount = new Account(this.repository, atState.getATAddress());
for (ATStateData atStateData : this.ourAtStates) {
Account atAccount = new Account(this.repository, atStateData.getATAddress());
// Subtract AT-generated fees from AT accounts
atAccount.setConfirmedBalance(Asset.QORT, atAccount.getConfirmedBalance(Asset.QORT) - atState.getFees());
atAccount.setConfirmedBalance(Asset.QORT, atAccount.getConfirmedBalance(Asset.QORT) - atStateData.getFees());
atRepository.save(atState);
// Update AT info with latest state
ATData atData = atRepository.fromATAddress(atStateData.getATAddress());
AT at = new AT(repository, atData, atStateData);
at.update(this.blockData.getHeight(), this.blockData.getTimestamp());
}
}
@ -1569,15 +1575,18 @@ public class Block {
protected void orphanAtFeesAndStates() throws DataException {
ATRepository atRepository = this.repository.getATRepository();
for (ATStateData atState : this.getATStates()) {
Account atAccount = new Account(this.repository, atState.getATAddress());
for (ATStateData atStateData : this.getATStates()) {
Account atAccount = new Account(this.repository, atStateData.getATAddress());
// Return AT-generated fees to AT accounts
atAccount.setConfirmedBalance(Asset.QORT, atAccount.getConfirmedBalance(Asset.QORT) + atState.getFees());
}
atAccount.setConfirmedBalance(Asset.QORT, atAccount.getConfirmedBalance(Asset.QORT) + atStateData.getFees());
// Delete ATStateData for this height
atRepository.deleteATStates(this.blockData.getHeight());
// Revert AT info to prior values
ATData atData = atRepository.fromATAddress(atStateData.getATAddress());
AT at = new AT(repository, atData, atStateData);
at.revert(this.blockData.getHeight(), this.blockData.getTimestamp());
}
}
protected void decreaseAccountLevels() throws DataException {

View File

@ -151,6 +151,19 @@ public class BlockChain {
/** Maximum time to retain online account signatures (ms) for block validity checks, to allow for clock variance. */
private long onlineAccountSignaturesMaxLifetime;
/** Settings relating to CIYAM AT feature. */
public static class CiyamAtSettings {
/** Fee per step/op-code executed. */
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long feePerStep;
/** Maximum number of steps per execution round, before AT is forced to sleep until next block. */
public int maxStepsPerRound;
/** How many steps for calling a function. */
public int stepsPerFunctionCall;
/** Roughly how many minutes per block. */
public int minutesPerBlock;
}
private CiyamAtSettings ciyamAtSettings;
// Constructors, etc.
@ -347,6 +360,10 @@ public class BlockChain {
return this.onlineAccountSignaturesMaxLifetime;
}
public CiyamAtSettings getCiyamAtSettings() {
return this.ciyamAtSettings;
}
// Convenience methods for specific blockchain feature triggers
// More complex getters for aspects that change by height or timestamp
@ -406,6 +423,9 @@ public class BlockChain {
if (this.founderEffectiveMintingLevel <= 0)
Settings.throwValidationError("Invalid/missing \"founderEffectiveMintingLevel\" in blockchain config");
if (this.ciyamAtSettings == null)
Settings.throwValidationError("No \"ciyamAtSettings\" entry found in blockchain config");
if (this.featureTriggers == null)
Settings.throwValidationError("No \"featureTriggers\" entry found in blockchain config");

View File

@ -337,11 +337,9 @@ public class BlockMinter extends Thread {
this.interrupt();
}
public static void mintTestingBlock(Repository repository, PrivateKeyAccount... mintingAndOnlineAccounts) throws DataException {
if (!BlockChain.getInstance().isTestChain()) {
LOGGER.warn("Ignoring attempt to mint testing block for non-test chain!");
return;
}
public static Block mintTestingBlock(Repository repository, PrivateKeyAccount... mintingAndOnlineAccounts) throws DataException {
if (!BlockChain.getInstance().isTestChain())
throw new DataException("Ignoring attempt to mint testing block for non-test chain!");
// Ensure mintingAccount is 'online' so blocks can be minted
Controller.getInstance().ensureTestingAccountsOnline(mintingAndOnlineAccounts);
@ -372,6 +370,8 @@ public class BlockMinter extends Thread {
LOGGER.info(String.format("Minted new test block: %d", newBlock.getBlockData().getHeight()));
repository.saveChanges();
return newBlock;
} finally {
blockchainLock.unlock();
}

View File

@ -290,6 +290,10 @@ public class GenesisBlock extends Block {
for (Transaction transaction : this.getTransactions())
this.repository.getTransactionRepository().save(transaction.getTransactionData());
// No ATs in genesis block
this.ourAtStates = Collections.emptyList();
this.ourAtFees = 0;
super.process();
}

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();
}
@ -677,6 +681,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

@ -10,96 +10,193 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.Date;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
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.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.BlockChain;
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.VerificationException;
import org.bitcoinj.core.listeners.BlocksDownloadedEventListener;
import org.bitcoinj.core.listeners.NewBestBlockListener;
import org.bitcoinj.net.discovery.DnsDiscovery;
import org.bitcoinj.params.MainNetParams;
import org.bitcoinj.params.RegTestParams;
import org.bitcoinj.params.TestNet3Params;
import org.bitcoinj.script.Script;
import org.bitcoinj.script.Script.ScriptType;
import org.bitcoinj.store.BlockStore;
import org.bitcoinj.store.BlockStoreException;
import org.bitcoinj.store.SPVBlockStore;
import org.bitcoinj.store.MemoryBlockStore;
import org.bitcoinj.utils.MonetaryFormat;
import org.bitcoinj.utils.Threading;
import org.bitcoinj.wallet.KeyChainGroup;
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;
public class BTC {
private static class RollbackBlockChain extends BlockChain {
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;
public RollbackBlockChain(NetworkParameters params, BlockStore blockStore) throws BlockStoreException {
super(params, blockStore);
private static final MessageDigest RIPE_MD160_DIGESTER;
private static final MessageDigest SHA256_DIGESTER;
static {
try {
RIPE_MD160_DIGESTER = MessageDigest.getInstance("RIPEMD160");
SHA256_DIGESTER = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
@Override
public void setChainHead(StoredBlock chainHead) throws BlockStoreException {
super.setChainHead(chainHead);
}
protected static final Logger LOGGER = LogManager.getLogger(BTC.class);
public enum BitcoinNet {
MAIN {
@Override
public NetworkParameters getParams() {
return MainNetParams.get();
}
},
TEST3 {
@Override
public NetworkParameters getParams() {
return TestNet3Params.get();
}
},
REGTEST {
@Override
public NetworkParameters getParams() {
return RegTestParams.get();
}
};
public abstract NetworkParameters getParams();
}
private static class UpdateableCheckpointManager extends CheckpointManager implements NewBestBlockListener {
private static final long CHECKPOINT_THRESHOLD = 7 * 24 * 60 * 60; // seconds
private static final int checkpointInterval = 500;
private static final String MINIMAL_TESTNET3_TEXTFILE = "TXT CHECKPOINTS 1\n0\n1\nAAAAAAAAB+EH4QfhAAAH4AEAAAApmwX6UCEnJcYIKTa7HO3pFkqqNhAzJVBMdEuGAAAAAPSAvVCBUypCbBW/OqU0oIF7ISF84h2spOqHrFCWN9Zw6r6/T///AB0E5oOO\n";
private static final String MINIMAL_MAINNET_TEXTFILE = "TXT CHECKPOINTS 1\n0\n1\nAAAAAAAAB+EH4QfhAAAH4AEAAABjl7tqvU/FIcDT9gcbVlA4nwtFUbxAtOawZzBpAAAAAKzkcK7NqciBjI/ldojNKncrWleVSgDfBCCn3VRrbSxXaw5/Sf//AB0z8Bkv\n";
private static final String minimalTestNet3TextFile = "TXT CHECKPOINTS 1\n0\n1\nAAAAAAAAB+EH4QfhAAAH4AEAAAApmwX6UCEnJcYIKTa7HO3pFkqqNhAzJVBMdEuGAAAAAPSAvVCBUypCbBW/OqU0oIF7ISF84h2spOqHrFCWN9Zw6r6/T///AB0E5oOO\n";
private static final String minimalMainNetTextFile = "TXT CHECKPOINTS 1\n0\n1\nAAAAAAAAB+EH4QfhAAAH4AEAAABjl7tqvU/FIcDT9gcbVlA4nwtFUbxAtOawZzBpAAAAAKzkcK7NqciBjI/ldojNKncrWleVSgDfBCCn3VRrbSxXaw5/Sf//AB0z8Bkv\n";
public UpdateableCheckpointManager(NetworkParameters params) throws IOException {
super(params, getMinimalTextFileStream(params));
public UpdateableCheckpointManager(NetworkParameters params, File checkpointsFile) throws IOException {
super(params, getMinimalTextFileStream(params, checkpointsFile));
}
public UpdateableCheckpointManager(NetworkParameters params, InputStream inputStream) throws IOException {
super(params, inputStream);
}
private static ByteArrayInputStream getMinimalTextFileStream(NetworkParameters params) {
private static ByteArrayInputStream getMinimalTextFileStream(NetworkParameters params, File checkpointsFile) {
if (params == MainNetParams.get())
return new ByteArrayInputStream(minimalMainNetTextFile.getBytes());
return new ByteArrayInputStream(MINIMAL_MAINNET_TEXTFILE.getBytes());
if (params == TestNet3Params.get())
return new ByteArrayInputStream(minimalTestNet3TextFile.getBytes());
return new ByteArrayInputStream(MINIMAL_TESTNET3_TEXTFILE.getBytes());
if (params == RegTestParams.get())
return newRegTestCheckpointsStream(checkpointsFile); // We have to build this
throw new RuntimeException("Failed to construct empty UpdateableCheckpointManageer");
}
@Override
public void notifyNewBestBlock(StoredBlock block) throws VerificationException {
int height = block.getHeight();
private static ByteArrayInputStream newRegTestCheckpointsStream(File checkpointsFile) {
try {
final NetworkParameters params = RegTestParams.get();
if (height % checkpointInterval == 0)
checkpoints.put(block.getHeader().getTimeSeconds(), block);
final BlockStore store = new MemoryBlockStore(params);
final BlockChain chain = new BlockChain(params, store);
final PeerGroup peerGroup = new PeerGroup(params, chain);
final InetAddress ipAddress = InetAddress.getLoopbackAddress();
final PeerAddress peerAddress = new PeerAddress(params, ipAddress);
peerGroup.addAddress(peerAddress);
peerGroup.start();
final TreeMap<Integer, StoredBlock> checkpoints = new TreeMap<>();
chain.addNewBestBlockListener((block) -> checkpoints.put(block.getHeight(), block));
peerGroup.downloadBlockChain();
peerGroup.stop();
saveAsText(checkpointsFile, checkpoints.values());
return new ByteArrayInputStream(Files.readAllBytes(checkpointsFile.toPath()));
} catch (BlockStoreException e) {
throw new RuntimeException(e);
} catch (UnknownHostException e) {
throw new RuntimeException(e);
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public void saveAsText(File textFile) throws FileNotFoundException {
@Override
public void notifyNewBestBlock(StoredBlock block) {
final int height = block.getHeight();
if (height % this.params.getInterval() != 0)
return;
final long blockTimestamp = block.getHeader().getTimeSeconds();
final long now = System.currentTimeMillis() / 1000L;
if (blockTimestamp > now - CHECKPOINT_THRESHOLD)
return; // Too recent
LOGGER.trace(() -> String.format("Checkpointing at block %d dated %s", height, LocalDateTime.ofInstant(Instant.ofEpochSecond(blockTimestamp), ZoneOffset.UTC)));
this.checkpoints.put(blockTimestamp, block);
try {
saveAsText(new File(BTC.getInstance().getDirectory(), BTC.getInstance().getCheckpointsFileName()), this.checkpoints.values());
} catch (FileNotFoundException e) {
// Save failed - log it but it's not critical
LOGGER.warn("Failed to save updated BTC checkpoints: " + e.getMessage());
}
}
private static void saveAsText(File textFile, Collection<StoredBlock> checkpointBlocks) throws FileNotFoundException {
try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(new FileOutputStream(textFile), StandardCharsets.US_ASCII))) {
writer.println("TXT CHECKPOINTS 1");
writer.println("0"); // Number of signatures to read. Do this later.
writer.println(checkpoints.size());
writer.println(checkpointBlocks.size());
ByteBuffer buffer = ByteBuffer.allocate(StoredBlock.COMPACT_SERIALIZED_SIZE);
for (StoredBlock block : checkpoints.values()) {
for (StoredBlock block : checkpointBlocks) {
block.serializeCompact(buffer);
writer.println(CheckpointManager.BASE64.encode(buffer.array()));
buffer.position(0);
@ -119,9 +216,11 @@ public class BTC {
dataOutputStream.writeBytes("CHECKPOINTS 1");
dataOutputStream.writeInt(0); // Number of signatures to read. Do this later.
digestOutputStream.on(true);
dataOutputStream.writeInt(checkpoints.size());
dataOutputStream.writeInt(this.checkpoints.size());
ByteBuffer buffer = ByteBuffer.allocate(StoredBlock.COMPACT_SERIALIZED_SIZE);
for (StoredBlock block : checkpoints.values()) {
for (StoredBlock block : this.checkpoints.values()) {
block.serializeCompact(buffer);
dataOutputStream.write(buffer.array());
buffer.position(0);
@ -130,53 +229,65 @@ 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 static final Object instanceLock = new Object();
private static File directory;
private static String chainFileName;
private static String checkpointsFileName;
private final NetworkParameters params;
private final String checkpointsFileName;
private final File directory;
private static NetworkParameters params;
private static PeerGroup peerGroup;
private static BlockStore blockStore;
private static RollbackBlockChain chain;
private static UpdateableCheckpointManager manager;
private PeerGroup peerGroup;
private BlockStore blockStore;
private ResettableBlockChain chain;
private UpdateableCheckpointManager manager;
// Constructors and instance
private BTC() {
// Start wallet
if (Settings.getInstance().useBitcoinTestNet()) {
params = TestNet3Params.get();
chainFileName = "bitcoinj-testnet.spvchain";
checkpointsFileName = "checkpoints-testnet.txt";
} else {
params = MainNetParams.get();
chainFileName = "bitcoinj.spvchain";
checkpointsFileName = "checkpoints.txt";
BitcoinNet bitcoinNet = Settings.getInstance().getBitcoinNet();
this.params = bitcoinNet.getParams();
switch (bitcoinNet) {
case MAIN:
this.checkpointsFileName = "checkpoints.txt";
break;
case TEST3:
this.checkpointsFileName = "checkpoints-testnet.txt";
break;
case REGTEST:
this.checkpointsFileName = "checkpoints-regtest.txt";
break;
default:
throw new IllegalStateException("Unsupported Bitcoin network: " + bitcoinNet.name());
}
directory = new File("Qortal-BTC");
if (!directory.exists())
directory.mkdirs();
this.directory = new File("Qortal-BTC");
File chainFile = new File(directory, chainFileName);
if (!this.directory.exists())
this.directory.mkdirs();
try {
blockStore = new SPVBlockStore(params, chainFile);
} catch (BlockStoreException e) {
throw new RuntimeException("Failed to open/create BTC SPVBlockStore", e);
}
File checkpointsFile = new File(directory, checkpointsFileName);
File checkpointsFile = new File(this.directory, this.checkpointsFileName);
try (InputStream checkpointsStream = new FileInputStream(checkpointsFile)) {
manager = new UpdateableCheckpointManager(params, checkpointsStream);
this.manager = new UpdateableCheckpointManager(this.params, checkpointsStream);
} catch (FileNotFoundException e) {
// Construct with no checkpoints then
try {
manager = new UpdateableCheckpointManager(params);
this.manager = new UpdateableCheckpointManager(this.params, checkpointsFile);
} catch (IOException e2) {
throw new RuntimeException("Failed to create new BTC checkpoints", e2);
}
@ -185,15 +296,11 @@ public class BTC {
}
try {
chain = new RollbackBlockChain(params, blockStore);
this.start(System.currentTimeMillis() / 1000L);
// this.peerGroup.waitForPeers(this.peerGroup.getMaxConnections()).get();
} catch (BlockStoreException e) {
throw new RuntimeException("Failed to construct BTC blockchain", e);
throw new RuntimeException("Failed to start BTC instance", e);
}
peerGroup = new PeerGroup(params, chain);
peerGroup.setUserAgent("qortal", "1.0");
peerGroup.addPeerDiscovery(new DnsDiscovery(params));
peerGroup.start();
}
public static synchronized BTC getInstance() {
@ -203,108 +310,259 @@ public class BTC {
return instance;
}
public void shutdown() {
synchronized (instanceLock) {
if (instance == null)
return;
// Getters & setters
instance = null;
}
peerGroup.stop();
try {
blockStore.close();
} catch (BlockStoreException e) {
// What can we do?
}
/* package */ File getDirectory() {
return this.directory;
}
/* package */ String getCheckpointsFileName() {
return this.checkpointsFileName;
}
public NetworkParameters getNetworkParameters() {
return this.params;
}
// Static utility methods
public static byte[] hash160(byte[] message) {
return RIPE_MD160_DIGESTER.digest(SHA256_DIGESTER.digest(message));
}
// Start-up & shutdown
private void start(long startTime) throws BlockStoreException {
StoredBlock checkpoint = this.manager.getCheckpointBefore(startTime - 1);
this.blockStore = new MemoryBlockStore(params);
this.blockStore.put(checkpoint);
this.blockStore.setChainHead(checkpoint);
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));
} else {
peerGroup.addAddress(PeerAddress.localhost(this.params));
}
this.peerGroup.start();
}
public void shutdown() {
this.peerGroup.stop();
}
// Utility methods
protected Wallet createEmptyWallet() {
ECKey dummyKey = new ECKey();
KeyChainGroup keyChainGroup = KeyChainGroup.createBasic(params);
keyChainGroup.importKeys(dummyKey);
Wallet wallet = new Wallet(params, keyChainGroup);
wallet.removeKey(dummyKey);
return wallet;
return Wallet.createBasic(this.params);
}
public void watch(String base58Address, long startTime) throws InterruptedException, ExecutionException, TimeoutException, BlockStoreException {
Wallet wallet = createEmptyWallet();
private class ReplayHooks {
private Runnable preReplay;
private Runnable postReplay;
WalletCoinsReceivedEventListener coinsReceivedListener = new WalletCoinsReceivedEventListener() {
@Override
public void onCoinsReceived(Wallet wallet, Transaction tx, Coin prevBalance, Coin newBalance) {
System.out.println("Coins received via transaction " + tx.getTxId().toString());
}
};
wallet.addCoinsReceivedEventListener(coinsReceivedListener);
Address address = Address.fromString(params, base58Address);
wallet.addWatchedAddress(address, startTime);
StoredBlock checkpoint = manager.getCheckpointBefore(startTime);
blockStore.put(checkpoint);
blockStore.setChainHead(checkpoint);
chain.setChainHead(checkpoint);
chain.addWallet(wallet);
peerGroup.addWallet(wallet);
peerGroup.setFastCatchupTimeSecs(startTime);
peerGroup.addBlocksDownloadedEventListener((peer, block, filteredBlock, blocksLeft) -> {
if (blocksLeft % 1000 == 0)
System.out.println("Blocks left: " + blocksLeft);
});
System.out.println("Starting download...");
peerGroup.downloadBlockChain();
List<TransactionOutput> outputs = wallet.getWatchedOutputs(true);
peerGroup.removeWallet(wallet);
chain.removeWallet(wallet);
for (TransactionOutput output : outputs)
System.out.println(output.toString());
}
public void watch(Script script) {
// wallet.addWatchedScripts(scripts);
}
public void updateCheckpoints() {
final long now = new Date().getTime() / 1000 - 86400;
try {
StoredBlock checkpoint = manager.getCheckpointBefore(now);
blockStore.put(checkpoint);
blockStore.setChainHead(checkpoint);
chain.setChainHead(checkpoint);
} catch (BlockStoreException e) {
throw new RuntimeException("Failed to update BTC checkpoints", e);
public ReplayHooks(Runnable preReplay, Runnable postReplay) {
this.preReplay = preReplay;
this.postReplay = postReplay;
}
peerGroup.setFastCatchupTimeSecs(now);
public void preReplay() {
this.preReplay.run();
}
chain.addNewBestBlockListener(Threading.SAME_THREAD, manager);
public void postReplay() {
this.postReplay.run();
}
}
peerGroup.addBlocksDownloadedEventListener((peer, block, filteredBlock, blocksLeft) -> {
if (blocksLeft % 1000 == 0)
System.out.println("Blocks left: " + blocksLeft);
});
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);
System.out.println("Starting download...");
peerGroup.downloadBlockChain();
final WalletCoinsReceivedEventListener coinsReceivedListener = (someWallet, tx, prevBalance, newBalance) -> {
LOGGER.debug(String.format("Wallet-related transaction %s", tx.getTxId()));
};
final WalletCoinsSentEventListener coinsSentListener = (someWallet, tx, prevBalance, newBalance) -> {
LOGGER.debug(String.format("Wallet-related transaction %s", tx.getTxId()));
};
if (wallet != null) {
wallet.addCoinsReceivedEventListener(coinsReceivedListener);
wallet.addCoinsSentEventListener(coinsSentListener);
// Link wallet to chain and peerGroup
this.chain.addWallet(wallet);
this.peerGroup.addWallet(wallet);
}
try {
manager.saveAsText(new File(directory, checkpointsFileName));
} catch (FileNotFoundException e) {
throw new RuntimeException("Failed to save updated BTC checkpoints", e);
if (replayHooks != null)
replayHooks.preReplay();
// Sync blockchain using peerGroup, skipping as much as we can before startTime
this.peerGroup.setFastCatchupTimeSecs(startTime);
this.chain.addNewBestBlockListener(Threading.SAME_THREAD, this.manager);
this.peerGroup.downloadBlockChain();
} finally {
// Clean up
if (replayHooks != null)
replayHooks.postReplay();
if (wallet != null) {
wallet.removeCoinsReceivedEventListener(coinsReceivedListener);
wallet.removeCoinsSentEventListener(coinsSentListener);
this.peerGroup.removeWallet(wallet);
this.chain.removeWallet(wallet);
}
// For safety, disconnect download peer just in case
Peer downloadPeer = this.peerGroup.getDownloadPeer();
if (downloadPeer != null)
downloadPeer.close();
}
}
// 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
int startTime = (int) (System.currentTimeMillis() / 1000L) - 110 * 60; // 110 minutes before now, in seconds
try {
this.replayChain(startTime, null, null);
List<StoredBlock> latestBlocks = new ArrayList<>(11);
StoredBlock block = this.blockStore.getChainHead();
for (int i = 0; i < 11; ++i) {
latestBlocks.add(block);
block = block.getPrev(this.blockStore);
}
// Descending, but order shouldn't matter as we're picking median...
latestBlocks.sort((a, b) -> Long.compare(b.getHeader().getTimeSeconds(), a.getHeader().getTimeSeconds()));
return latestBlocks.get(5).getHeader().getTimeSeconds();
} catch (BlockStoreException e) {
LOGGER.error(String.format("BTC blockstore issue: %s", e.getMessage()));
return null;
}
}
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);
wallet.addWatchedAddress(address, startTime);
try {
replayChain(startTime, wallet, null);
// Now that blockchain is up-to-date, return current balance
return wallet.getBalance();
} catch (BlockStoreException e) {
LOGGER.error(String.format("BTC blockstore issue: %s", e.getMessage()));
return null;
}
}
public List<TransactionOutput> getOutputs(String base58Address, int startTime) {
Wallet wallet = createEmptyWallet();
Address address = Address.fromString(this.params, base58Address);
wallet.addWatchedAddress(address, startTime);
try {
replayChain(startTime, wallet, null);
// Now that blockchain is up-to-date, return outputs
return wallet.getWatchedOutputs(true);
} catch (BlockStoreException e) {
LOGGER.error(String.format("BTC blockstore issue: %s", e.getMessage()));
return null;
}
}
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
ECKey fakeKey = new ECKey();
wallet.addWatchedAddress(Address.fromKey(this.params, fakeKey, ScriptType.P2PKH), startTime);
final Sha256Hash txHash = Sha256Hash.wrap(txId);
final AtomicReference<Transaction> foundTransaction = new AtomicReference<>();
final BlocksDownloadedEventListener listener = (peer, block, filteredBlock, blocksLeft) -> {
List<Transaction> transactions = block.getTransactions();
if (transactions == null)
return;
for (Transaction transaction : transactions)
if (transaction.getTxId().equals(txHash)) {
System.out.println(String.format("We downloaded block containing tx!"));
foundTransaction.set(transaction);
}
};
ReplayHooks replayHooks = new ReplayHooks(() -> this.peerGroup.addBlocksDownloadedEventListener(listener), () -> this.peerGroup.removeBlocksDownloadedEventListener(listener));
// Replay chain in the hope it will download transactionId as a dependency
try {
replayChain(startTime, wallet, replayHooks);
Transaction realTx = foundTransaction.get();
return realTx.getOutputs();
} catch (BlockStoreException e) {
LOGGER.error(String.format("BTC blockstore issue: %s", e.getMessage()));
return null;
}
}
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

@ -0,0 +1,663 @@
package org.qortal.crosschain;
import static org.ciyam.at.OpCode.calcOffset;
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;
import org.bitcoinj.core.Transaction;
import org.bitcoinj.core.Transaction.SigHash;
import org.bitcoinj.core.TransactionInput;
import org.bitcoinj.core.TransactionOutput;
import org.bitcoinj.crypto.TransactionSignature;
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;
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;
import com.google.common.hash.HashCode;
import com.google.common.primitives.Bytes;
/*
* Bob generates Bitcoin private key
* private key required to sign P2SH redeem tx
* private key can be used to create 'secret' (e.g. double-SHA256)
* encrypted private key could be stored in Qortal AT for access by Bob from any node
* Bob creates Qortal AT
* Alice finds Qortal AT and wants to trade
* Alice generates Bitcoin private key
* Alice will need to send Bob her Qortal address and Bitcoin refund address
* Bob sends Alice's Qortal address to Qortal AT
* Qortal AT sends initial QORT payment to Alice (so she has QORT to send message to AT and claim funds)
* Alice receives funds and checks Qortal AT to confirm it's locked to her
* Alice creates/funds Bitcoin P2SH
* Alice requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime
* Bob checks P2SH is funded
* Bob requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime
* Bob uses secret to redeem P2SH
* Qortal core/UI will need to create, and sign, this transaction
* Alice scans P2SH redeem tx and uses secret to redeem Qortal AT
*/
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
/*
* OP_TUCK (to copy public key to before signature)
* OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails)
* OP_HASH160 (convert public key to PKH)
* OP_DUP (duplicate PKH)
* <push 20 bytes> <refund PKH> OP_EQUAL (does PKH match refund PKH?)
* OP_IF
* OP_DROP (no need for duplicate PKH)
* <push 4 bytes> <locktime>
* OP_CHECKLOCKTIMEVERIFY (if this passes, leftover stack is <locktime> so script passes)
* OP_ELSE
* <push 20 bytes> <redeem PKH> OP_EQUALVERIFY (duplicate PKH must match redeem PKH or script fails)
* OP_HASH160 (hash secret)
* <push 20 bytes> <hash of secret> OP_EQUAL (do hashes of secrets match? if true, script passes else script fails)
* OP_ENDIF
*/
private static final byte[] redeemScript1 = HashCode.fromString("7dada97614").asBytes(); // OP_TUCK OP_CHECKSIGVERIFY OP_HASH160 OP_DUP push(0x14 bytes)
private static final byte[] redeemScript2 = HashCode.fromString("87637504").asBytes(); // OP_EQUAL OP_IF OP_DROP push(0x4 bytes)
private static final byte[] redeemScript3 = HashCode.fromString("b16714").asBytes(); // OP_CHECKLOCKTIMEVERIFY OP_ELSE push(0x14 bytes)
private static final byte[] redeemScript4 = HashCode.fromString("88a914").asBytes(); // OP_EQUALVERIFY OP_HASH160 push(0x14 bytes)
private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF
/**
* Returns Bitcoin redeemScript used for cross-chain trading.
* <p>
* See comments in {@link BTCACCT} for more details.
*
* @param refunderPubKeyHash 20-byte HASH160 of P2SH funder's public key, for refunding purposes
* @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund
* @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key
* @param secretHash 20-byte HASH160 of secret, used by P2SH redeemer to claim funds
* @return
*/
public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] secretHash) {
return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)),
redeemScript3, redeemerPubKeyHash, redeemScript4, secretHash, redeemScript5);
}
/**
* Builds a custom transaction to spend P2SH.
*
* @param amount output amount, should be total of input amounts, less miner fees
* @param spendKey key for signing transaction, and also where funds are 'sent' (output)
* @param fundingOutput output from transaction that funded P2SH address
* @param redeemScriptBytes the redeemScript itself, in byte[] form
* @param lockTime (optional) transaction nLockTime, used in refund scenario
* @param scriptSigBuilder function for building scriptSig using transaction input signature
* @return Signed Bitcoin transaction for spending P2SH
*/
public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, TransactionOutput fundingOutput, byte[] redeemScriptBytes, Long lockTime, Function<byte[], Script> scriptSigBuilder) {
NetworkParameters params = BTC.getInstance().getNetworkParameters();
Transaction transaction = new Transaction(params);
transaction.setVersion(2);
// Output is back to P2SH funder
transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(spendKey.getPubKeyHash()));
// Input (without scriptSig prior to signing)
TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor());
if (lockTime != null)
input.setSequenceNumber(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF
else
input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
transaction.addInput(input);
// Set locktime after inputs added but before input signatures are generated
if (lockTime != null)
transaction.setLockTime(lockTime);
// Generate transaction signature for input
final boolean anyoneCanPay = false;
TransactionSignature txSig = transaction.calculateSignature(0, spendKey, redeemScriptBytes, SigHash.ALL, anyoneCanPay);
// Calculate transaction signature
byte[] txSigBytes = txSig.encodeToBitcoin();
// Build scriptSig using lambda and tx signature
Script scriptSig = scriptSigBuilder.apply(txSigBytes);
// Set input scriptSig
transaction.getInput(0).setScriptSig(scriptSig);
return transaction;
}
/**
* Returns signed Bitcoin transaction claiming refund from P2SH address.
*
* @param refundAmount refund amount, should be total of input amounts, less miner fees
* @param refundKey key for signing transaction, and also where refund is 'sent' (output)
* @param fundingOutput output from transaction that funded P2SH address
* @param redeemScriptBytes the redeemScript itself, in byte[] form
* @param lockTime transaction nLockTime - must be at least locktime used in redeemScript
* @return Signed Bitcoin transaction for refunding P2SH
*/
public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, TransactionOutput fundingOutput, byte[] redeemScriptBytes, long lockTime) {
Function<byte[], Script> refundSigScriptBuilder = (txSigBytes) -> {
// Build scriptSig with...
ScriptBuilder scriptBuilder = new ScriptBuilder();
// transaction signature
scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes));
// redeem public key
byte[] refundPubKey = refundKey.getPubKey();
scriptBuilder.addChunk(new ScriptChunk(refundPubKey.length, refundPubKey));
// redeem script
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
return scriptBuilder.build();
};
return buildP2shTransaction(refundAmount, refundKey, fundingOutput, redeemScriptBytes, lockTime, refundSigScriptBuilder);
}
/**
* Returns signed Bitcoin transaction redeeming funds from P2SH address.
*
* @param redeemAmount redeem amount, should be total of input amounts, less miner fees
* @param redeemKey key for signing transaction, and also where funds are 'sent' (output)
* @param fundingOutput output from transaction that funded P2SH address
* @param redeemScriptBytes the redeemScript itself, in byte[] form
* @param secret actual 32-byte secret used when building redeemScript
* @return Signed Bitcoin transaction for redeeming P2SH
*/
public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, TransactionOutput fundingOutput, byte[] redeemScriptBytes, byte[] secret) {
Function<byte[], Script> redeemSigScriptBuilder = (txSigBytes) -> {
// Build scriptSig with...
ScriptBuilder scriptBuilder = new ScriptBuilder();
// secret
scriptBuilder.addChunk(new ScriptChunk(secret.length, secret));
// transaction signature
scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes));
// redeem public key
byte[] redeemPubKey = redeemKey.getPubKey();
scriptBuilder.addChunk(new ScriptChunk(redeemPubKey.length, redeemPubKey));
// redeem script
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
return scriptBuilder.build();
};
return buildP2shTransaction(redeemAmount, redeemKey, fundingOutput, redeemScriptBytes, null, redeemSigScriptBuilder);
}
/**
* Returns Qortal AT creation bytes for cross-chain trading AT.
* <p>
* <tt>tradeTimeout</tt> (minutes) is the time window for the recipient to send the
* 32-byte secret to the AT, before the AT automatically refunds the AT's creator.
*
* @param qortalCreator Qortal address for AT creator, also used for refunds
* @param secretHash 20-byte HASH160 of 32-byte secret
* @param tradeTimeout how many minutes, from start of 'trade mode' until AT auto-refunds AT creator
* @param initialPayout how much QORT to pay trade partner upon switch to 'trade mode'
* @param redeemPayout how much QORT to pay trade partner if they send correct 32-byte secret to AT
* @param bitcoinAmount how much BTC the AT creator is expecting to trade
* @return
*/
public static byte[] buildQortalAT(String qortalCreator, byte[] secretHash, int tradeTimeout, long initialPayout, long redeemPayout, long bitcoinAmount) {
// Labels for data segment addresses
int addrCounter = 0;
// Constants (with corresponding dataByteBuffer.put*() calls below)
final int addrQortalCreator1 = addrCounter++;
final int addrQortalCreator2 = addrCounter++;
final int addrQortalCreator3 = addrCounter++;
final int addrQortalCreator4 = addrCounter++;
final int addrSecretHash = addrCounter;
addrCounter += 4;
final int addrTradeTimeout = addrCounter++;
final int addrInitialPayoutAmount = addrCounter++;
final int addrRedeemPayoutAmount = addrCounter++;
final int addrBitcoinAmount = addrCounter++;
final int addrMessageTxType = addrCounter++;
final int addrSecretHashPointer = addrCounter++;
final int addrQortalRecipientPointer = addrCounter++;
final int addrMessageSenderPointer = addrCounter++;
final int addrMessageDataPointer = addrCounter++;
final int addrMessageDataLength = addrCounter++;
final int addrEndOfConstants = addrCounter;
// Variables
final int addrQortalRecipient1 = addrCounter++;
final int addrQortalRecipient2 = addrCounter++;
final int addrQortalRecipient3 = addrCounter++;
final int addrQortalRecipient4 = addrCounter++;
final int addrTradeRefundTimestamp = addrCounter++;
final int addrLastTxTimestamp = addrCounter++;
final int addrBlockTimestamp = addrCounter++;
final int addrTxType = addrCounter++;
final int addrResult = addrCounter++;
final int addrMessageSender1 = addrCounter++;
final int addrMessageSender2 = addrCounter++;
final int addrMessageSender3 = addrCounter++;
final int addrMessageSender4 = addrCounter++;
final int addrMessageData = addrCounter;
addrCounter += 4;
// Data segment
ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE);
// AT creator's Qortal address, decoded from Base58
assert dataByteBuffer.position() == addrQortalCreator1 * MachineState.VALUE_SIZE : "addrQortalCreator1 incorrect";
byte[] qortalCreatorBytes = Base58.decode(qortalCreator);
dataByteBuffer.put(Bytes.ensureCapacity(qortalCreatorBytes, 32, 0));
// Hash of secret
assert dataByteBuffer.position() == addrSecretHash * MachineState.VALUE_SIZE : "addrSecretHash incorrect";
dataByteBuffer.put(Bytes.ensureCapacity(secretHash, 32, 0));
// Trade timeout in minutes
assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect";
dataByteBuffer.putLong(tradeTimeout);
// Initial payout amount
assert dataByteBuffer.position() == addrInitialPayoutAmount * MachineState.VALUE_SIZE : "addrInitialPayoutAmount incorrect";
dataByteBuffer.putLong(initialPayout);
// Redeem payout amount
assert dataByteBuffer.position() == addrRedeemPayoutAmount * MachineState.VALUE_SIZE : "addrRedeemPayoutAmount incorrect";
dataByteBuffer.putLong(redeemPayout);
// Expected Bitcoin amount
assert dataByteBuffer.position() == addrBitcoinAmount * MachineState.VALUE_SIZE : "addrBitcoinAmount incorrect";
dataByteBuffer.putLong(bitcoinAmount);
// We're only interested in MESSAGE transactions
assert dataByteBuffer.position() == addrMessageTxType * MachineState.VALUE_SIZE : "addrMessageTxType incorrect";
dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value);
// Index into data segment of hash, used by GET_B_IND
assert dataByteBuffer.position() == addrSecretHashPointer * MachineState.VALUE_SIZE : "addrSecretHashPointer incorrect";
dataByteBuffer.putLong(addrSecretHash);
// Index into data segment of recipient address, used by SET_B_IND
assert dataByteBuffer.position() == addrQortalRecipientPointer * MachineState.VALUE_SIZE : "addrQortalRecipientPointer incorrect";
dataByteBuffer.putLong(addrQortalRecipient1);
// Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND
assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect";
dataByteBuffer.putLong(addrMessageSender1);
// Source location and length for hashing any passed secret
assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect";
dataByteBuffer.putLong(addrMessageData);
assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect";
dataByteBuffer.putLong(32L);
assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants";
// Code labels
Integer labelRefund = null;
Integer labelOfferTxLoop = null;
Integer labelCheckOfferTx = null;
Integer labelTradeMode = null;
Integer labelTradeTxLoop = null;
Integer labelCheckTradeTx = null;
ByteBuffer codeByteBuffer = ByteBuffer.allocate(512);
// Two-pass version
for (int pass = 0; pass < 2; ++pass) {
codeByteBuffer.clear();
try {
/* Initialization */
// Use AT creation 'timestamp' as starting point for finding transactions sent to AT
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp));
// Set restart position to after this opcode
codeByteBuffer.put(OpCode.SET_PCS.compile());
/* Loop, waiting for message from AT owner containing trade partner details, or AT owner's address to cancel offer */
/* Transaction processing loop */
labelOfferTxLoop = codeByteBuffer.position();
// Find next transaction to this AT since the last one (if any)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp));
// If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0.
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
// If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckOfferTx)));
// Stop and wait for next block
codeByteBuffer.put(OpCode.STP_IMD.compile());
/* Check transaction */
labelCheckOfferTx = codeByteBuffer.position();
// Update our 'last found transaction's timestamp' using 'timestamp' from transaction
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp));
// Extract transaction type (message/payment) from transaction and save type in addrTxType
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType));
// If transaction type is not MESSAGE type then go look for another transaction
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelOfferTxLoop)));
/* Check transaction's sender */
// Extract sender address from transaction into B register
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
// Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
// Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction.
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalCreator1, calcOffset(codeByteBuffer, labelOfferTxLoop)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalCreator2, calcOffset(codeByteBuffer, labelOfferTxLoop)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalCreator3, calcOffset(codeByteBuffer, labelOfferTxLoop)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalCreator4, calcOffset(codeByteBuffer, labelOfferTxLoop)));
/* Extract trade partner info from message */
// Extract message from transaction into B register
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
// Save B register into data segment starting at addrQortalRecipient1 (as pointed to by addrQortalRecipientPointer)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalRecipientPointer));
// Compare each of recipient address with creator's address (for offer-cancel scenario). If they don't match, assume recipient is trade partner.
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient1, addrQortalCreator1, calcOffset(codeByteBuffer, labelTradeMode)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient2, addrQortalCreator2, calcOffset(codeByteBuffer, labelTradeMode)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient3, addrQortalCreator3, calcOffset(codeByteBuffer, labelTradeMode)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient4, addrQortalCreator4, calcOffset(codeByteBuffer, labelTradeMode)));
// Recipient address is AT creator's address, so cancel offer and finish.
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund));
/* Switch to 'trade mode' */
labelTradeMode = codeByteBuffer.position();
// Send initial payment to recipient so they have enough funds to message AT if all goes well
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrInitialPayoutAmount));
// Calculate trade timeout refund 'timestamp' by adding addrTradeTimeout minutes to above message's 'timestamp', then save into addrTradeRefundTimestamp
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrTradeRefundTimestamp, addrLastTxTimestamp, addrTradeTimeout));
// Set restart position to after this opcode
codeByteBuffer.put(OpCode.SET_PCS.compile());
/* Loop, waiting for trade timeout or message from Qortal trade recipient containing secret */
// Fetch current block 'timestamp'
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp));
// If we're not past refund 'timestamp' then look for next transaction
codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrTradeRefundTimestamp, calcOffset(codeByteBuffer, labelTradeTxLoop)));
// We're past refund 'timestamp' so go refund everything back to AT creator
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund));
/* Transaction processing loop */
labelTradeTxLoop = codeByteBuffer.position();
// Find next transaction to this AT since the last one (if any)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp));
// If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0.
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
// If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTx)));
// Stop and wait for next block
codeByteBuffer.put(OpCode.STP_IMD.compile());
/* Check transaction */
labelCheckTradeTx = codeByteBuffer.position();
// Update our 'last found transaction's timestamp' using 'timestamp' from transaction
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp));
// Extract transaction type (message/payment) from transaction and save type in addrTxType
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType));
// If transaction type is not MESSAGE type then go look for another transaction
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelTradeTxLoop)));
/* Check transaction's sender */
// Extract sender address from transaction into B register
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
// Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
// Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction.
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalRecipient1, calcOffset(codeByteBuffer, labelTradeTxLoop)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalRecipient2, calcOffset(codeByteBuffer, labelTradeTxLoop)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalRecipient3, calcOffset(codeByteBuffer, labelTradeTxLoop)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalRecipient4, calcOffset(codeByteBuffer, labelTradeTxLoop)));
/* Check 'secret' in transaction's message */
// Extract message from transaction into B register
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
// Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer));
// Load B register with expected hash result (as pointed to by addrSecretHashPointer)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrSecretHashPointer));
// Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength).
// Save the equality result (1 if they match, 0 otherwise) into addrResult.
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength));
// If hashes don't match, addrResult will be zero so go find another transaction
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelTradeTxLoop)));
/* Success! Pay arranged amount to intended recipient */
// Load B register with intended recipient address (as pointed to by addrQortalRecipientPointer)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrQortalRecipientPointer));
// Pay AT's balance to recipient
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrRedeemPayoutAmount));
// Fall-through to refunding any remaining balance back to AT creator
/* Refund balance back to AT creator */
labelRefund = codeByteBuffer.position();
// Load B register with AT creator's address.
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B));
// Pay AT's balance back to AT's creator.
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PAY_ALL_TO_ADDRESS_IN_B));
// We're finished forever
codeByteBuffer.put(OpCode.FIN_IMD.compile());
} catch (CompilationException e) {
throw new IllegalStateException("Unable to compile BTC-QORT ACCT?", e);
}
}
codeByteBuffer.flip();
byte[] codeBytes = new byte[codeByteBuffer.limit()];
codeByteBuffer.get(codeBytes);
assert Arrays.equals(Crypto.digest(codeBytes), BTCACCT.CODE_BYTES_HASH)
: String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes)));
final short ciyamAtVersion = 2;
final short numCallStackPages = 0;
final short numUserStackPages = 0;
final long minActivationAmount = 0L;
return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount);
}
/**
* Returns CrossChainTradeData with useful info extracted from AT.
*
* @param repository
* @param atAddress
* @throws DataException
*/
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];
// Skip AT creator address
dataByteBuffer.position(dataByteBuffer.position() + 32);
// Hash of secret
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();
// Initial payout
tradeData.initialPayout = dataByteBuffer.getLong();
// Redeem payout
tradeData.redeemPayout = dataByteBuffer.getLong();
// Expected BTC amount
tradeData.expectedBitcoin = dataByteBuffer.getLong();
// Skip MESSAGE transaction type
dataByteBuffer.position(dataByteBuffer.position() + 8);
// Skip pointer to secretHash
dataByteBuffer.position(dataByteBuffer.position() + 8);
// Skip pointer to Qortal recipient
dataByteBuffer.position(dataByteBuffer.position() + 8);
// Skip pointer to message sender
dataByteBuffer.position(dataByteBuffer.position() + 8);
// Skip pointer to message data
dataByteBuffer.position(dataByteBuffer.position() + 8);
// Skip message data length
dataByteBuffer.position(dataByteBuffer.position() + 8);
// Qortal recipient (if any)
dataByteBuffer.get(addressBytes);
// Trade offer timeout (AT 'timestamp' converted to Qortal block height)
long tradeRefundTimestamp = dataByteBuffer.getLong();
if (tradeRefundTimestamp != 0) {
tradeData.mode = CrossChainTradeData.Mode.TRADE;
tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
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

@ -58,6 +58,18 @@ public class Crypto {
return Bytes.concat(digest, digest);
}
/** Returns RMD160(SHA256(data)) */
public static byte[] hash160(byte[] data) {
byte[] interim = digest(data);
try {
MessageDigest md160 = MessageDigest.getInstance("RIPEMD160");
return md160.digest(interim);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("RIPEMD160 message digest not available");
}
}
private static String toAddress(byte addressVersion, byte[] input) {
// SHA2-256 input to create new data and of known size
byte[] inputHash = digest(input);
@ -100,14 +112,15 @@ public class Crypto {
return isValidTypedAddress(address, ADDRESS_VERSION, AT_ADDRESS_VERSION);
}
public static boolean isValidAddress(byte[] addressBytes) {
return areValidTypedAddressBytes(addressBytes, ADDRESS_VERSION, AT_ADDRESS_VERSION);
}
public static boolean isValidAtAddress(String address) {
return isValidTypedAddress(address, AT_ADDRESS_VERSION);
}
private static boolean isValidTypedAddress(String address, byte...addressVersions) {
if (addressVersions == null || addressVersions.length == 0)
return false;
byte[] addressBytes;
try {
@ -117,6 +130,13 @@ public class Crypto {
return false;
}
return areValidTypedAddressBytes(addressBytes, addressVersions);
}
private static boolean areValidTypedAddressBytes(byte[] addressBytes, byte...addressVersions) {
if (addressVersions == null || addressVersions.length == 0)
return false;
// Check address length
if (addressBytes == null || addressBytes.length != Account.ADDRESS_LENGTH)
return false;

View File

@ -1,5 +1,11 @@
package org.qortal.data.at;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
// All properties to be converted to JSON via JAXB
@XmlAccessorType(XmlAccessType.FIELD)
public class ATData {
// Properties
@ -9,23 +15,30 @@ public class ATData {
private int version;
private long assetId;
private byte[] codeBytes;
private byte[] codeHash;
private boolean isSleeping;
private Integer sleepUntilHeight;
private boolean isFinished;
private boolean hadFatalError;
private boolean isFrozen;
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private Long frozenBalance;
// Constructors
public ATData(String ATAddress, byte[] creatorPublicKey, long creation, int version, long assetId, byte[] codeBytes, boolean isSleeping,
Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance) {
// necessary for JAXB serialization
protected ATData() {
}
public ATData(String ATAddress, byte[] creatorPublicKey, long creation, int version, long assetId, byte[] codeBytes, byte[] codeHash,
boolean isSleeping, Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance) {
this.ATAddress = ATAddress;
this.creatorPublicKey = creatorPublicKey;
this.creation = creation;
this.version = version;
this.assetId = assetId;
this.codeBytes = codeBytes;
this.codeHash = codeHash;
this.isSleeping = isSleeping;
this.sleepUntilHeight = sleepUntilHeight;
this.isFinished = isFinished;
@ -34,6 +47,14 @@ public class ATData {
this.frozenBalance = frozenBalance;
}
/** For constructing skeleton ATData with bare minimum info. */
public ATData(String ATAddress, byte[] creatorPublicKey, long creation, long assetId) {
this.ATAddress = ATAddress;
this.creatorPublicKey = creatorPublicKey;
this.creation = creation;
this.assetId = assetId;
}
// Getters / setters
public String getATAddress() {
@ -60,6 +81,10 @@ public class ATData {
return this.codeBytes;
}
public byte[] getCodeHash() {
return this.codeHash;
}
public boolean getIsSleeping() {
return this.isSleeping;
}

View File

@ -9,32 +9,38 @@ public class ATStateData {
private byte[] stateData;
private byte[] stateHash;
private Long fees;
private boolean isInitial;
// Constructors
/** Create new ATStateData */
public ATStateData(String ATAddress, Integer height, Long creation, byte[] stateData, byte[] stateHash, Long fees) {
public ATStateData(String ATAddress, Integer height, Long creation, byte[] stateData, byte[] stateHash, Long fees, boolean isInitial) {
this.ATAddress = ATAddress;
this.height = height;
this.creation = creation;
this.stateData = stateData;
this.stateHash = stateHash;
this.fees = fees;
this.isInitial = isInitial;
}
/** For recreating per-block ATStateData from repository where not all info is needed */
public ATStateData(String ATAddress, int height, byte[] stateHash, Long fees) {
this(ATAddress, height, null, null, stateHash, fees);
public ATStateData(String ATAddress, int height, byte[] stateHash, Long fees, boolean isInitial) {
this(ATAddress, height, null, null, stateHash, fees, isInitial);
}
/** For creating ATStateData from serialized bytes when we don't have all the info */
public ATStateData(String ATAddress, byte[] stateHash) {
this(ATAddress, null, null, null, stateHash, null);
// This won't ever be initial AT state from deployment as that's never serialized over the network,
// but generated when the DeployAtTransaction is processed locally.
this(ATAddress, null, null, null, stateHash, null, false);
}
/** For creating ATStateData from serialized bytes when we don't have all the info */
public ATStateData(String ATAddress, byte[] stateHash, Long fees) {
this(ATAddress, null, null, null, stateHash, fees);
// This won't ever be initial AT state from deployment as that's never serialized over the network,
// but generated when the DeployAtTransaction is processed locally.
this(ATAddress, null, null, null, stateHash, fees, false);
}
// Getters / setters
@ -68,4 +74,8 @@ public class ATStateData {
return this.fees;
}
public boolean isInitial() {
return this.isInitial;
}
}

View File

@ -0,0 +1,68 @@
package org.qortal.data.crosschain;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import io.swagger.v3.oas.annotations.media.Schema;
// All properties to be converted to JSON via JAXB
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainTradeData {
public enum Mode { OFFER, TRADE };
// Properties
@Schema(description = "AT's Qortal address")
public String qortalAtAddress;
@Schema(description = "AT creator's Qortal address")
public String qortalCreator;
@Schema(description = "Timestamp when AT was created (milliseconds since epoch)")
public long creationTimestamp;
@Schema(description = "AT's current QORT balance")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long qortBalance;
@Schema(description = "HASH160 of 32-byte secret")
public byte[] secretHash;
@Schema(description = "Initial QORT payment that will be sent to Qortal trade partner")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long initialPayout;
@Schema(description = "Final QORT payment that will be sent to Qortal trade partner")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long redeemPayout;
@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;
@Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)")
public Integer tradeRefundHeight;
@Schema(description = "Amount, in BTC, that AT creator expects Bitcoin P2SH to pay out (excluding miner fees)")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long expectedBitcoin;
public Mode mode;
@Schema(description = "Suggested Bitcoin P2SH nLockTime based on trade timeout")
public Integer lockTime;
// Constructors
// Necessary for JAXB
public CrossChainTradeData() {
}
}

View File

@ -18,6 +18,9 @@ public interface ATRepository {
/** Returns list of executable ATs, empty if none found */
public List<ATData> getAllExecutableATs() throws DataException;
/** Returns list of ATs with matching code hash, optionally executable only. */
public List<ATData> getATsByFunctionality(byte[] codeHash, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException;
/** Returns creation block height given AT's address or null if not found */
public Integer getATCreationBlockHeight(String atAddress) throws DataException;

View File

@ -1,10 +1,11 @@
package org.qortal.repository.hsqldb;
import static org.qortal.repository.hsqldb.HSQLDBRepository.getZonedTimestampMilli;
import static org.qortal.repository.hsqldb.HSQLDBRepository.toOffsetDateTime;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import org.qortal.data.at.ATData;
@ -24,33 +25,38 @@ public class HSQLDBATRepository implements ATRepository {
@Override
public ATData fromATAddress(String atAddress) throws DataException {
final String sql = "SELECT creator, creation, version, asset_id, code_bytes, is_sleeping, sleep_until_height, is_finished, had_fatal_error, is_frozen, frozen_balance FROM ATs WHERE AT_address = ?";
String sql = "SELECT creator, creation, version, asset_id, code_bytes, code_hash, "
+ "is_sleeping, sleep_until_height, is_finished, had_fatal_error, "
+ "is_frozen, frozen_balance "
+ "FROM ATs "
+ "WHERE AT_address = ? LIMIT 1";
try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress)) {
if (resultSet == null)
return null;
byte[] creatorPublicKey = resultSet.getBytes(1);
long creation = resultSet.getTimestamp(2, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
long creation = getZonedTimestampMilli(resultSet, 2);
int version = resultSet.getInt(3);
long assetId = resultSet.getLong(4);
byte[] codeBytes = resultSet.getBytes(5); // Actually BLOB
boolean isSleeping = resultSet.getBoolean(6);
byte[] codeHash = resultSet.getBytes(6);
boolean isSleeping = resultSet.getBoolean(7);
Integer sleepUntilHeight = resultSet.getInt(7);
Integer sleepUntilHeight = resultSet.getInt(8);
if (sleepUntilHeight == 0 && resultSet.wasNull())
sleepUntilHeight = null;
boolean isFinished = resultSet.getBoolean(8);
boolean hadFatalError = resultSet.getBoolean(9);
boolean isFrozen = resultSet.getBoolean(10);
boolean isFinished = resultSet.getBoolean(9);
boolean hadFatalError = resultSet.getBoolean(10);
boolean isFrozen = resultSet.getBoolean(11);
Long frozenBalance = resultSet.getLong(11);
if (frozenBalance == 0 && resultSet.wasNull())
frozenBalance = null;
return new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, isSleeping, sleepUntilHeight, isFinished, hadFatalError,
isFrozen, frozenBalance);
return new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, codeHash,
isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance);
} catch (SQLException e) {
throw new DataException("Unable to fetch AT from repository", e);
}
@ -67,9 +73,14 @@ public class HSQLDBATRepository implements ATRepository {
@Override
public List<ATData> getAllExecutableATs() throws DataException {
final String sql = "SELECT AT_address, creator, creation, version, asset_id, code_bytes, is_sleeping, sleep_until_height, had_fatal_error, is_frozen, frozen_balance FROM ATs WHERE is_finished = false ORDER BY creation ASC";
String sql = "SELECT AT_address, creator, creation, version, asset_id, code_bytes, code_hash, "
+ "is_sleeping, sleep_until_height, had_fatal_error, "
+ "is_frozen, frozen_balance "
+ "FROM ATs "
+ "WHERE is_finished = false "
+ "ORDER BY creation ASC";
List<ATData> executableATs = new ArrayList<ATData>();
List<ATData> executableATs = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql)) {
if (resultSet == null)
@ -80,25 +91,26 @@ public class HSQLDBATRepository implements ATRepository {
do {
String atAddress = resultSet.getString(1);
byte[] creatorPublicKey = resultSet.getBytes(2);
long creation = resultSet.getTimestamp(3, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
long creation = getZonedTimestampMilli(resultSet, 3);
int version = resultSet.getInt(4);
long assetId = resultSet.getLong(5);
byte[] codeBytes = resultSet.getBytes(6); // Actually BLOB
boolean isSleeping = resultSet.getBoolean(7);
byte[] codeHash = resultSet.getBytes(7);
boolean isSleeping = resultSet.getBoolean(8);
Integer sleepUntilHeight = resultSet.getInt(8);
Integer sleepUntilHeight = resultSet.getInt(9);
if (sleepUntilHeight == 0 && resultSet.wasNull())
sleepUntilHeight = null;
boolean hadFatalError = resultSet.getBoolean(9);
boolean isFrozen = resultSet.getBoolean(10);
boolean hadFatalError = resultSet.getBoolean(10);
boolean isFrozen = resultSet.getBoolean(11);
Long frozenBalance = resultSet.getLong(11);
if (frozenBalance == 0 && resultSet.wasNull())
frozenBalance = null;
ATData atData = new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, isSleeping, sleepUntilHeight, isFinished,
hadFatalError, isFrozen, frozenBalance);
ATData atData = new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, codeHash,
isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance);
executableATs.add(atData);
} while (resultSet.next());
@ -109,9 +121,72 @@ public class HSQLDBATRepository implements ATRepository {
}
}
@Override
public List<ATData> getATsByFunctionality(byte[] codeHash, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(512);
sql.append("SELECT AT_address, creator, creation, version, asset_id, code_bytes, ")
.append("is_sleeping, sleep_until_height, is_finished, had_fatal_error, ")
.append("is_frozen, frozen_balance ")
.append("FROM ATs ")
.append("WHERE code_hash = ? ");
if (isExecutable != null)
sql.append("AND is_finished = ").append(isExecutable ? "false" : "true");
sql.append(" ORDER BY creation ");
if (reverse != null && reverse)
sql.append("DESC");
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<ATData> matchingATs = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), codeHash)) {
if (resultSet == null)
return matchingATs;
do {
String atAddress = resultSet.getString(1);
byte[] creatorPublicKey = resultSet.getBytes(2);
long creation = getZonedTimestampMilli(resultSet, 3);
int version = resultSet.getInt(4);
long assetId = resultSet.getLong(5);
byte[] codeBytes = resultSet.getBytes(6); // Actually BLOB
boolean isSleeping = resultSet.getBoolean(7);
Integer sleepUntilHeight = resultSet.getInt(8);
if (sleepUntilHeight == 0 && resultSet.wasNull())
sleepUntilHeight = null;
boolean isFinished = resultSet.getBoolean(9);
boolean hadFatalError = resultSet.getBoolean(10);
boolean isFrozen = resultSet.getBoolean(11);
Long frozenBalance = resultSet.getLong(12);
if (frozenBalance == 0 && resultSet.wasNull())
frozenBalance = null;
ATData atData = new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, codeHash,
isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance);
matchingATs.add(atData);
} while (resultSet.next());
return matchingATs;
} catch (SQLException e) {
throw new DataException("Unable to fetch matching ATs from repository", e);
}
}
@Override
public Integer getATCreationBlockHeight(String atAddress) throws DataException {
final String sql = "SELECT height from DeployATTransactions JOIN BlockTransactions ON transaction_signature = signature JOIN Blocks ON Blocks.signature = block_signature WHERE AT_address = ?";
String sql = "SELECT height "
+ "FROM DeployATTransactions "
+ "JOIN BlockTransactions ON transaction_signature = signature "
+ "JOIN Blocks ON Blocks.signature = block_signature "
+ "WHERE AT_address = ? "
+ "LIMIT 1";
try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress)) {
if (resultSet == null)
@ -127,8 +202,9 @@ public class HSQLDBATRepository implements ATRepository {
public void save(ATData atData) throws DataException {
HSQLDBSaver saveHelper = new HSQLDBSaver("ATs");
saveHelper.bind("AT_address", atData.getATAddress()).bind("creator", atData.getCreatorPublicKey()).bind("creation", new Timestamp(atData.getCreation()))
.bind("version", atData.getVersion()).bind("asset_id", atData.getAssetId()).bind("code_bytes", atData.getCodeBytes())
saveHelper.bind("AT_address", atData.getATAddress()).bind("creator", atData.getCreatorPublicKey()).bind("creation", toOffsetDateTime(atData.getCreation()))
.bind("version", atData.getVersion()).bind("asset_id", atData.getAssetId())
.bind("code_bytes", atData.getCodeBytes()).bind("code_hash", atData.getCodeHash())
.bind("is_sleeping", atData.getIsSleeping()).bind("sleep_until_height", atData.getSleepUntilHeight())
.bind("is_finished", atData.getIsFinished()).bind("had_fatal_error", atData.getHadFatalError()).bind("is_frozen", atData.getIsFrozen())
.bind("frozen_balance", atData.getFrozenBalance());
@ -154,17 +230,22 @@ public class HSQLDBATRepository implements ATRepository {
@Override
public ATStateData getATStateAtHeight(String atAddress, int height) throws DataException {
try (ResultSet resultSet = this.repository
.checkedExecute("SELECT creation, state_data, state_hash, fees FROM ATStates WHERE AT_address = ? AND height = ?", atAddress, height)) {
String sql = "SELECT creation, state_data, state_hash, fees, is_initial "
+ "FROM ATStates "
+ "WHERE AT_address = ? AND height = ? "
+ "LIMIT 1";
try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress, height)) {
if (resultSet == null)
return null;
long creation = resultSet.getTimestamp(1, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
long creation = getZonedTimestampMilli(resultSet, 1);
byte[] stateData = resultSet.getBytes(2); // Actually BLOB
byte[] stateHash = resultSet.getBytes(3);
long fees = resultSet.getLong(4);
boolean isInitial = resultSet.getBoolean(5);
return new ATStateData(atAddress, height, creation, stateData, stateHash, fees);
return new ATStateData(atAddress, height, creation, stateData, stateHash, fees, isInitial);
} catch (SQLException e) {
throw new DataException("Unable to fetch AT state from repository", e);
}
@ -172,18 +253,24 @@ public class HSQLDBATRepository implements ATRepository {
@Override
public ATStateData getLatestATState(String atAddress) throws DataException {
try (ResultSet resultSet = this.repository
.checkedExecute("SELECT height, creation, state_data, state_hash, fees FROM ATStates WHERE AT_address = ? ORDER BY height DESC", atAddress)) {
String sql = "SELECT height, creation, state_data, state_hash, fees, is_initial "
+ "FROM ATStates "
+ "WHERE AT_address = ? "
+ "ORDER BY height DESC "
+ "LIMIT 1";
try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress)) {
if (resultSet == null)
return null;
int height = resultSet.getInt(1);
long creation = resultSet.getTimestamp(2, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
long creation = getZonedTimestampMilli(resultSet, 2);
byte[] stateData = resultSet.getBytes(3); // Actually BLOB
byte[] stateHash = resultSet.getBytes(4);
long fees = resultSet.getLong(5);
boolean isInitial = resultSet.getBoolean(6);
return new ATStateData(atAddress, height, creation, stateData, stateHash, fees);
return new ATStateData(atAddress, height, creation, stateData, stateHash, fees, isInitial);
} catch (SQLException e) {
throw new DataException("Unable to fetch latest AT state from repository", e);
}
@ -191,10 +278,14 @@ public class HSQLDBATRepository implements ATRepository {
@Override
public List<ATStateData> getBlockATStatesAtHeight(int height) throws DataException {
String sql = "SELECT AT_address, state_hash, fees, is_initial "
+ "FROM ATStates "
+ "WHERE height = ? "
+ "ORDER BY creation ASC";
List<ATStateData> atStates = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute("SELECT AT_address, state_hash, fees FROM ATStates WHERE height = ? ORDER BY creation ASC",
height)) {
try (ResultSet resultSet = this.repository.checkedExecute(sql, height)) {
if (resultSet == null)
return atStates; // No atStates in this block
@ -203,8 +294,9 @@ public class HSQLDBATRepository implements ATRepository {
String atAddress = resultSet.getString(1);
byte[] stateHash = resultSet.getBytes(2);
long fees = resultSet.getLong(3);
boolean isInitial = resultSet.getBoolean(4);
ATStateData atStateData = new ATStateData(atAddress, height, stateHash, fees);
ATStateData atStateData = new ATStateData(atAddress, height, stateHash, fees, isInitial);
atStates.add(atStateData);
} while (resultSet.next());
} catch (SQLException e) {
@ -223,8 +315,9 @@ public class HSQLDBATRepository implements ATRepository {
HSQLDBSaver saveHelper = new HSQLDBSaver("ATStates");
saveHelper.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight())
.bind("creation", new Timestamp(atStateData.getCreation())).bind("state_data", atStateData.getStateData())
.bind("state_hash", atStateData.getStateHash()).bind("fees", atStateData.getFees());
.bind("creation", toOffsetDateTime(atStateData.getCreation())).bind("state_data", atStateData.getStateData())
.bind("state_hash", atStateData.getStateHash()).bind("fees", atStateData.getFees())
.bind("is_initial", atStateData.isInitial());
try {
saveHelper.execute(this.repository);

View File

@ -976,6 +976,16 @@ public class HSQLDBDatabaseUpdates {
stmt.execute("CHECKPOINT DEFRAG");
break;
case 71:
// Add flag to AT state data to indicate 'initial deployment state'
stmt.execute("ALTER TABLE ATStates ADD COLUMN is_initial BOOLEAN NOT NULL DEFAULT TRUE");
break;
case 72:
// For ATs, add hash of code bytes to allow searching for specific function ATs, e.g. cross-chain trading
stmt.execute("ALTER TABLE ATs ADD COLUMN code_hash VARBINARY(32) NOT NULL BEFORE is_sleeping"); // Assuming something like SHA256
break;
default:
// nothing to do
return false;

View File

@ -20,6 +20,7 @@ import org.eclipse.persistence.exceptions.XMLMarshalException;
import org.eclipse.persistence.jaxb.JAXBContextFactory;
import org.eclipse.persistence.jaxb.UnmarshallerProperties;
import org.qortal.block.BlockChain;
import org.qortal.crosschain.BTC.BitcoinNet;
// All properties to be converted to JSON via JAXB
@XmlAccessorType(XmlAccessType.FIELD)
@ -91,7 +92,7 @@ public class Settings {
// Which blockchains this node is running
private String blockchainConfig = null; // use default from resources
private boolean useBitcoinTestNet = false;
private BitcoinNet bitcoinNet = BitcoinNet.MAIN;
// Repository related
/** Queries that take longer than this are logged. (milliseconds) */
@ -345,8 +346,8 @@ public class Settings {
return this.blockchainConfig;
}
public boolean useBitcoinTestNet() {
return this.useBitcoinTestNet;
public BitcoinNet getBitcoinNet() {
return this.bitcoinNet;
}
public Long getSlowQueryThreshold() {

View File

@ -145,6 +145,8 @@ public class AtTransaction extends Transaction {
@Override
public void processReferencesAndFees() throws DataException {
getATAccount().setLastReference(this.atTransactionData.getSignature());
if (this.atTransactionData.getAmount() != null) {
Account recipient = getRecipient();
long assetId = this.atTransactionData.getAssetId();
@ -152,7 +154,7 @@ public class AtTransaction extends Transaction {
// For QORT amounts only: if recipient has no reference yet, then this is their starting reference
if (assetId == Asset.QORT && recipient.getLastReference() == null)
// In Qora1 last reference was set to 64-bytes of zero
// In Qortal we use AT-Transction's signature, which makes more sense
// In Qortal we use AT-Transaction's signature, which makes more sense
recipient.setLastReference(this.atTransactionData.getSignature());
}
}
@ -179,6 +181,8 @@ public class AtTransaction extends Transaction {
@Override
public void orphanReferencesAndFees() throws DataException {
getATAccount().setLastReference(this.atTransactionData.getReference());
if (this.atTransactionData.getAmount() != null) {
Account recipient = getRecipient();

View File

@ -1,23 +1,25 @@
package org.qortal.transaction;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import org.ciyam.at.MachineState;
import org.ciyam.at.Timestamp;
import org.qortal.account.Account;
import org.qortal.asset.Asset;
import org.qortal.at.AT;
import org.qortal.at.QortalATAPI;
import org.qortal.at.QortalAtLoggerFactory;
import org.qortal.crypto.Crypto;
import org.qortal.data.asset.AssetData;
import org.qortal.data.at.ATData;
import org.qortal.data.transaction.DeployAtTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.transform.Transformer;
import org.qortal.transform.TransformationException;
import org.qortal.utils.Amounts;
import org.qortal.transform.transaction.TransactionTransformer;
import com.google.common.base.Utf8;
@ -51,7 +53,7 @@ public class DeployAtTransaction extends Transaction {
/** Returns AT version from the header bytes */
private short getVersion() {
byte[] creationBytes = deployATTransactionData.getCreationBytes();
return (short) ((creationBytes[0] & 0xff) | (creationBytes[1] << 8)); // Little-endian
return (short) ((creationBytes[0] << 8) | (creationBytes[1] & 0xff)); // Big-endian
}
/** Make sure deployATTransactionData has an ATAddress */
@ -59,29 +61,14 @@ public class DeployAtTransaction extends Transaction {
if (this.deployATTransactionData.getAtAddress() != null)
return;
int blockHeight = this.getHeight();
if (blockHeight == 0)
blockHeight = this.repository.getBlockRepository().getBlockchainHeight() + 1;
byte[] name = this.deployATTransactionData.getName().getBytes(StandardCharsets.UTF_8);
byte[] description = this.deployATTransactionData.getDescription().replaceAll("\\s", "").getBytes(StandardCharsets.UTF_8);
byte[] creatorPublicKey = this.deployATTransactionData.getCreatorPublicKey();
byte[] creationBytes = this.deployATTransactionData.getCreationBytes();
ByteBuffer byteBuffer = ByteBuffer
.allocate(name.length + description.length + creatorPublicKey.length + creationBytes.length + Transformer.INT_LENGTH);
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
byteBuffer.put(name);
byteBuffer.put(description);
byteBuffer.put(creatorPublicKey);
byteBuffer.put(creationBytes);
byteBuffer.putInt(blockHeight);
String atAddress = Crypto.toATAddress(byteBuffer.array());
this.deployATTransactionData.setAtAddress(atAddress);
// Use transaction transformer
try {
String atAddress = Crypto.toATAddress(TransactionTransformer.toBytesForSigning(this.deployATTransactionData));
this.deployATTransactionData.setAtAddress(atAddress);
return;
} catch (TransformationException e) {
throw new DataException("Unable to generate AT address");
}
}
// Navigation
@ -154,8 +141,22 @@ public class DeployAtTransaction extends Transaction {
// Check creation bytes are valid (for v2+)
if (this.getVersion() >= 2) {
// Do actual validation
ensureATAddress();
// Just enough AT data to allow API to query initial balances, etc.
String atAddress = this.deployATTransactionData.getAtAddress();
byte[] creatorPublicKey = this.deployATTransactionData.getCreatorPublicKey();
long creation = this.deployATTransactionData.getTimestamp();
ATData skeletonAtData = new ATData(atAddress, creatorPublicKey, creation, assetId);
int height = this.repository.getBlockRepository().getBlockchainHeight() + 1;
long blockTimestamp = Timestamp.toLong(height, 0);
QortalATAPI api = new QortalATAPI(repository, skeletonAtData, blockTimestamp);
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
try {
new MachineState(this.deployATTransactionData.getCreationBytes());
new MachineState(api, loggerFactory, this.deployATTransactionData.getCreationBytes());
} catch (IllegalArgumentException e) {
// Not valid
return ValidationResult.INVALID_CREATION_BYTES;

View File

@ -980,11 +980,16 @@ public abstract class Transaction {
// AT transactions come before non-AT transactions
if (td1.getType() == TransactionType.AT && td2.getType() != TransactionType.AT)
return -1;
// Non-AT transactions come after AT transactions
if (td1.getType() != TransactionType.AT && td2.getType() == TransactionType.AT)
return 1;
// Both transactions are either AT or non-AT so compare timestamps
// If both transactions are AT type, then preserve existing ordering.
if (td1.getType() == TransactionType.AT)
return 0;
// Both transactions are non-AT so compare timestamps
int result = Long.compare(td1.getTimestamp(), td2.getTimestamp());
if (result == 0)

View File

@ -138,6 +138,9 @@ public class BlockTransformer extends Transformer {
atStates.add(new ATStateData(atAddress, stateHash, fees));
}
// Bump byteBuffer over AT states just read in slice
byteBuffer.position(byteBuffer.position() + atBytesLength);
// AT count to reflect the number of states we have
atCount = atStates.size();
@ -264,6 +267,10 @@ public class BlockTransformer extends Transformer {
bytes.write(Ints.toByteArray(atBytesLength));
for (ATStateData atStateData : block.getATStates()) {
// Skip initial states generated by DEPLOY_AT transactions in the same block
if (atStateData.isInitial())
continue;
bytes.write(Base58.decode(atStateData.getATAddress()));
bytes.write(atStateData.getStateHash());
bytes.write(Longs.toByteArray(atStateData.getFees()));
@ -273,6 +280,10 @@ public class BlockTransformer extends Transformer {
bytes.write(Ints.toByteArray(blockData.getTransactionCount()));
for (Transaction transaction : block.getTransactions()) {
// Don't serialize AT transactions!
if (transaction.getTransactionData().getType() == TransactionType.AT)
continue;
TransactionData transactionData = transaction.getTransactionData();
bytes.write(Ints.toByteArray(TransactionTransformer.getDataLength(transactionData)));
bytes.write(TransactionTransformer.toBytes(transactionData));

View File

@ -21,4 +21,9 @@ public class BitTwiddling {
return maxValue;
}
/** Convert int to little-endian byte array */
public static byte[] toLEByteArray(int value) {
return new byte[] { (byte) (value), (byte) (value >> 8), (byte) (value >> 16), (byte) (value >> 24) };
}
}

View File

@ -40,6 +40,12 @@
"blockTimingsByHeight": [
{ "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 }
],
"ciyamAtSettings": {
"feePerStep": "0.0001",
"maxStepsPerRound": 500,
"stepsPerFunctionCall": 10,
"minutesPerBlock": 1
},
"featureTriggers": {
"messageHeight": 0,
"atHeight": 0,

View File

@ -70,11 +70,11 @@ public class BTCACCTTests {
// For when we want to re-run
private static final byte[] prevSecret = HashCode.fromString("30a13291e350214bea5318f990b77bc11d2cb709f7c39859f248bef396961dcc").asBytes();
private static final long prevLockTime = 1539347892L;
private static final boolean usePreviousFundingTx = true;
private static final boolean usePreviousFundingTx = false;
private static final boolean doRefundNotRedeem = false;
public void main(String[] args) throws NoSuchAlgorithmException, InsufficientMoneyException, InterruptedException, ExecutionException, UnknownHostException {
public static void main(String[] args) throws NoSuchAlgorithmException, InsufficientMoneyException, InterruptedException, ExecutionException, UnknownHostException {
Security.insertProviderAt(new BouncyCastleProvider(), 0);
byte[] secret = new byte[32];
@ -173,7 +173,7 @@ public class BTCACCTTests {
private static final byte[] redeemScript4 = HashCode.fromString("b17576a914").asBytes();
private static final byte[] redeemScript5 = HashCode.fromString("88ac68").asBytes();
private byte[] buildRedeemScript(byte[] secret, byte[] senderPubKey, byte[] recipientPubKey, long lockTime) {
private static byte[] buildRedeemScript(byte[] secret, byte[] senderPubKey, byte[] recipientPubKey, long lockTime) {
try {
MessageDigest sha256Digester = MessageDigest.getInstance("SHA-256");
@ -188,7 +188,7 @@ public class BTCACCTTests {
}
}
private byte[] hash160(byte[] input) {
private static byte[] hash160(byte[] input) {
try {
MessageDigest rmd160Digester = MessageDigest.getInstance("RIPEMD160");
MessageDigest sha256Digester = MessageDigest.getInstance("SHA-256");
@ -199,7 +199,7 @@ public class BTCACCTTests {
}
}
private Transaction buildFundingTransaction(NetworkParameters params, Sha256Hash prevTxHash, long outputIndex, Coin balance, ECKey sigKey, Coin value,
private static Transaction buildFundingTransaction(NetworkParameters params, Sha256Hash prevTxHash, long outputIndex, Coin balance, ECKey sigKey, Coin value,
byte[] redeemScriptHash) {
Transaction fundingTransaction = new Transaction(params);
@ -218,7 +218,7 @@ public class BTCACCTTests {
return fundingTransaction;
}
private Transaction buildRedeemTransaction(NetworkParameters params, TransactionOutPoint fundingOutPoint, ECKey recipientKey, Coin value, byte[] secret,
private static Transaction buildRedeemTransaction(NetworkParameters params, TransactionOutPoint fundingOutPoint, ECKey recipientKey, Coin value, byte[] secret,
byte[] redeemScriptBytes) {
Transaction redeemTransaction = new Transaction(params);
redeemTransaction.setVersion(2);
@ -255,7 +255,7 @@ public class BTCACCTTests {
return redeemTransaction;
}
private Transaction buildRefundTransaction(NetworkParameters params, TransactionOutPoint fundingOutPoint, ECKey senderKey, Coin value,
private static Transaction buildRefundTransaction(NetworkParameters params, TransactionOutPoint fundingOutPoint, ECKey senderKey, Coin value,
byte[] redeemScriptBytes, long lockTime) {
Transaction refundTransaction = new Transaction(params);
refundTransaction.setVersion(2);
@ -294,7 +294,7 @@ public class BTCACCTTests {
return refundTransaction;
}
private void broadcastWithConfirmation(WalletAppKit kit, Transaction transaction) {
private static void broadcastWithConfirmation(WalletAppKit kit, Transaction transaction) {
System.out.println("Broadcasting tx: " + transaction.getTxId().toString());
System.out.println("TX hex: " + HashCode.fromBytes(transaction.bitcoinSerialize()).toString());
@ -320,7 +320,7 @@ public class BTCACCTTests {
}
/** Convert int to little-endian byte array */
private byte[] toLEByteArray(int value) {
private static byte[] toLEByteArray(int value) {
return new byte[] { (byte) (value), (byte) (value >> 8), (byte) (value >> 16), (byte) (value >> 24) };
}

View File

@ -0,0 +1,67 @@
package org.qortal.test.apps;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.TreeMap;
import org.bitcoinj.core.BlockChain;
import org.bitcoinj.core.CheckpointManager;
import org.bitcoinj.core.NetworkParameters;
import org.bitcoinj.core.PeerAddress;
import org.bitcoinj.core.PeerGroup;
import org.bitcoinj.core.StoredBlock;
import org.bitcoinj.params.RegTestParams;
import org.bitcoinj.store.BlockStore;
import org.bitcoinj.store.MemoryBlockStore;
public class BuildCheckpoints {
private static final TreeMap<Integer, StoredBlock> checkpoints = new TreeMap<>();
public static void main(String[] args) throws Exception {
final NetworkParameters params = RegTestParams.get();
final BlockStore store = new MemoryBlockStore(params);
final BlockChain chain = new BlockChain(params, store);
final PeerGroup peerGroup = new PeerGroup(params, chain);
final InetAddress ipAddress = InetAddress.getLoopbackAddress();
final PeerAddress peerAddress = new PeerAddress(params, ipAddress);
peerGroup.addAddress(peerAddress);
peerGroup.start();
chain.addNewBestBlockListener((block) -> checkpoints.put(block.getHeight(), block));
peerGroup.downloadBlockChain();
peerGroup.stop();
final File checkpointsFile = new File("regtest-checkpoints");
saveAsText(checkpointsFile);
}
private static void saveAsText(File textFile) {
try (PrintWriter writer = new PrintWriter(
new OutputStreamWriter(new FileOutputStream(textFile), StandardCharsets.US_ASCII))) {
writer.println("TXT CHECKPOINTS 1");
writer.println("0"); // Number of signatures to read. Do this later.
writer.println(checkpoints.size());
ByteBuffer buffer = ByteBuffer.allocate(StoredBlock.COMPACT_SERIALIZED_SIZE);
for (StoredBlock block : checkpoints.values()) {
block.serializeCompact(buffer);
writer.println(CheckpointManager.BASE64.encode(buffer.array()));
buffer.position(0);
}
} catch (FileNotFoundException e) {
return;
}
}
}

View File

@ -13,17 +13,23 @@ import org.qortal.settings.Settings;
public class orphan {
public static void main(String[] args) {
if (args.length == 0) {
System.err.println("usage: orphan <new-blockchain-tip-height>");
if (args.length < 1 || args.length > 2) {
System.err.println("usage: orphan [<settings-file>] <new-blockchain-tip-height>");
System.exit(1);
}
int targetHeight = Integer.parseInt(args[0]);
Security.insertProviderAt(new BouncyCastleProvider(), 0);
// Load/check settings, which potentially sets up blockchain config, etc.
Settings.getInstance();
int argIndex = 0;
if (args.length > 1) {
Settings.fileInstance(args[argIndex++]);
} else {
// Load/check settings, which potentially sets up blockchain config, etc.
Settings.getInstance();
}
int targetHeight = Integer.parseInt(args[argIndex]);
try {
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());

View File

@ -0,0 +1,552 @@
package org.qortal.test.btcacct;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import org.bitcoinj.core.Base58;
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.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;
import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.group.Group;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.test.common.BlockUtils;
import org.qortal.test.common.Common;
import org.qortal.test.common.TransactionUtils;
import org.qortal.transaction.DeployAtTransaction;
import org.qortal.transaction.MessageTransaction;
import org.qortal.utils.Amounts;
import com.google.common.hash.HashCode;
import com.google.common.primitives.Bytes;
public class AtTests extends Common {
public static final byte[] secret = "This string is exactly 32 bytes!".getBytes();
public static final byte[] secretHash = Crypto.hash160(secret); // daf59884b4d1aec8c1b17102530909ee43c0151a
public static final int refundTimeout = 10; // blocks
public static final long initialPayout = 100000L;
public static final long redeemAmount = 80_40200000L;
public static final long fundingAmount = 123_45600000L;
public static final long bitcoinAmount = 864200L;
@Before
public void beforeTest() throws DataException {
Common.useDefaultSettings();
}
@Test
public void testCompile() {
Account deployer = Common.getTestAccount(null, "chloe");
byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount);
System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString());
}
@Test
public void testDeploy() throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe");
PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert");
long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT);
long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT);
DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer);
long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee();
long actualBalance = deployer.getConfirmedBalance(Asset.QORT);
assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance);
expectedBalance = fundingAmount;
actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT);
assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance);
expectedBalance = recipientsInitialBalance;
actualBalance = recipient.getConfirmedBalance(Asset.QORT);
assertEquals("Recipient's post-deployment balance incorrect", expectedBalance, actualBalance);
// Test orphaning
BlockUtils.orphanLastBlock(repository);
expectedBalance = deployersInitialBalance;
actualBalance = deployer.getConfirmedBalance(Asset.QORT);
assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance);
expectedBalance = 0;
actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT);
assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance);
expectedBalance = recipientsInitialBalance;
actualBalance = recipient.getConfirmedBalance(Asset.QORT);
assertEquals("Recipient's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance);
}
}
@SuppressWarnings("unused")
@Test
public void testOfferCancel() throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe");
PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert");
long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT);
long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT);
DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer);
Account at = deployAtTransaction.getATAccount();
String atAddress = at.getAddress();
long deployAtFee = deployAtTransaction.getTransactionData().getFee();
long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee;
// Send creator's address to AT
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(deployer.getAddress()), 32, 0);
MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress);
long messageFee = messageTransaction.getTransactionData().getFee();
// Refund should happen 1st block after receiving recipient address
BlockUtils.mintBlock(repository);
long expectedMinimumBalance = deployersPostDeploymentBalance;
long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee;
long actualBalance = deployer.getConfirmedBalance(Asset.QORT);
assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance);
assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance);
describeAt(repository, atAddress);
// Test orphaning
BlockUtils.orphanLastBlock(repository);
long expectedBalance = deployersPostDeploymentBalance - messageFee;
actualBalance = deployer.getConfirmedBalance(Asset.QORT);
assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance);
}
}
@SuppressWarnings("unused")
@Test
public void testInitialPayment() throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe");
PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert");
long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT);
long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT);
DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer);
Account at = deployAtTransaction.getATAccount();
String atAddress = at.getAddress();
// Send recipient's address to AT
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0);
MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress);
// Initial payment should happen 1st block after receiving recipient address
BlockUtils.mintBlock(repository);
long expectedBalance = recipientsInitialBalance + initialPayout;
long actualBalance = recipient.getConfirmedBalance(Asset.QORT);
assertEquals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance);
describeAt(repository, atAddress);
// Test orphaning
BlockUtils.orphanLastBlock(repository);
expectedBalance = recipientsInitialBalance;
actualBalance = recipient.getConfirmedBalance(Asset.QORT);
assertEquals("Recipient's pre-initial-payout balance incorrect", expectedBalance, actualBalance);
}
}
// TEST SENDING RECIPIENT ADDRESS BUT NOT FROM AT CREATOR (SHOULD BE IGNORED)
@SuppressWarnings("unused")
@Test
public void testIncorrectTradeSender() throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe");
PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert");
PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob");
long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT);
long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT);
DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer);
Account at = deployAtTransaction.getATAccount();
String atAddress = at.getAddress();
// Send recipient's address to AT BUT NOT FROM AT CREATOR
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0);
MessageTransaction messageTransaction = sendMessage(repository, bystander, recipientAddressBytes, atAddress);
// Initial payment should NOT happen
BlockUtils.mintBlock(repository);
long expectedBalance = recipientsInitialBalance;
long actualBalance = recipient.getConfirmedBalance(Asset.QORT);
assertEquals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance);
describeAt(repository, atAddress);
}
}
@SuppressWarnings("unused")
@Test
public void testAutomaticTradeRefund() throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe");
PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert");
long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT);
long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT);
DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer);
Account at = deployAtTransaction.getATAccount();
String atAddress = at.getAddress();
// Send recipient's address to AT
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0);
MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress);
// Initial payment should happen 1st block after receiving recipient address
BlockUtils.mintBlock(repository);
long deployAtFee = deployAtTransaction.getTransactionData().getFee();
long messageFee = messageTransaction.getTransactionData().getFee();
long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee - messageFee;
checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee);
describeAt(repository, atAddress);
// Test orphaning
BlockUtils.orphanLastBlock(repository);
BlockUtils.orphanLastBlock(repository);
long expectedBalance = deployersPostDeploymentBalance;
long actualBalance = deployer.getConfirmedBalance(Asset.QORT);
assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance);
}
}
@SuppressWarnings("unused")
@Test
public void testCorrectSecretCorrectSender() throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe");
PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert");
long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT);
long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT);
DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer);
Account at = deployAtTransaction.getATAccount();
String atAddress = at.getAddress();
// Send recipient's address to AT
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0);
MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress);
// Initial payment should happen 1st block after receiving recipient address
BlockUtils.mintBlock(repository);
// Send correct secret to AT
messageTransaction = sendMessage(repository, recipient, secret, atAddress);
// AT should send funds in the next block
ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress);
BlockUtils.mintBlock(repository);
long expectedBalance = recipientsInitialBalance + initialPayout - messageTransaction.getTransactionData().getFee() + redeemAmount;
long actualBalance = recipient.getConfirmedBalance(Asset.QORT);
assertEquals("Recipent's post-redeem balance incorrect", expectedBalance, actualBalance);
describeAt(repository, atAddress);
// Orphan redeem
BlockUtils.orphanLastBlock(repository);
expectedBalance = recipientsInitialBalance + initialPayout - messageTransaction.getTransactionData().getFee();
actualBalance = recipient.getConfirmedBalance(Asset.QORT);
assertEquals("Recipent's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance);
// Check AT state
ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress);
assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData()));
}
}
@SuppressWarnings("unused")
@Test
public void testCorrectSecretIncorrectSender() throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe");
PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert");
PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob");
long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT);
long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT);
DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer);
long deployAtFee = deployAtTransaction.getTransactionData().getFee();
Account at = deployAtTransaction.getATAccount();
String atAddress = at.getAddress();
// Send recipient's address to AT
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0);
MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress);
// Initial payment should happen 1st block after receiving recipient address
BlockUtils.mintBlock(repository);
// Send correct secret to AT, but from wrong account
messageTransaction = sendMessage(repository, bystander, secret, atAddress);
// AT should NOT send funds in the next block
ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress);
BlockUtils.mintBlock(repository);
long expectedBalance = recipientsInitialBalance + initialPayout;
long actualBalance = recipient.getConfirmedBalance(Asset.QORT);
assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance);
describeAt(repository, atAddress);
checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee);
}
}
@SuppressWarnings("unused")
@Test
public void testIncorrectSecretCorrectSender() throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe");
PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert");
long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT);
long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT);
DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer);
long deployAtFee = deployAtTransaction.getTransactionData().getFee();
Account at = deployAtTransaction.getATAccount();
String atAddress = at.getAddress();
// Send recipient's address to AT
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0);
MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress);
// Initial payment should happen 1st block after receiving recipient address
BlockUtils.mintBlock(repository);
// Send correct secret to AT, but from wrong account
byte[] wrongSecret = Crypto.digest(secret);
messageTransaction = sendMessage(repository, recipient, wrongSecret, atAddress);
// AT should NOT send funds in the next block
ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress);
BlockUtils.mintBlock(repository);
long expectedBalance = recipientsInitialBalance + initialPayout - messageTransaction.getTransactionData().getFee();
long actualBalance = recipient.getConfirmedBalance(Asset.QORT);
assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance);
describeAt(repository, atAddress);
checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee);
}
}
@SuppressWarnings("unused")
@Test
public void testDescribeDeployed() throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe");
PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert");
long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT);
long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT);
DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer);
List<ATData> executableAts = repository.getATRepository().getAllExecutableATs();
for (ATData atData : executableAts) {
String atAddress = atData.getATAddress();
byte[] codeBytes = atData.getCodeBytes();
byte[] codeHash = Crypto.digest(codeBytes);
System.out.println(String.format("%s: code length: %d byte%s, code hash: %s",
atAddress,
codeBytes.length,
(codeBytes.length != 1 ? "s": ""),
HashCode.fromBytes(codeHash)));
// Not one of ours?
if (!Arrays.equals(codeHash, BTCACCT.CODE_BYTES_HASH))
continue;
describeAt(repository, atAddress);
}
}
}
private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException {
byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount);
long txTimestamp = System.currentTimeMillis();
byte[] lastReference = deployer.getLastReference();
if (lastReference == null) {
System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress()));
System.exit(2);
}
Long fee = null;
String name = "QORT-BTC cross-chain trade";
String description = String.format("Qortal-Bitcoin cross-chain trade");
String atType = "ACCT";
String tags = "QORT-BTC ACCT";
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null);
TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT);
DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
fee = deployAtTransaction.calcRecommendedFee();
deployAtTransactionData.setFee(fee);
TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer);
return deployAtTransaction;
}
private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException {
long txTimestamp = System.currentTimeMillis();
byte[] lastReference = sender.getLastReference();
if (lastReference == null) {
System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress()));
System.exit(2);
}
Long fee = null;
long amount = 0;
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null);
TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, 4, recipient, Asset.QORT, amount, data, false, false);
MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData);
fee = messageTransaction.calcRecommendedFee();
messageTransactionData.setFee(fee);
TransactionUtils.signAndMint(repository, messageTransactionData, sender);
return messageTransaction;
}
private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException {
long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee;
// AT should automatically refund deployer after 'refundTimeout' blocks
for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount)
BlockUtils.mintBlock(repository);
// We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range
long expectedMinimumBalance = deployersPostDeploymentBalance;
long expectedMaximumBalance = deployersInitialBalance - deployAtFee - initialPayout;
long actualBalance = deployer.getConfirmedBalance(Asset.QORT);
assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance);
assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance);
}
private void describeAt(Repository repository, String atAddress) throws DataException {
ATData atData = repository.getATRepository().fromATAddress(atAddress);
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();
System.out.print(String.format("%s:\n"
+ "\tcreator: %s,\n"
+ "\tcreation timestamp: %s,\n"
+ "\tcurrent balance: %s QORT,\n"
+ "\tHASH160 of secret: %s,\n"
+ "\tinitial payout: %s QORT,\n"
+ "\tredeem payout: %s QORT,\n"
+ "\texpected bitcoin: %s BTC,\n"
+ "\ttrade timeout: %d minutes (from trade start),\n"
+ "\tcurrent block height: %d,\n",
tradeData.qortalAtAddress,
tradeData.qortalCreator,
epochMilliFormatter.apply(tradeData.creationTimestamp),
Amounts.prettyAmount(tradeData.qortBalance),
HashCode.fromBytes(tradeData.secretHash).toString().substring(0, 40),
Amounts.prettyAmount(tradeData.initialPayout),
Amounts.prettyAmount(tradeData.redeemPayout),
Amounts.prettyAmount(tradeData.expectedBitcoin),
tradeData.tradeRefundTimeout,
currentBlockHeight));
// Are we in 'offer' or 'trade' stage?
if (tradeData.tradeRefundHeight == null) {
// Offer
System.out.println(String.format("\tstatus: 'offer mode'"));
} else {
// 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

@ -0,0 +1,125 @@
package org.qortal.test.btcacct;
import java.security.Security;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.LegacyAddress;
import org.bitcoinj.core.NetworkParameters;
import org.bitcoinj.script.Script.ScriptType;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.qortal.controller.Controller;
import org.qortal.crosschain.BTC;
import org.qortal.crosschain.BTCACCT;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryFactory;
import org.qortal.repository.RepositoryManager;
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
import org.qortal.settings.Settings;
import com.google.common.hash.HashCode;
public class BuildP2SH {
private static void usage(String error) {
if (error != null)
System.err.println(error);
System.err.println(String.format("usage: BuildP2SH <refund-BTC-P2PKH> <BTC-amount> <redeem-BTC-P2PKH> <HASH160-of-secret> <locktime> (<BTC-redeem/refund-fee>)"));
System.err.println(String.format("example: BuildP2SH "
+ "mrTDPdM15cFWJC4g223BXX5snicfVJBx6M \\\n"
+ "\t0.00008642 \\\n"
+ "\tn2N5VKrzq39nmuefZwp3wBiF4icdXX2B6o \\\n"
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
+ "\t1585920000"));
System.exit(1);
}
public static void main(String[] args) {
if (args.length < 5 || args.length > 6)
usage(null);
Security.insertProviderAt(new BouncyCastleProvider(), 0);
Settings.fileInstance("settings-test.json");
BTC btc = BTC.getInstance();
NetworkParameters params = btc.getNetworkParameters();
Address refundBitcoinAddress = null;
Coin bitcoinAmount = null;
Address redeemBitcoinAddress = null;
byte[] secretHash = null;
int lockTime = 0;
Coin bitcoinFee = Common.DEFAULT_BTC_FEE;
int argIndex = 0;
try {
refundBitcoinAddress = Address.fromString(params, args[argIndex++]);
if (refundBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
usage("Refund BTC address must be in P2PKH form");
bitcoinAmount = Coin.parseCoin(args[argIndex++]);
redeemBitcoinAddress = Address.fromString(params, args[argIndex++]);
if (redeemBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
usage("Redeem BTC address must be in P2PKH form");
secretHash = HashCode.fromString(args[argIndex++]).asBytes();
if (secretHash.length != 20)
usage("Hash of secret must be 20 bytes");
lockTime = Integer.parseInt(args[argIndex++]);
int refundTimeoutDelay = lockTime - (int) (System.currentTimeMillis() / 1000L);
if (refundTimeoutDelay < 600 || refundTimeoutDelay > 30 * 24 * 60 * 60)
usage("Locktime (seconds) should be at between 10 minutes and 1 month from now");
if (args.length > argIndex)
bitcoinFee = Coin.parseCoin(args[argIndex++]);
} catch (IllegalArgumentException e) {
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
}
try {
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
RepositoryManager.setRepositoryFactory(repositoryFactory);
} catch (DataException e) {
throw new RuntimeException("Repository startup issue: " + e.getMessage());
}
try (final Repository repository = RepositoryManager.getRepository()) {
System.out.println("Confirm the following is correct based on the info you've given:");
System.out.println(String.format("Refund Bitcoin address: %s", refundBitcoinAddress));
System.out.println(String.format("Bitcoin redeem amount: %s", bitcoinAmount.toPlainString()));
System.out.println(String.format("Redeem Bitcoin address: %s", redeemBitcoinAddress));
System.out.println(String.format("Redeem miner's fee: %s", BTC.FORMAT.format(bitcoinFee)));
System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime));
System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(secretHash)));
byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash);
System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes)));
byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes);
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
System.out.println(String.format("P2SH address: %s", p2shAddress));
bitcoinAmount = bitcoinAmount.add(bitcoinFee);
// Fund P2SH
System.out.println(String.format("\nYou need to fund %s with %s (includes redeem/refund fee of %s)",
p2shAddress.toString(), BTC.FORMAT.format(bitcoinAmount), BTC.FORMAT.format(bitcoinFee)));
System.out.println("Once this is done, responder should run Respond to check P2SH funding and create AT");
} catch (DataException e) {
throw new RuntimeException("Repository issue: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,172 @@
package org.qortal.test.btcacct;
import java.security.Security;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.LegacyAddress;
import org.bitcoinj.core.NetworkParameters;
import org.bitcoinj.core.TransactionOutput;
import org.bitcoinj.script.Script.ScriptType;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.qortal.controller.Controller;
import org.qortal.crosschain.BTC;
import org.qortal.crosschain.BTCACCT;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryFactory;
import org.qortal.repository.RepositoryManager;
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
import org.qortal.settings.Settings;
import com.google.common.hash.HashCode;
public class CheckP2SH {
private static void usage(String error) {
if (error != null)
System.err.println(error);
System.err.println(String.format("usage: CheckP2SH <P2SH-address> <refund-BTC-P2PKH> <BTC-amount> <redeem-BTC-P2PKH> <HASH160-of-secret> <locktime> (<BTC-redeem/refund-fee>)"));
System.err.println(String.format("example: CheckP2SH "
+ "2NEZboTLhBDPPQciR7sExBhy3TsDi7wV3Cv \\\n"
+ "mrTDPdM15cFWJC4g223BXX5snicfVJBx6M \\\n"
+ "\t0.00008642 \\\n"
+ "\tn2N5VKrzq39nmuefZwp3wBiF4icdXX2B6o \\\n"
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
+ "\t1585920000"));
System.exit(1);
}
public static void main(String[] args) {
if (args.length < 6 || args.length > 7)
usage(null);
Security.insertProviderAt(new BouncyCastleProvider(), 0);
Settings.fileInstance("settings-test.json");
BTC btc = BTC.getInstance();
NetworkParameters params = btc.getNetworkParameters();
Address p2shAddress = null;
Address refundBitcoinAddress = null;
Coin bitcoinAmount = null;
Address redeemBitcoinAddress = null;
byte[] secretHash = null;
int lockTime = 0;
Coin bitcoinFee = Common.DEFAULT_BTC_FEE;
int argIndex = 0;
try {
p2shAddress = Address.fromString(params, args[argIndex++]);
if (p2shAddress.getOutputScriptType() != ScriptType.P2SH)
usage("P2SH address invalid");
refundBitcoinAddress = Address.fromString(params, args[argIndex++]);
if (refundBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
usage("Refund BTC address must be in P2PKH form");
bitcoinAmount = Coin.parseCoin(args[argIndex++]);
redeemBitcoinAddress = Address.fromString(params, args[argIndex++]);
if (redeemBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
usage("Redeem BTC address must be in P2PKH form");
secretHash = HashCode.fromString(args[argIndex++]).asBytes();
if (secretHash.length != 20)
usage("Hash of secret must be 20 bytes");
lockTime = Integer.parseInt(args[argIndex++]);
int refundTimeoutDelay = lockTime - (int) (System.currentTimeMillis() / 1000L);
if (refundTimeoutDelay < 600 || refundTimeoutDelay > 7 * 24 * 60 * 60)
usage("Locktime (seconds) should be at between 10 minutes and 1 week from now");
if (args.length > argIndex)
bitcoinFee = Coin.parseCoin(args[argIndex++]);
} catch (IllegalArgumentException e) {
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
}
try {
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
RepositoryManager.setRepositoryFactory(repositoryFactory);
} catch (DataException e) {
throw new RuntimeException("Repository startup issue: " + e.getMessage());
}
try (final Repository repository = RepositoryManager.getRepository()) {
System.out.println("Confirm the following is correct based on the info you've given:");
System.out.println(String.format("Refund Bitcoin address: %s", redeemBitcoinAddress));
System.out.println(String.format("Bitcoin redeem amount: %s", bitcoinAmount.toPlainString()));
System.out.println(String.format("Redeem Bitcoin address: %s", refundBitcoinAddress));
System.out.println(String.format("Redeem miner's fee: %s", BTC.FORMAT.format(bitcoinFee)));
System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime));
System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(secretHash)));
System.out.println(String.format("P2SH address: %s", p2shAddress));
byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash);
System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes)));
byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes);
Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
if (!derivedP2shAddress.equals(p2shAddress)) {
System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress));
System.exit(2);
}
bitcoinAmount = bitcoinAmount.add(bitcoinFee);
long medianBlockTime = BTC.getInstance().getMedianBlockTime();
System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
long now = System.currentTimeMillis();
if (now < medianBlockTime * 1000L)
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 int startTime = lockTime - 86400;
Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString(), startTime);
if (p2shBalance == null) {
System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress));
System.exit(2);
}
System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.FORMAT.format(p2shBalance)));
// Grab all P2SH funding transactions (just in case there are more than one)
List<TransactionOutput> fundingOutputs = BTC.getInstance().getOutputs(p2shAddress.toString(), startTime);
if (fundingOutputs == null) {
System.err.println(String.format("Can't find outputs for P2SH"));
System.exit(2);
}
System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : "")));
for (TransactionOutput fundingOutput : fundingOutputs)
System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.FORMAT.format(fundingOutput.getValue())));
if (fundingOutputs.isEmpty()) {
System.err.println(String.format("Can't redeem spent/unfunded P2SH"));
System.exit(2);
}
if (fundingOutputs.size() != 1) {
System.err.println(String.format("Expecting only one unspent output for P2SH"));
System.exit(2);
}
} catch (DataException e) {
throw new RuntimeException("Repository issue: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,9 @@
package org.qortal.test.btcacct;
import org.bitcoinj.core.Coin;
public abstract class Common {
public static final Coin DEFAULT_BTC_FEE = Coin.parseCoin("0.00001000");
}

View File

@ -0,0 +1,160 @@
package org.qortal.test.btcacct;
import java.security.Security;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.qortal.account.PrivateKeyAccount;
import org.qortal.asset.Asset;
import org.qortal.controller.Controller;
import org.qortal.crosschain.BTCACCT;
import org.qortal.data.transaction.BaseTransactionData;
import org.qortal.data.transaction.DeployAtTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.group.Group;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryFactory;
import org.qortal.repository.RepositoryManager;
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
import org.qortal.settings.Settings;
import org.qortal.transaction.DeployAtTransaction;
import org.qortal.transaction.Transaction;
import org.qortal.transform.TransformationException;
import org.qortal.transform.transaction.TransactionTransformer;
import org.qortal.utils.Amounts;
import org.qortal.utils.Base58;
import com.google.common.hash.HashCode;
public class DeployAT {
public static final long atFundingExtra = 2000000L;
private static void usage(String error) {
if (error != null)
System.err.println(error);
System.err.println(String.format("usage: DeployAT <your Qortal PRIVATE key> <QORT amount> <BTC amount> <HASH160-of-secret> <initial QORT payout> <AT funding amount> <AT trade timeout>"));
System.err.println(String.format("example: DeployAT "
+ "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot \\\n"
+ "\t80.4020 \\\n"
+ "\t0.00864200 \\\n"
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
+ "\t0.0001 \\\n"
+ "\t123.456 \\\n"
+ "\t10"));
System.exit(1);
}
public static void main(String[] args) {
if (args.length != 8)
usage(null);
Security.insertProviderAt(new BouncyCastleProvider(), 0);
Settings.fileInstance("settings-test.json");
byte[] refundPrivateKey = null;
long redeemAmount = 0;
long expectedBitcoin = 0;
byte[] secretHash = null;
long initialPayout = 0;
long fundingAmount = 0;
int tradeTimeout = 0;
int argIndex = 0;
try {
refundPrivateKey = Base58.decode(args[argIndex++]);
if (refundPrivateKey.length != 32)
usage("Refund private key must be 32 bytes");
redeemAmount = Long.parseLong(args[argIndex++]);
if (redeemAmount <= 0)
usage("QORT amount must be positive");
expectedBitcoin = Long.parseLong(args[argIndex++]);
if (expectedBitcoin <= 0)
usage("Expected BTC amount must be positive");
secretHash = HashCode.fromString(args[argIndex++]).asBytes();
if (secretHash.length != 20)
usage("Hash of secret must be 20 bytes");
initialPayout = Long.parseLong(args[argIndex++]);
if (initialPayout < 0)
usage("Initial QORT payout must be positive");
fundingAmount = Long.parseLong(args[argIndex++]);
if (fundingAmount <= redeemAmount)
usage("AT funding amount must be greater than QORT redeem amount");
tradeTimeout = Integer.parseInt(args[argIndex++]);
if (tradeTimeout < 10 || tradeTimeout > 50000)
usage("AT trade timeout should be between 10 and 50,000 minutes");
} catch (IllegalArgumentException e) {
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
}
try {
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
RepositoryManager.setRepositoryFactory(repositoryFactory);
} catch (DataException e) {
throw new RuntimeException("Repository startup issue: " + e.getMessage());
}
try (final Repository repository = RepositoryManager.getRepository()) {
System.out.println("Confirm the following is correct based on the info you've given:");
PrivateKeyAccount refundAccount = new PrivateKeyAccount(repository, refundPrivateKey);
System.out.println(String.format("Refund Qortal address: %s", refundAccount.getAddress()));
System.out.println(String.format("QORT redeem amount: %s", Amounts.prettyAmount(redeemAmount)));
System.out.println(String.format("AT funding amount: %s", Amounts.prettyAmount(fundingAmount)));
System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash)));
// Deploy AT
byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), secretHash, tradeTimeout, initialPayout, redeemAmount, expectedBitcoin);
System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString());
long txTimestamp = System.currentTimeMillis();
byte[] lastReference = refundAccount.getLastReference();
if (lastReference == null) {
System.err.println(String.format("Qortal account %s has no last reference", refundAccount.getAddress()));
System.exit(2);
}
Long fee = null;
String name = "QORT-BTC cross-chain trade";
String description = String.format("Qortal-Bitcoin cross-chain trade");
String atType = "ACCT";
String tags = "QORT-BTC ACCT";
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, refundAccount.getPublicKey(), fee, null);
TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT);
Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
fee = deployAtTransaction.calcRecommendedFee();
deployAtTransactionData.setFee(fee);
deployAtTransaction.sign(refundAccount);
byte[] signedBytes = null;
try {
signedBytes = TransactionTransformer.toBytes(deployAtTransactionData);
} catch (TransformationException e) {
System.err.println(String.format("Unable to convert transaction to base58: %s", e.getMessage()));
System.exit(2);
}
System.out.println(String.format("\nSigned transaction in base58, ready for POST /transactions/process:\n%s\n", Base58.encode(signedBytes)));
} catch (NumberFormatException e) {
usage(String.format("Number format exception: %s", e.getMessage()));
} catch (DataException e) {
throw new RuntimeException("Repository issue: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,64 @@
package org.qortal.test.btcacct;
import java.security.Security;
import java.util.List;
import org.bitcoinj.core.AddressFormatException;
import org.bitcoinj.core.TransactionOutput;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.qortal.crosschain.BTC;
import org.qortal.settings.Settings;
import com.google.common.hash.HashCode;
public class GetTransaction {
static {
// This must go before any calls to LogManager/Logger
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
}
private static void usage(String error) {
if (error != null)
System.err.println(error);
System.err.println(String.format("usage: GetTransaction <bitcoin-tx> <start-time>"));
System.err.println(String.format("example (mainnet): GetTransaction 816407e79568f165f13e09e9912c5f2243e0a23a007cec425acedc2e89284660 1585317000"));
System.err.println(String.format("example (testnet): GetTransaction 3bfd17a492a4e3d6cb7204e17e20aca6c1ab82e1828bd1106eefbaf086fb8a4e 1584376000"));
System.exit(1);
}
public static void main(String[] args) {
if (args.length < 2 || args.length > 2)
usage(null);
Security.insertProviderAt(new BouncyCastleProvider(), 0);
Settings.fileInstance("settings-test.json");
byte[] transactionId = null;
int startTime = 0;
try {
int argIndex = 0;
transactionId = HashCode.fromString(args[argIndex++]).asBytes();
startTime = Integer.parseInt(args[argIndex++]);
} catch (NumberFormatException | AddressFormatException e) {
usage(String.format("Argument format exception: %s", e.getMessage()));
}
// Grab all outputs from transaction
List<TransactionOutput> fundingOutputs = BTC.getInstance().getOutputs(transactionId, startTime);
if (fundingOutputs == null) {
System.out.println(String.format("Transaction not found"));
return;
}
System.out.println(String.format("Found %d output%s", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : "")));
for (TransactionOutput fundingOutput : fundingOutputs)
System.out.println(String.format("Output %d: %s", fundingOutput.getIndex(), fundingOutput.getValue().toPlainString()));
}
}

View File

@ -0,0 +1,198 @@
package org.qortal.test.btcacct;
import java.security.Security;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.List;
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.Transaction;
import org.bitcoinj.core.TransactionOutput;
import org.bitcoinj.script.Script.ScriptType;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.qortal.controller.Controller;
import org.qortal.crosschain.BTC;
import org.qortal.crosschain.BTCACCT;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryFactory;
import org.qortal.repository.RepositoryManager;
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
import org.qortal.settings.Settings;
import com.google.common.hash.HashCode;
public class Redeem {
static {
// This must go before any calls to LogManager/Logger
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
}
private static void usage(String error) {
if (error != null)
System.err.println(error);
System.err.println(String.format("usage: Redeem <P2SH-address> <refund-BTC-P2PKH> <redeem-BTC-PRIVATE-key> <secret> <locktime> (<BTC-redeem/refund-fee>)"));
System.err.println(String.format("example: Redeem "
+ "2NEZboTLhBDPPQciR7sExBhy3TsDi7wV3Cv \\\n"
+ "\tmrTDPdM15cFWJC4g223BXX5snicfVJBx6M \\\n"
+ "\tec199a4abc9d3bf024349e397535dfee9d287e174aeabae94237eb03a0118c03 \\\n"
+ "\t5468697320737472696e672069732065786163746c7920333220627974657321 \\\n"
+ "\t1585920000"));
System.exit(1);
}
public static void main(String[] args) {
if (args.length < 5 || args.length > 6)
usage(null);
Security.insertProviderAt(new BouncyCastleProvider(), 0);
Settings.fileInstance("settings-test.json");
BTC btc = BTC.getInstance();
NetworkParameters params = btc.getNetworkParameters();
Address p2shAddress = null;
Address refundBitcoinAddress = null;
byte[] redeemPrivateKey = null;
byte[] secret = null;
int lockTime = 0;
Coin bitcoinFee = Common.DEFAULT_BTC_FEE;
int argIndex = 0;
try {
p2shAddress = Address.fromString(params, args[argIndex++]);
if (p2shAddress.getOutputScriptType() != ScriptType.P2SH)
usage("P2SH address invalid");
refundBitcoinAddress = Address.fromString(params, args[argIndex++]);
if (refundBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
usage("Refund BTC address must be in P2PKH form");
redeemPrivateKey = HashCode.fromString(args[argIndex++]).asBytes();
// Auto-trim
if (redeemPrivateKey.length >= 37 && redeemPrivateKey.length <= 38)
redeemPrivateKey = Arrays.copyOfRange(redeemPrivateKey, 1, 33);
if (redeemPrivateKey.length != 32)
usage("Redeem private key must be 32 bytes");
secret = HashCode.fromString(args[argIndex++]).asBytes();
if (secret.length == 0)
usage("Invalid secret bytes");
lockTime = Integer.parseInt(args[argIndex++]);
if (args.length > argIndex)
bitcoinFee = Coin.parseCoin(args[argIndex++]);
} catch (IllegalArgumentException e) {
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
}
try {
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
RepositoryManager.setRepositoryFactory(repositoryFactory);
} catch (DataException e) {
throw new RuntimeException("Repository startup issue: " + e.getMessage());
}
try (final Repository repository = RepositoryManager.getRepository()) {
System.out.println("Confirm the following is correct based on the info you've given:");
System.out.println(String.format("Redeem PRIVATE key: %s", HashCode.fromBytes(redeemPrivateKey)));
System.out.println(String.format("Redeem miner's fee: %s", BTC.FORMAT.format(bitcoinFee)));
System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime));
// New/derived info
byte[] secretHash = BTC.hash160(secret);
System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash)));
ECKey redeemKey = ECKey.fromPrivate(redeemPrivateKey);
Address redeemAddress = Address.fromKey(params, redeemKey, ScriptType.P2PKH);
System.out.println(String.format("Redeem recipient (PKH): %s (%s)", redeemAddress, HashCode.fromBytes(redeemAddress.getHash())));
System.out.println(String.format("P2SH address: %s", p2shAddress));
byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemAddress.getHash(), secretHash);
System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes)));
byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes);
Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
if (!derivedP2shAddress.equals(p2shAddress)) {
System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress));
System.exit(2);
}
// Some checks
System.out.println("\nProcessing:");
long medianBlockTime = BTC.getInstance().getMedianBlockTime();
System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
long now = System.currentTimeMillis();
if (now < medianBlockTime * 1000L) {
System.err.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)));
System.exit(2);
}
// Check P2SH is funded
final int startTime = ((int) (System.currentTimeMillis() / 1000L)) - 86400;
Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString(), startTime);
if (p2shBalance == null) {
System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress));
System.exit(2);
}
System.out.println(String.format("P2SH address %s balance: %s BTC", p2shAddress, p2shBalance.toPlainString()));
// Grab all P2SH funding transactions (just in case there are more than one)
List<TransactionOutput> fundingOutputs = BTC.getInstance().getOutputs(p2shAddress.toString(), startTime);
if (fundingOutputs == null) {
System.err.println(String.format("Can't find outputs for P2SH"));
System.exit(2);
}
System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : "")));
for (TransactionOutput fundingOutput : fundingOutputs)
System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.FORMAT.format(fundingOutput.getValue())));
if (fundingOutputs.isEmpty()) {
System.err.println(String.format("Can't redeem spent/unfunded P2SH"));
System.exit(2);
}
if (fundingOutputs.size() != 1) {
System.err.println(String.format("Expecting only one unspent output for P2SH"));
System.exit(2);
}
TransactionOutput fundingOutput = fundingOutputs.get(0);
System.out.println(String.format("Using output %s:%d for redeem", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex()));
Coin redeemAmount = p2shBalance.subtract(bitcoinFee);
System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.FORMAT.format(redeemAmount), BTC.FORMAT.format(bitcoinFee)));
Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutput, redeemScriptBytes, secret);
byte[] redeemBytes = redeemTransaction.bitcoinSerialize();
System.out.println(String.format("\nLoad this transaction into your wallet and broadcast:\n%s\n", HashCode.fromBytes(redeemBytes).toString()));
} catch (NumberFormatException e) {
usage(String.format("Number format exception: %s", e.getMessage()));
} catch (DataException e) {
throw new RuntimeException("Repository issue: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,202 @@
package org.qortal.test.btcacct;
import java.security.Security;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.List;
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.Transaction;
import org.bitcoinj.core.TransactionOutput;
import org.bitcoinj.script.Script.ScriptType;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.qortal.controller.Controller;
import org.qortal.crosschain.BTC;
import org.qortal.crosschain.BTCACCT;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryFactory;
import org.qortal.repository.RepositoryManager;
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
import org.qortal.settings.Settings;
import com.google.common.hash.HashCode;
public class Refund {
static {
// This must go before any calls to LogManager/Logger
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
}
private static void usage(String error) {
if (error != null)
System.err.println(error);
System.err.println(String.format("usage: Refund <P2SH-address> <refund-BTC-PRIVATE-KEY> <redeem-BTC-P2PKH> <HASH160-of-secret> <locktime> (<BTC-redeem/refund-fee>)"));
System.err.println(String.format("example: Refund "
+ "2NEZboTLhBDPPQciR7sExBhy3TsDi7wV3Cv \\\n"
+ "\tef027fb5828c5e201eaf6de4cd3b0b340d16a191ef848cd691f35ef8f727358c9c01b576fb7e \\\n"
+ "\tn2N5VKrzq39nmuefZwp3wBiF4icdXX2B6o \\\n"
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
+ "\t1585920000"));
System.exit(1);
}
public static void main(String[] args) {
if (args.length < 5 || args.length > 6)
usage(null);
Security.insertProviderAt(new BouncyCastleProvider(), 0);
Settings.fileInstance("settings-test.json");
BTC btc = BTC.getInstance();
NetworkParameters params = btc.getNetworkParameters();
Address p2shAddress = null;
byte[] refundPrivateKey = null;
Address redeemBitcoinAddress = null;
byte[] secretHash = null;
int lockTime = 0;
Coin bitcoinFee = Common.DEFAULT_BTC_FEE;
int argIndex = 0;
try {
p2shAddress = Address.fromString(params, args[argIndex++]);
if (p2shAddress.getOutputScriptType() != ScriptType.P2SH)
usage("P2SH address invalid");
refundPrivateKey = HashCode.fromString(args[argIndex++]).asBytes();
// Auto-trim
if (refundPrivateKey.length >= 37 && refundPrivateKey.length <= 38)
refundPrivateKey = Arrays.copyOfRange(refundPrivateKey, 1, 33);
if (refundPrivateKey.length != 32)
usage("Refund private key must be 32 bytes");
redeemBitcoinAddress = Address.fromString(params, args[argIndex++]);
if (redeemBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
usage("Their BTC address must be in P2PKH form");
secretHash = HashCode.fromString(args[argIndex++]).asBytes();
if (secretHash.length != 20)
usage("HASH160 of secret must be 20 bytes");
lockTime = Integer.parseInt(args[argIndex++]);
if (args.length > argIndex)
bitcoinFee = Coin.parseCoin(args[argIndex++]);
} catch (IllegalArgumentException e) {
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
}
try {
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
RepositoryManager.setRepositoryFactory(repositoryFactory);
} catch (DataException e) {
throw new RuntimeException("Repository startup issue: " + e.getMessage());
}
try (final Repository repository = RepositoryManager.getRepository()) {
System.out.println("Confirm the following is correct based on the info you've given:");
System.out.println(String.format("Refund PRIVATE key: %s", HashCode.fromBytes(refundPrivateKey)));
System.out.println(String.format("Redeem Bitcoin address: %s", redeemBitcoinAddress));
System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime));
System.out.println(String.format("P2SH address: %s", p2shAddress));
System.out.println(String.format("Refund miner's fee: %s", BTC.FORMAT.format(bitcoinFee)));
// New/derived info
System.out.println("\nCHECKING info from other party:");
ECKey refundKey = ECKey.fromPrivate(refundPrivateKey);
Address refundAddress = Address.fromKey(params, refundKey, ScriptType.P2PKH);
System.out.println(String.format("Refund recipient (PKH): %s (%s)", refundAddress, HashCode.fromBytes(refundAddress.getHash())));
byte[] redeemScriptBytes = BTCACCT.buildScript(refundAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash);
System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes)));
byte[] redeemScriptHash = BTC.hash160(redeemScriptBytes);
Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
if (!derivedP2shAddress.equals(p2shAddress)) {
System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress));
System.exit(2);
}
// Some checks
System.out.println("\nProcessing:");
long medianBlockTime = BTC.getInstance().getMedianBlockTime();
System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
long now = System.currentTimeMillis();
if (now < medianBlockTime * 1000L) {
System.err.println(String.format("Too soon (%s) to refund based on median block time %s", LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
System.exit(2);
}
if (now < lockTime * 1000L) {
System.err.println(String.format("Too soon (%s) to refund based on lockTime %s", LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC)));
System.exit(2);
}
// Check P2SH is funded
final int startTime = ((int) (System.currentTimeMillis() / 1000L)) - 86400;
Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString(), startTime);
if (p2shBalance == null) {
System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress));
System.exit(2);
}
System.out.println(String.format("P2SH address %s balance: %s BTC", p2shAddress, p2shBalance.toPlainString()));
// Grab all P2SH funding transactions (just in case there are more than one)
List<TransactionOutput> fundingOutputs = BTC.getInstance().getOutputs(p2shAddress.toString(), startTime);
if (fundingOutputs == null) {
System.err.println(String.format("Can't find outputs for P2SH"));
System.exit(2);
}
System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : "")));
for (TransactionOutput fundingOutput : fundingOutputs)
System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.FORMAT.format(fundingOutput.getValue())));
if (fundingOutputs.isEmpty()) {
System.err.println(String.format("Can't refund spent/unfunded P2SH"));
System.exit(2);
}
if (fundingOutputs.size() != 1) {
System.err.println(String.format("Expecting only one unspent output for P2SH"));
System.exit(2);
}
TransactionOutput fundingOutput = fundingOutputs.get(0);
System.out.println(String.format("Using output %s:%d for refund", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex()));
Coin refundAmount = p2shBalance.subtract(bitcoinFee);
System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.FORMAT.format(refundAmount), BTC.FORMAT.format(bitcoinFee)));
Transaction redeemTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutput, redeemScriptBytes, lockTime);
byte[] redeemBytes = redeemTransaction.bitcoinSerialize();
System.out.println(String.format("\nLoad this transaction into your wallet and broadcast:\n%s\n", HashCode.fromBytes(redeemBytes).toString()));
} catch (NumberFormatException e) {
usage(String.format("Number format exception: %s", e.getMessage()));
} catch (DataException e) {
throw new RuntimeException("Repository issue: " + e.getMessage());
}
}
}

View File

@ -15,9 +15,9 @@ public class BlockUtils {
private static final Logger LOGGER = LogManager.getLogger(BlockUtils.class);
/** Mints a new block using "alice-reward-share" test account. */
public static void mintBlock(Repository repository) throws DataException {
public static Block mintBlock(Repository repository) throws DataException {
PrivateKeyAccount mintingAccount = Common.getTestAccount(repository, "alice-reward-share");
BlockMinter.mintTestingBlock(repository, mintingAccount);
return BlockMinter.mintTestingBlock(repository, mintingAccount);
}
public static Long getNextBlockReward(Repository repository) throws DataException {

View File

@ -29,6 +29,12 @@
"blockTimingsByHeight": [
{ "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 }
],
"ciyamAtSettings": {
"feePerStep": "0.0001",
"maxStepsPerRound": 500,
"stepsPerFunctionCall": 10,
"minutesPerBlock": 1
},
"featureTriggers": {
"messageHeight": 0,
"atHeight": 0,

View File

@ -29,6 +29,12 @@
"blockTimingsByHeight": [
{ "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 }
],
"ciyamAtSettings": {
"feePerStep": "0.0001",
"maxStepsPerRound": 500,
"stepsPerFunctionCall": 10,
"minutesPerBlock": 1
},
"featureTriggers": {
"messageHeight": 0,
"atHeight": 0,

View File

@ -29,6 +29,12 @@
"blockTimingsByHeight": [
{ "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 }
],
"ciyamAtSettings": {
"feePerStep": "0.0001",
"maxStepsPerRound": 500,
"stepsPerFunctionCall": 10,
"minutesPerBlock": 1
},
"featureTriggers": {
"messageHeight": 0,
"atHeight": 0,

View File

@ -29,6 +29,12 @@
"blockTimingsByHeight": [
{ "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 }
],
"ciyamAtSettings": {
"feePerStep": "0.0001",
"maxStepsPerRound": 500,
"stepsPerFunctionCall": 10,
"minutesPerBlock": 1
},
"featureTriggers": {
"messageHeight": 0,
"atHeight": 0,

View File

@ -2,5 +2,6 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-founder-rewards.json",
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0
}

View File

@ -2,5 +2,6 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-minting.json",
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0
}

View File

@ -2,5 +2,6 @@
"restrictedApi": false,
"blockchainConfig": "src/test/resources/test-chain-v2-qora-holder.json",
"wipeUnconfirmedOnStart": false,
"testNtpOffset": 0,
"minPeers": 0
}

View File

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