API: payments, issue asset, transaction decode, etc.

Added slow query check to HSQLDB repository to help
isolate cases where transaction searching takes too long.

Added BigDecimalTypeAdapter for normalizing API inputs
but doesn't seem to get reliably called so also added
.setScale(8) to BigDecimal serialization method.

API-built transactions are now validated before emitting
base58 raw transaction to help callers.

API's transaction decoder accepts signed/unsigned raw transactions.
This commit is contained in:
catbref 2018-12-19 09:52:42 +00:00
parent 107ef93b37
commit aab6b69da1
14 changed files with 210 additions and 57 deletions

View File

@ -8,9 +8,13 @@ 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 qora.transaction.Transaction;
import qora.transaction.Transaction.ValidationResult;
import repository.DataException;
import repository.Repository;
import repository.RepositoryManager;
import transform.TransformationException;
import transform.transaction.IssueAssetTransactionTransformer;
import utils.Base58;
import java.util.ArrayList;
@ -27,13 +31,13 @@ import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import api.models.AssetWithHolders;
import api.models.IssueAssetRequest;
import api.models.OrderWithTrades;
import api.models.TradeWithOrderInfo;
import data.account.AccountBalanceData;
import data.assets.AssetData;
import data.assets.OrderData;
import data.assets.TradeData;
import data.transaction.IssueAssetTransactionData;
@Path("/assets")
@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
@ -226,15 +230,36 @@ public class AssetsResource {
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = IssueAssetRequest.class)
schema = @Schema(implementation = IssueAssetTransactionData.class)
)
)
),
responses = {
@ApiResponse(
description = "raw, unsigned payment transaction encoded in Base58",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string"
)
)
)
}
)
public String issueAsset(IssueAssetRequest issueAssetRequest) {
// required: issuer (pubkey), name, description, quantity, isDivisible, fee
// optional: reference
// returns: raw tx
return "";
public String issueAsset(IssueAssetTransactionData transactionData) {
try (final Repository repository = RepositoryManager.getRepository()) {
Transaction transaction = Transaction.fromData(repository, transactionData);
ValidationResult result = transaction.isValid();
if (result != ValidationResult.OK)
throw new ApiException(400, ApiError.INVALID_DATA.getCode(), "Transaction invalid: " + result.name());
byte[] bytes = IssueAssetTransactionTransformer.toBytes(transactionData);
return Base58.encode(bytes);
} catch (TransformationException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.UNKNOWN, e);
} catch (DataException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.REPOSITORY_ISSUE, e);
}
}
}

View File

@ -15,11 +15,11 @@ public class Base58TypeAdapter extends XmlAdapter<String, byte[]> {
}
@Override
public String marshal(byte[] input) throws Exception {
if (input == null)
public String marshal(byte[] output) throws Exception {
if (output == null)
return null;
return Base58.encode(input);
return Base58.encode(output);
}
}

View File

@ -0,0 +1,25 @@
package api;
import java.math.BigDecimal;
import javax.xml.bind.annotation.adapters.XmlAdapter;
public class BigDecimalTypeAdapter extends XmlAdapter<String, BigDecimal> {
@Override
public BigDecimal unmarshal(String input) throws Exception {
if (input == null)
return null;
return new BigDecimal(input).setScale(8);
}
@Override
public String marshal(BigDecimal output) throws Exception {
if (output == null)
return null;
return output.toPlainString();
}
}

View File

