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.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import qora.transaction.Transaction;
import qora.transaction.Transaction.ValidationResult;
import repository.DataException; import repository.DataException;
import repository.Repository; import repository.Repository;
import repository.RepositoryManager; import repository.RepositoryManager;
import transform.TransformationException;
import transform.transaction.IssueAssetTransactionTransformer;
import utils.Base58; import utils.Base58;
import java.util.ArrayList; import java.util.ArrayList;
@ -27,13 +31,13 @@ import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import api.models.AssetWithHolders; import api.models.AssetWithHolders;
import api.models.IssueAssetRequest;
import api.models.OrderWithTrades; import api.models.OrderWithTrades;
import api.models.TradeWithOrderInfo; import api.models.TradeWithOrderInfo;
import data.account.AccountBalanceData; import data.account.AccountBalanceData;
import data.assets.AssetData; import data.assets.AssetData;
import data.assets.OrderData; import data.assets.OrderData;
import data.assets.TradeData; import data.assets.TradeData;
import data.transaction.IssueAssetTransactionData;
@Path("/assets") @Path("/assets")
@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) @Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
@ -226,15 +230,36 @@ public class AssetsResource {
required = true, required = true,
content = @Content( content = @Content(
mediaType = MediaType.APPLICATION_JSON, 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 public String issueAsset(IssueAssetTransactionData transactionData) {
// returns: raw tx try (final Repository repository = RepositoryManager.getRepository()) {
return ""; 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 @Override
public String marshal(byte[] input) throws Exception { public String marshal(byte[] output) throws Exception {
if (input == null) if (output == null)
return 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.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; 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.TransformationException;
import transform.transaction.RegisterNameTransactionTransformer; import transform.transaction.RegisterNameTransactionTransformer;
import utils.Base58; import utils.Base58;
@ -48,6 +53,7 @@ public class NamesResource {
@ApiResponse( @ApiResponse(
description = "raw, unsigned REGISTER_NAME transaction encoded in Base58", description = "raw, unsigned REGISTER_NAME transaction encoded in Base58",
content = @Content( content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema( schema = @Schema(
type = "string" type = "string"
) )
@ -56,11 +62,19 @@ public class NamesResource {
} }
) )
public String buildTransaction(RegisterNameTransactionData transactionData) { 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); byte[] bytes = RegisterNameTransactionTransformer.toBytes(transactionData);
return Base58.encode(bytes); return Base58.encode(bytes);
} catch (TransformationException e) { } catch (TransformationException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.UNKNOWN, 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.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; 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.TransformationException;
import transform.transaction.PaymentTransactionTransformer; import transform.transaction.PaymentTransactionTransformer;
import utils.Base58; import utils.Base58;
@ -48,6 +53,7 @@ public class PaymentsResource {
@ApiResponse( @ApiResponse(
description = "raw, unsigned payment transaction encoded in Base58", description = "raw, unsigned payment transaction encoded in Base58",
content = @Content( content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema( schema = @Schema(
type = "string" type = "string"
) )
@ -55,12 +61,20 @@ public class PaymentsResource {
) )
} }
) )
public String buildTransaction(PaymentTransactionData paymentTransactionData) { public String buildTransaction(PaymentTransactionData transactionData) {
try { try (final Repository repository = RepositoryManager.getRepository()) {
byte[] bytes = PaymentTransactionTransformer.toBytes(paymentTransactionData); 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); return Base58.encode(bytes);
} catch (TransformationException e) { } catch (TransformationException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.UNKNOWN, 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 api.models.SimpleTransactionSignRequest;
import data.transaction.GenesisTransactionData; import data.transaction.GenesisTransactionData;
import data.transaction.PaymentTransactionData; import data.transaction.PaymentTransactionData;
import data.transaction.RegisterNameTransactionData;
import data.transaction.TransactionData; import data.transaction.TransactionData;
import repository.DataException; import repository.DataException;
import repository.Repository; import repository.Repository;
import repository.RepositoryManager; import repository.RepositoryManager;
import transform.TransformationException; import transform.TransformationException;
import transform.transaction.RegisterNameTransactionTransformer;
import transform.transaction.TransactionTransformer; import transform.transaction.TransactionTransformer;
import utils.Base58; import utils.Base58;
@ -352,13 +350,18 @@ public class TransactionsResource {
) )
public String signTransaction(SimpleTransactionSignRequest signRequest) { public String signTransaction(SimpleTransactionSignRequest signRequest) {
try { 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]); byte[] rawBytes = Bytes.concat(signRequest.transactionBytes, new byte[TransactionTransformer.SIGNATURE_LENGTH]);
TransactionData transactionData = TransactionTransformer.fromBytes(rawBytes); TransactionData transactionData = TransactionTransformer.fromBytes(rawBytes);
PrivateKeyAccount signer = new PrivateKeyAccount(null, signRequest.privateKey); PrivateKeyAccount signer = new PrivateKeyAccount(null, signRequest.privateKey);
Transaction transaction = Transaction.fromData(null, transactionData); Transaction transaction = Transaction.fromData(null, transactionData);
transaction.sign(signer); transaction.sign(signer);
byte[] signedBytes = TransactionTransformer.toBytes(transactionData); byte[] signedBytes = TransactionTransformer.toBytes(transactionData);
return Base58.encode(signedBytes); return Base58.encode(signedBytes);
} catch (TransformationException e) { } catch (TransformationException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.UNKNOWN, e); throw ApiErrorFactory.getInstance().createError(ApiError.UNKNOWN, e);
@ -375,7 +378,8 @@ public class TransactionsResource {
mediaType = MediaType.TEXT_PLAIN, mediaType = MediaType.TEXT_PLAIN,
schema = @Schema( schema = @Schema(
type = "string", 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()) if (!transaction.isSignatureValid())
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_SIGNATURE); throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_SIGNATURE);
if (transaction.isValid() != ValidationResult.OK) ValidationResult result = transaction.isValid();
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA); if (result != ValidationResult.OK)
throw new ApiException(400, ApiError.INVALID_DATA.getCode(), "Transaction invalid: " + result.name());
repository.getTransactionRepository().save(transactionData); repository.getTransactionRepository().save(transactionData);
repository.getTransactionRepository().unconfirmTransaction(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 { public class SimpleTransactionSignRequest {
@Schema( @Schema(
description = "signer's private key" description = "signer's private key",
example = "A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6"
) )
public byte[] privateKey; public byte[] privateKey;
@Schema( @Schema(
description = "raw, unsigned transaction bytes" description = "raw, unsigned transaction bytes",
example = "base58"
) )
public byte[] transactionBytes; public byte[] transactionBytes;

View File

@ -1,6 +1,15 @@
// This file (data/package-info.java) is used as a template! // 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; package data;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; 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 javax.xml.bind.annotation.XmlAccessorType;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.AccessMode;
import qora.transaction.Transaction.TransactionType; import qora.transaction.Transaction.TransactionType;
// All properties to be converted to JSON via JAX-RS // All properties to be converted to JSON via JAX-RS
@ -15,6 +16,7 @@ public class IssueAssetTransactionData extends TransactionData {
// Properties // Properties
// assetId can be null but assigned during save() or during load from repository // assetId can be null but assigned during save() or during load from repository
@Schema(accessMode = AccessMode.READ_ONLY)
private Long assetId = null; private Long assetId = null;
private byte[] issuerPublicKey; private byte[] issuerPublicKey;
private String owner; private String owner;
@ -27,6 +29,7 @@ public class IssueAssetTransactionData extends TransactionData {
// For JAX-RS // For JAX-RS
protected IssueAssetTransactionData() { protected IssueAssetTransactionData() {
super(TransactionType.ISSUE_ASSET);
} }
public IssueAssetTransactionData(Long assetId, byte[] issuerPublicKey, String owner, String assetName, String description, long quantity, 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.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import qora.transaction.Transaction.TransactionType; import qora.transaction.Transaction.TransactionType;
@ -14,14 +15,22 @@ import qora.transaction.Transaction.TransactionType;
public class PaymentTransactionData extends TransactionData { public class PaymentTransactionData extends TransactionData {
// Properties // Properties
@Schema(description = "sender's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP")
private byte[] senderPublicKey; private byte[] senderPublicKey;
@Schema(description = "recipient's address", example = "Qj2Stco8ziE3ZQN2AdpWCmkBFfYjuz8fGu")
private String recipient; private String recipient;
@Schema(description = "amount to send", example = "123.456")
@XmlJavaTypeAdapter(
type = BigDecimal.class,
value = api.BigDecimalTypeAdapter.class
)
private BigDecimal amount; private BigDecimal amount;
// Constructors // Constructors
// For JAX-RS // For JAX-RS
protected PaymentTransactionData() { protected PaymentTransactionData() {
super(TransactionType.PAYMENT);
} }
public PaymentTransactionData(byte[] senderPublicKey, String recipient, BigDecimal amount, BigDecimal fee, long timestamp, byte[] reference, 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 { 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); private static final Logger LOGGER = LogManager.getLogger(HSQLDBRepository.class);
public static final TimeZone UTC = TimeZone.getTimeZone("UTC"); public static final TimeZone UTC = TimeZone.getTimeZone("UTC");
@ -136,10 +139,20 @@ public class HSQLDBRepository implements Repository {
@SuppressWarnings("resource") @SuppressWarnings("resource")
public ResultSet checkedExecute(String sql, Object... objects) throws SQLException { public ResultSet checkedExecute(String sql, Object... objects) throws SQLException {
PreparedStatement preparedStatement = this.connection.prepareStatement(sql); PreparedStatement preparedStatement = this.connection.prepareStatement(sql);
// Close the PreparedStatement when the ResultSet is closed otherwise there's a potential resource leak. // 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. // We can't use try-with-resources here as closing the PreparedStatement on return would also prematurely close the ResultSet.
preparedStatement.closeOnCompletion(); 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); 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())) { try (ResultSet resultSet = this.repository.checkedExecute(sql, bindParams.toArray())) {
if (resultSet == null) if (resultSet == null)

View File

@ -23,7 +23,9 @@ public class Serialization {
* @throws IOException * @throws IOException
*/ */
public static byte[] serializeBigDecimal(BigDecimal amount, int length) 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]; byte[] output = new byte[length];
System.arraycopy(amountBytes, 0, output, length - amountBytes.length, amountBytes.length); System.arraycopy(amountBytes, 0, output, length - amountBytes.length, amountBytes.length);
return output; return output;
@ -49,10 +51,7 @@ public class Serialization {
* @throws IOException * @throws IOException
*/ */
public static void serializeBigDecimal(ByteArrayOutputStream bytes, BigDecimal amount, int length) throws IOException { public static void serializeBigDecimal(ByteArrayOutputStream bytes, BigDecimal amount, int length) throws IOException {
byte[] amountBytes = amount.unscaledValue().toByteArray(); bytes.write(serializeBigDecimal(amount, length));
byte[] output = new byte[length];
System.arraycopy(amountBytes, 0, output, length - amountBytes.length, amountBytes.length);
bytes.write(output);
} }
/** /**