From aab6b69da140cffa0e92542bb216515363fa47aa Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 19 Dec 2018 09:52:42 +0000 Subject: [PATCH] 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. --- src/api/AssetsResource.java | 41 ++++++++-- src/api/Base58TypeAdapter.java | 6 +- src/api/BigDecimalTypeAdapter.java | 25 ++++++ src/api/NamesResource.java | 16 +++- src/api/PaymentsResource.java | 20 ++++- src/api/TransactionsResource.java | 79 +++++++++++++++++-- src/api/models/IssueAssetRequest.java | 26 ------ .../models/SimpleTransactionSignRequest.java | 6 +- src/data/package-info.java | 11 ++- .../IssueAssetTransactionData.java | 3 + .../transaction/PaymentTransactionData.java | 9 +++ src/repository/hsqldb/HSQLDBRepository.java | 15 +++- .../HSQLDBTransactionRepository.java | 1 - src/utils/Serialization.java | 9 +-- 14 files changed, 210 insertions(+), 57 deletions(-) create mode 100644 src/api/BigDecimalTypeAdapter.java delete mode 100644 src/api/models/IssueAssetRequest.java diff --git a/src/api/AssetsResource.java b/src/api/AssetsResource.java index ac83dbdb..81e51ca2 100644 --- a/src/api/AssetsResource.java +++ b/src/api/AssetsResource.java @@ -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); + } } } diff --git a/src/api/Base58TypeAdapter.java b/src/api/Base58TypeAdapter.java index 4a55565b..cd5e73da 100644 --- a/src/api/Base58TypeAdapter.java +++ b/src/api/Base58TypeAdapter.java @@ -15,11 +15,11 @@ public class Base58TypeAdapter extends XmlAdapter { } @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); } } diff --git a/src/api/BigDecimalTypeAdapter.java b/src/api/BigDecimalTypeAdapter.java new file mode 100644 index 00000000..a1c03598 --- /dev/null +++ b/src/api/BigDecimalTypeAdapter.java @@ -0,0 +1,25 @@ +package api; + +import java.math.BigDecimal; + +import javax.xml.bind.annotation.adapters.XmlAdapter; + +public class BigDecimalTypeAdapter extends XmlAdapter { + + @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(); + } + +} diff --git a/src/api/NamesResource.java b/src/api/NamesResource.java index 97200cec..4631400d 100644 --- a/src/api/NamesResource.java +++ b/src/api/NamesResource.java @@ -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); } } diff --git a/src/api/PaymentsResource.java b/src/api/PaymentsResource.java index 2e0e5e4c..5a8106e3 100644 --- a/src/api/PaymentsResource.java +++ b/src/api/PaymentsResource.java @@ -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); } } diff --git a/src/api/TransactionsResource.java b/src/api/TransactionsResource.java index ac0fe4ff..0d68f088 100644 --- a/src/api/TransactionsResource.java +++ b/src/api/TransactionsResource.java @@ -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); + } + } + } diff --git a/src/api/models/IssueAssetRequest.java b/src/api/models/IssueAssetRequest.java deleted file mode 100644 index 1065dec1..00000000 --- a/src/api/models/IssueAssetRequest.java +++ /dev/null @@ -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; - -} diff --git a/src/api/models/SimpleTransactionSignRequest.java b/src/api/models/SimpleTransactionSignRequest.java index 81b014cb..e68b31e5 100644 --- a/src/api/models/SimpleTransactionSignRequest.java +++ b/src/api/models/SimpleTransactionSignRequest.java @@ -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; diff --git a/src/data/package-info.java b/src/data/package-info.java index 27ea8739..0e67fb58 100644 --- a/src/data/package-info.java +++ b/src/data/package-info.java @@ -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; diff --git a/src/data/transaction/IssueAssetTransactionData.java b/src/data/transaction/IssueAssetTransactionData.java index d832248e..0f590aa1 100644 --- a/src/data/transaction/IssueAssetTransactionData.java +++ b/src/data/transaction/IssueAssetTransactionData.java @@ -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, diff --git a/src/data/transaction/PaymentTransactionData.java b/src/data/transaction/PaymentTransactionData.java index e93ada3a..e4faccb0 100644 --- a/src/data/transaction/PaymentTransactionData.java +++ b/src/data/transaction/PaymentTransactionData.java @@ -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, diff --git a/src/repository/hsqldb/HSQLDBRepository.java b/src/repository/hsqldb/HSQLDBRepository.java index fc181eea..3ae733cc 100644 --- a/src/repository/hsqldb/HSQLDBRepository.java +++ b/src/repository/hsqldb/HSQLDBRepository.java @@ -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; } /** diff --git a/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 8dc0f8b9..45fe2b89 100644 --- a/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -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) diff --git a/src/utils/Serialization.java b/src/utils/Serialization.java index c906ff87..a394c797 100644 --- a/src/utils/Serialization.java +++ b/src/utils/Serialization.java @@ -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)); } /**