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)); } /**