@ -6,6 +6,11 @@ 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 qora.transaction.Transaction;
import qora.transaction.Transaction.ValidationResult;
import repository.DataException;
import repository.Repository;
import repository.RepositoryManager;
import transform.TransformationException;
import transform.transaction.RegisterNameTransactionTransformer;
import utils.Base58;
@ -48,6 +53,7 @@ public class NamesResource {
@ApiResponse(
description = "raw, unsigned REGISTER_NAME transaction encoded in Base58",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string"
)
@ -56,11 +62,19 @@ public class NamesResource {
}
)
public String buildTransaction(RegisterNameTransactionData transactionData) {
try {
try (final Repository repository = RepositoryManager.getRepository()) {
Transaction transaction = Transaction.fromData(repository, transactionData);
ValidationResult result = transaction.isValid();
if (result != ValidationResult.OK)
throw new ApiException(400, ApiError.INVALID_DATA.getCode(), "Transaction invalid: " + result.name());
byte[] bytes = RegisterNameTransactionTransformer.toBytes(transactionData);
return Base58.encode(bytes);
} catch (TransformationException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.UNKNOWN, e);
} catch (DataException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.REPOSITORY_ISSUE, e);
}
}

View File

@ -6,6 +6,11 @@ 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 qora.transaction.Transaction;
import qora.transaction.Transaction.ValidationResult;
import repository.DataException;
import repository.Repository;
import repository.RepositoryManager;
import transform.TransformationException;
import transform.transaction.PaymentTransactionTransformer;
import utils.Base58;
@ -48,6 +53,7 @@ public class PaymentsResource {
@ApiResponse(
description = "raw, unsigned payment transaction encoded in Base58",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string"
)
@ -55,12 +61,20 @@ public class PaymentsResource {
)
}
)
public String buildTransaction(PaymentTransactionData paymentTransactionData) {
try {
byte[] bytes = PaymentTransactionTransformer.toBytes(paymentTransactionData);
public String buildTransaction(PaymentTransactionData transactionData) {
try (final Repository repository = RepositoryManager.getRepository()) {
Transaction transaction = Transaction.fromData(repository, transactionData);
ValidationResult result = transaction.isValid();
if (result != ValidationResult.OK)
throw new ApiException(400, ApiError.INVALID_DATA.getCode(), "Transaction invalid: " + result.name());
byte[] bytes = PaymentTransactionTransformer.toBytes(transactionData);
return Base58.encode(bytes);
} catch (TransformationException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.UNKNOWN, e);
} catch (DataException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.REPOSITORY_ISSUE, e);
}
}

View File

@ -33,13 +33,11 @@ import com.google.common.primitives.Bytes;
import api.models.SimpleTransactionSignRequest;
import data.transaction.GenesisTransactionData;
import data.transaction.PaymentTransactionData;
import data.transaction.RegisterNameTransactionData;
import data.transaction.TransactionData;
import repository.DataException;
import repository.Repository;
import repository.RepositoryManager;
import transform.TransformationException;
import transform.transaction.RegisterNameTransactionTransformer;
import transform.transaction.TransactionTransformer;
import utils.Base58;
@ -352,13 +350,18 @@ public class TransactionsResource {
)
public String signTransaction(SimpleTransactionSignRequest signRequest) {
try {
// Append null signature on the end
// Append null signature on the end before transformation
byte[] rawBytes = Bytes.concat(signRequest.transactionBytes, new byte[TransactionTransformer.SIGNATURE_LENGTH]);
TransactionData transactionData = TransactionTransformer.fromBytes(rawBytes);
PrivateKeyAccount signer = new PrivateKeyAccount(null, signRequest.privateKey);
Transaction transaction = Transaction.fromData(null, transactionData);
transaction.sign(signer);
byte[] signedBytes = TransactionTransformer.toBytes(transactionData);
return Base58.encode(signedBytes);
} catch (TransformationException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.UNKNOWN, e);
@ -375,7 +378,8 @@ public class TransactionsResource {
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string",
description = "raw, signed transaction in base58 encoding"
description = "raw, signed transaction in base58 encoding",
example = "base58"
)
)
),
@ -400,8 +404,9 @@ public class TransactionsResource {
if (!transaction.isSignatureValid())
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_SIGNATURE);
if (transaction.isValid() != ValidationResult.OK)
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
ValidationResult result = transaction.isValid();
if (result != ValidationResult.OK)
throw new ApiException(400, ApiError.INVALID_DATA.getCode(), "Transaction invalid: " + result.name());
repository.getTransactionRepository().save(transactionData);
repository.getTransactionRepository().unconfirmTransaction(transactionData);
@ -417,4 +422,66 @@ public class TransactionsResource {
}
}
@POST
@Path("/decode")
@Operation(
summary = "Decode a raw, signed transaction",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string",
description = "raw, unsigned/signed transaction in base58 encoding",
example = "base58"
)
)
),
responses = {
@ApiResponse(
description = "a transaction",
content = @Content(
schema = @Schema(
implementation = TransactionData.class
)
)
)
}
)
public TransactionData decodeTransaction(String rawBytes58) {
try (final Repository repository = RepositoryManager.getRepository()) {
byte[] rawBytes = Base58.decode(rawBytes58);
boolean hasSignature = true;
TransactionData transactionData;
try {
transactionData = TransactionTransformer.fromBytes(rawBytes);
} catch (TransformationException e) {
// Maybe we're missing a signature, so append one and try one more time
rawBytes = Bytes.concat(rawBytes, new byte[TransactionTransformer.SIGNATURE_LENGTH]);
hasSignature = false;
transactionData = TransactionTransformer.fromBytes(rawBytes);
}
Transaction transaction = Transaction.fromData(repository, transactionData);
if (hasSignature && !transaction.isSignatureValid())
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_SIGNATURE);
ValidationResult result = transaction.isValid();
if (result != ValidationResult.OK)
throw new ApiException(400, ApiError.INVALID_DATA.getCode(), "Transaction invalid: " + result.name());
if (!hasSignature)
transactionData.setSignature(null);
return transactionData;
} catch (TransformationException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.UNKNOWN, e);
} catch (ApiException e) {
throw e;
} catch (DataException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.REPOSITORY_ISSUE, e);
}
}
}

View File

@ -1,26 +0,0 @@
package api.models;
import java.math.BigDecimal;
import io.swagger.v3.oas.annotations.media.Schema;
public class IssueAssetRequest {
@Schema(description = "asset issuer's public key")
public byte[] issuer;
@Schema(description = "asset name - must be lowercase", example = "my-asset123")
public String name;
@Schema(description = "asset description")
public String description;
public BigDecimal quantity;
public boolean isDivisible;
public BigDecimal fee;
public byte[] reference;
}

View File

@ -9,12 +9,14 @@ import io.swagger.v3.oas.annotations.media.Schema;
public class SimpleTransactionSignRequest {
@Schema(
description = "signer's private key"
description = "signer's private key",
example = "A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6"
)
public byte[] privateKey;
@Schema(
description = "raw, unsigned transaction bytes"
description = "raw, unsigned transaction bytes",
example = "base58"
)
public byte[] transactionBytes;

View File

@ -1,6 +1,15 @@
// This file (data/package-info.java) is used as a template!
@XmlJavaTypeAdapter(type = byte[].class, value = api.Base58TypeAdapter.class)
@XmlJavaTypeAdapters({
@XmlJavaTypeAdapter(
type = byte[].class,
value = api.Base58TypeAdapter.class
), @XmlJavaTypeAdapter(
type = java.math.BigDecimal.class,
value = api.BigDecimalTypeAdapter.class
)
})
package data;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapters;

View File

@ -6,6 +6,7 @@ import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.AccessMode;
import qora.transaction.Transaction.TransactionType;
// All properties to be converted to JSON via JAX-RS
@ -15,6 +16,7 @@ public class IssueAssetTransactionData extends TransactionData {
// Properties
// assetId can be null but assigned during save() or during load from repository
@Schema(accessMode = AccessMode.READ_ONLY)
private Long assetId = null;
private byte[] issuerPublicKey;
private String owner;
@ -27,6 +29,7 @@ public class IssueAssetTransactionData extends TransactionData {
// For JAX-RS
protected IssueAssetTransactionData() {
super(TransactionType.ISSUE_ASSET);
}
public IssueAssetTransactionData(Long assetId, byte[] issuerPublicKey, String owner, String assetName, String description, long quantity,

View File

@ -4,6 +4,7 @@ import java.math.BigDecimal;
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;
import qora.transaction.Transaction.TransactionType;
@ -14,14 +15,22 @@ import qora.transaction.Transaction.TransactionType;
public class PaymentTransactionData extends TransactionData {
// Properties
@Schema(description = "sender's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP")
private byte[] senderPublicKey;
@Schema(description = "recipient's address", example = "Qj2Stco8ziE3ZQN2AdpWCmkBFfYjuz8fGu")
private String recipient;
@Schema(description = "amount to send", example = "123.456")
@XmlJavaTypeAdapter(
type = BigDecimal.class,
value = api.BigDecimalTypeAdapter.class
)
private BigDecimal amount;
// Constructors
// For JAX-RS
protected PaymentTransactionData() {
super(TransactionType.PAYMENT);
}
public PaymentTransactionData(byte[] senderPublicKey, String recipient, BigDecimal amount, BigDecimal fee, long timestamp, byte[] reference,

View File

@ -24,6 +24,9 @@ import repository.hsqldb.transaction.HSQLDBTransactionRepository;
public class HSQLDBRepository implements Repository {
/** Queries that take longer than this (milliseconds) are logged */
private static final long MAX_QUERY_TIME = 1000L;
private static final Logger LOGGER = LogManager.getLogger(HSQLDBRepository.class);
public static final TimeZone UTC = TimeZone.getTimeZone("UTC");
@ -136,10 +139,20 @@ public class HSQLDBRepository implements Repository {
@SuppressWarnings("resource")
public ResultSet checkedExecute(String sql, Object... objects) throws SQLException {
PreparedStatement preparedStatement = this.connection.prepareStatement(sql);
// Close the PreparedStatement when the ResultSet is closed otherwise there's a potential resource leak.
// We can't use try-with-resources here as closing the PreparedStatement on return would also prematurely close the ResultSet.
preparedStatement.closeOnCompletion();
return this.checkedExecuteResultSet(preparedStatement, objects);
long beforeQuery = System.currentTimeMillis();
ResultSet resultSet = this.checkedExecuteResultSet(preparedStatement, objects);
long queryTime = System.currentTimeMillis() - beforeQuery;
if (queryTime > MAX_QUERY_TIME)
LOGGER.info(String.format("HSQLDB query took %d ms: %s", queryTime, sql));
return resultSet;
}
/**

View File

@ -353,7 +353,6 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
}
String sql = "SELECT " + signatureColumn + " FROM " + String.join(" JOIN ", tableJoins) + " WHERE " + String.join(" AND ", whereClauses);
System.out.println("Transaction search SQL:\n" + sql);
try (ResultSet resultSet = this.repository.checkedExecute(sql, bindParams.toArray())) {
if (resultSet == null)

View File

@ -23,7 +23,9 @@ public class Serialization {
* @throws IOException
*/
public static byte[] serializeBigDecimal(BigDecimal amount, int length) throws IOException {
byte[] amountBytes = amount.unscaledValue().toByteArray();
// Note: we call .setScale(8) here to normalize values, especially values from API as they can have varying scale
// (At least until the BigDecimal XmlAdapter works - see data/package-info.java)
byte[] amountBytes = amount.setScale(8).unscaledValue().toByteArray();
byte[] output = new byte[length];
System.arraycopy(amountBytes, 0, output, length - amountBytes.length, amountBytes.length);
return output;
@ -49,10 +51,7 @@ public class Serialization {
* @throws IOException
*/
public static void serializeBigDecimal(ByteArrayOutputStream bytes, BigDecimal amount, int length) throws IOException {
byte[] amountBytes = amount.unscaledValue().toByteArray();
byte[] output = new byte[length];
System.arraycopy(amountBytes, 0, output, length - amountBytes.length, amountBytes.length);
bytes.write(output);
bytes.write(serializeBigDecimal(amount, length));
}
/**