From 3829630b291e41b3465a5f58c59e6dd13e368a7f Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 10 Dec 2018 13:27:41 +0000 Subject: [PATCH] API: transaction searching Converted AddressesResource to full base64, removing base58. Narrowed range of API errors returnable while there. Added support for looking up public key of address. Added support for converting public key TO address. Added API endpoint for returning a range of block signatures, to aid block explorers. Added API support for fetching unconfirmed transactions. Added API endpoint for searching transactions to meet criteria like: - participating address (only recipients supported ATM) - block height range - transaction type - result count limit/offset --- Added storage of account's public key in repository along with supporting code in AccountData and Account business object to save public key where possible. --- src/api/AddressesResource.java | 150 +++++++++++++----- src/api/AdminResource.java | 5 +- src/api/AnnotationPostProcessor.java | 3 - src/api/ApiDefinition.java | 9 +- src/api/ApiError.java | 2 + src/api/ApiErrorFactory.java | 2 + src/api/BlocksResource.java | 86 ++++++++-- src/api/TransactionsResource.java | 142 +++++++++++++---- src/api/UtilsResource.java | 2 +- src/data/account/AccountData.java | 14 +- src/qora/account/Account.java | 3 +- src/qora/account/PrivateKeyAccount.java | 5 +- src/qora/account/PublicKeyAccount.java | 8 +- src/repository/AccountRepository.java | 2 +- src/repository/TransactionRepository.java | 3 + .../hsqldb/HSQLDBAccountRepository.java | 17 +- .../hsqldb/HSQLDBDatabaseUpdates.java | 31 ++++ .../HSQLDBTransactionRepository.java | 84 ++++++++++ tests/test/TransactionTests.java | 4 +- 19 files changed, 458 insertions(+), 114 deletions(-) diff --git a/src/api/AddressesResource.java b/src/api/AddressesResource.java index 9d335e68..81b5ee10 100644 --- a/src/api/AddressesResource.java +++ b/src/api/AddressesResource.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import java.math.BigDecimal; +import java.util.Base64; import java.util.List; import javax.servlet.http.HttpServletRequest; @@ -23,12 +24,15 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import data.account.AccountBalanceData; +import data.account.AccountData; import qora.account.Account; +import qora.account.PublicKeyAccount; import qora.assets.Asset; import qora.crypto.Crypto; +import repository.DataException; import repository.Repository; import repository.RepositoryManager; -import utils.Base58; +import transform.Transformer; @Path("addresses") @Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) @@ -36,7 +40,7 @@ import utils.Base58; @ExtensionProperty(name="path", value="/Api/AddressesResource") } ) -@Tag(name = "addresses") +@Tag(name = "Addresses") public class AddressesResource { @Context @@ -56,7 +60,7 @@ public class AddressesResource { @Path("/lastreference/{address}") @Operation( summary = "Fetch reference for next transaction to be created by address", - description = "Returns the 64-byte long base58-encoded signature of last transaction created by address, failing that: the first incoming transaction to address. Returns \"false\" if there is no transactions.", + description = "Returns the base64-encoded signature of the last confirmed transaction created by address, failing that: the first incoming transaction to address. Returns \"false\" if there is no transactions.", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET lastreference:address"), @@ -68,7 +72,7 @@ public class AddressesResource { }, responses = { @ApiResponse( - description = "the base58-encoded transaction signature or \"false\"", + description = "the base64-encoded transaction signature or \"false\"", content = @Content(schema = @Schema(implementation = String.class)), extensions = { @Extension(name = "translation", properties = { @@ -79,7 +83,7 @@ public class AddressesResource { } ) public String getLastReference( - @Parameter(description = "a base58-encoded address", required = true) @PathParam("address") String address + @Parameter(description = "a base64-encoded address", required = true) @PathParam("address") String address ) { if (!Crypto.isValidAddress(address)) throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); @@ -90,14 +94,14 @@ public class AddressesResource { lastReference = account.getLastReference(); } catch (ApiException e) { throw e; - } catch (Exception e) { - throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); } if(lastReference == null || lastReference.length == 0) { return "false"; } else { - return Base58.encode(lastReference); + return Base64.getEncoder().encodeToString(lastReference); } } @@ -105,7 +109,7 @@ public class AddressesResource { @Path("/lastreference/{address}/unconfirmed") @Operation( summary = "Fetch reference for next transaction to be created by address, considering unconfirmed transactions", - description = "Returns the 64-byte long base58-encoded signature of last transaction, including unconfirmed, created by address, failing that: the first incoming transaction. Returns \\\"false\\\" if there is no transactions.", + description = "Returns the base64-encoded signature of the last confirmed/unconfirmed transaction created by address, failing that: the first incoming transaction. Returns \\\"false\\\" if there is no transactions.", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET lastreference:address:unconfirmed"), @@ -117,7 +121,7 @@ public class AddressesResource { }, responses = { @ApiResponse( - description = "the base58-encoded transaction signature", + description = "the base64-encoded transaction signature", content = @Content(schema = @Schema(implementation = String.class)), extensions = { @Extension(name = "translation", properties = { @@ -137,14 +141,14 @@ public class AddressesResource { lastReference = account.getUnconfirmedLastReference(); } catch (ApiException e) { throw e; - } catch (Exception e) { - throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); } if(lastReference == null || lastReference.length == 0) { return "false"; } else { - return Base58.encode(lastReference); + return Base64.getEncoder().encodeToString(lastReference); } } @@ -179,7 +183,8 @@ public class AddressesResource { @GET @Path("/generatingbalance/{address}") @Operation( - description = "Return the generating balance of the given address.", + summary = "Return the generating balance of the given address", + description = "Returns the effective balance of the given address, used in Proof-of-Stake calculationgs when generating a new block.", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET generatingbalance:address"), @@ -205,21 +210,20 @@ public class AddressesResource { if (!Crypto.isValidAddress(address)) throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); - try (final Repository repository = RepositoryManager.getRepository()) { + try (final Repository repository = RepositoryManager.getRepository()) { Account account = new Account(repository, address); return account.getGeneratingBalance(); - } catch (ApiException e) { throw e; - } catch (Exception e) { - throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); - } + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } } @GET @Path("/balance/{address}") @Operation( - description = "Returns the confirmed balance of the given address.", + summary = "Returns the confirmed balance of the given address", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET balance:address"), @@ -245,19 +249,20 @@ public class AddressesResource { if (!Crypto.isValidAddress(address)) throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); - try (final Repository repository = RepositoryManager.getRepository()) { + try (final Repository repository = RepositoryManager.getRepository()) { Account account = new Account(repository, address); return account.getConfirmedBalance(Asset.QORA); } catch (ApiException e) { throw e; - } catch (Exception e) { - throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); - } + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } } @GET @Path("/assetbalance/{assetid}/{address}") @Operation( + summary = "Asset-specific balance request", description = "Returns the confirmed balance of the given address for the given asset key.", extensions = { @Extension(name = "translation", properties = { @@ -284,20 +289,21 @@ public class AddressesResource { if (!Crypto.isValidAddress(address)) throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); - try (final Repository repository = RepositoryManager.getRepository()) { + try (final Repository repository = RepositoryManager.getRepository()) { Account account = new Account(repository, address); return account.getConfirmedBalance(assetid); } catch (ApiException e) { throw e; - } catch (Exception e) { - throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); - } + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } } @GET @Path("/assets/{address}") @Operation( - description = "Returns the list of assets for this address with balances.", + summary = "All assets owned by this address", + description = "Returns the list of assets for this address, with balances.", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET assets:address"), @@ -323,19 +329,19 @@ public class AddressesResource { if (!Crypto.isValidAddress(address)) throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); - try (final Repository repository = RepositoryManager.getRepository()) { + try (final Repository repository = RepositoryManager.getRepository()) { return repository.getAccountRepository().getAllBalances(address); } catch (ApiException e) { throw e; - } catch (Exception e) { - throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); - } + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } } @GET @Path("/balance/{address}/{confirmations}") @Operation( - description = "Calculates the balance of the given address after the given confirmations.", + summary = "Calculates the balance of the given address for the given confirmations", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET balance:address:confirmations"), @@ -364,7 +370,8 @@ public class AddressesResource { @GET @Path("/publickey/{address}") @Operation( - description = "Returns the 32-byte long base58-encoded account publickey of the given address.", + summary = "Address' public key", + description = "Returns the base64-encoded account public key of the given address, or \"false\" if address not known or has no public key.", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET publickey:address"), @@ -376,7 +383,7 @@ public class AddressesResource { }, responses = { @ApiResponse( - description = "the publickey", + description = "the public key", content = @Content(schema = @Schema(implementation = String.class)), extensions = { @Extension(name = "translation", properties = { @@ -387,7 +394,74 @@ public class AddressesResource { } ) public String getPublicKey(@PathParam("address") String address) { - throw new UnsupportedOperationException(); + if (!Crypto.isValidAddress(address)) + throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountData accountData = repository.getAccountRepository().getAccount(address); + + if (accountData == null) + return "false"; + + byte[] publicKey = accountData.getPublicKey(); + if (publicKey == null) + return "false"; + + return Base64.getEncoder().encodeToString(publicKey); + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } } - + + @GET + @Path("/convert/{publickey}") + @Produces(MediaType.TEXT_PLAIN) + @Operation( + summary = "Convert public key into address", + description = "Returns account address based on supplied public key. Expects base64-encoded, 32-byte public key.", + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="path", value="GET publickey:address"), + @ExtensionProperty(name="description.key", value="operation:description") + }), + @Extension(properties = { + @ExtensionProperty(name="apiErrors", value="[\"INVALID_ADDRESS\"]", parseValue = true), + }) + }, + responses = { + @ApiResponse( + description = "the address", + content = @Content(schema = @Schema(implementation = String.class)), + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="success_response:description") + }) + } + ) + } + ) + public String fromPublicKey(@PathParam("publickey") String publicKey) { + // Decode public key + byte[] publicKeyBytes; + try { + publicKeyBytes = Base64.getDecoder().decode(publicKey); + } catch (NumberFormatException e) { + throw this.apiErrorFactory.createError(ApiError.INVALID_PUBLIC_KEY, e); + } + + // Correct size for public key? + if (publicKeyBytes.length != Transformer.PUBLIC_KEY_LENGTH) + throw this.apiErrorFactory.createError(ApiError.INVALID_PUBLIC_KEY); + + try (final Repository repository = RepositoryManager.getRepository()) { + return Crypto.toAddress(publicKeyBytes); + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } + } + } diff --git a/src/api/AdminResource.java b/src/api/AdminResource.java index ffb238f4..35346c19 100644 --- a/src/api/AdminResource.java +++ b/src/api/AdminResource.java @@ -25,7 +25,7 @@ import controller.Controller; @ExtensionProperty(name="path", value="/Api/AdminResource") } ) -@Tag(name = "admin") +@Tag(name = "Admin") public class AdminResource { @Context @@ -34,7 +34,8 @@ public class AdminResource { @GET @Path("/dud") @Parameter(name = "blockSignature", description = "Block signature", schema = @Schema(type = "string", format = "byte", minLength = 84, maxLength=88)) - @Parameter(in = ParameterIn.QUERY, name = "limit", description = "Maximum number of entries to return", schema = @Schema(type = "integer", defaultValue = "10")) + @Parameter(in = ParameterIn.QUERY, name = "count", description = "Maximum number of entries to return", schema = @Schema(type = "integer", defaultValue = "10")) + @Parameter(in = ParameterIn.QUERY, name = "limit", description = "Maximum number of entries to return, 0 means unlimited", schema = @Schema(type = "integer", defaultValue = "10")) @Parameter(in = ParameterIn.QUERY, name = "offset", description = "Starting entry in results", schema = @Schema(type = "integer")) @Parameter(in = ParameterIn.QUERY, name = "includeTransactions", description = "Include associated transactions in results", schema = @Schema(type = "boolean")) public String globalParameters() { diff --git a/src/api/AnnotationPostProcessor.java b/src/api/AnnotationPostProcessor.java index f4b2f22a..57a052cc 100644 --- a/src/api/AnnotationPostProcessor.java +++ b/src/api/AnnotationPostProcessor.java @@ -50,13 +50,10 @@ public class AnnotationPostProcessor implements ReaderListener { @Override public void beforeScan(Reader reader, OpenAPI openAPI) { - LOGGER.info("beforeScan"); } @Override public void afterScan(Reader reader, OpenAPI openAPI) { - LOGGER.info("afterScan"); - // Populate Components section with reusable parameters, like "limit" and "offset" // We take the reusable parameters from AdminResource.globalParameters path "/admin/dud" Components components = openAPI.getComponents(); diff --git a/src/api/ApiDefinition.java b/src/api/ApiDefinition.java index 46e3bd1b..898f4b12 100644 --- a/src/api/ApiDefinition.java +++ b/src/api/ApiDefinition.java @@ -9,10 +9,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; @OpenAPIDefinition( info = @Info( title = "Qora API", description = "NOTE: byte-arrays currently returned as Base64 but this is likely to change to Base58" ), tags = { - @Tag(name = "addresses"), - @Tag(name = "admin"), - @Tag(name = "blocks"), - @Tag(name = "transactions") + @Tag(name = "Addresses"), + @Tag(name = "Admin"), + @Tag(name = "Blocks"), + @Tag(name = "Transactions"), + @Tag(name = "Utilities") }, extensions = { @Extension(name = "translation", properties = { diff --git a/src/api/ApiError.java b/src/api/ApiError.java index 983e18bf..b29fe66d 100644 --- a/src/api/ApiError.java +++ b/src/api/ApiError.java @@ -33,6 +33,8 @@ public enum ApiError { FEE_LESS_REQUIRED(121, 422), WALLET_NOT_IN_SYNC(122, 422), INVALID_NETWORK_ADDRESS(123, 404), + ADDRESS_NO_EXISTS(124, 404), + INVALID_CRITERIA(125, 400), //WALLET WALLET_NO_EXISTS(201, 404), diff --git a/src/api/ApiErrorFactory.java b/src/api/ApiErrorFactory.java index adfe9d31..8a41d6ed 100644 --- a/src/api/ApiErrorFactory.java +++ b/src/api/ApiErrorFactory.java @@ -64,6 +64,8 @@ public class ApiErrorFactory { this.errorMessages.put(ApiError.FEE_LESS_REQUIRED, createErrorMessageEntry(ApiError.FEE_LESS_REQUIRED, "fee less required")); this.errorMessages.put(ApiError.WALLET_NOT_IN_SYNC, createErrorMessageEntry(ApiError.WALLET_NOT_IN_SYNC, "wallet needs to be synchronized")); this.errorMessages.put(ApiError.INVALID_NETWORK_ADDRESS, createErrorMessageEntry(ApiError.INVALID_NETWORK_ADDRESS, "invalid network address")); + this.errorMessages.put(ApiError.ADDRESS_NO_EXISTS, createErrorMessageEntry(ApiError.ADDRESS_NO_EXISTS, "account address does not exist")); + this.errorMessages.put(ApiError.INVALID_CRITERIA, createErrorMessageEntry(ApiError.INVALID_CRITERIA, "invalid search criteria")); //WALLET this.errorMessages.put(ApiError.WALLET_NO_EXISTS, createErrorMessageEntry(ApiError.WALLET_NO_EXISTS, "wallet does not exist")); diff --git a/src/api/BlocksResource.java b/src/api/BlocksResource.java index 271ad9a7..3b1d33cb 100644 --- a/src/api/BlocksResource.java +++ b/src/api/BlocksResource.java @@ -12,7 +12,9 @@ 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.Base64; +import java.util.List; import javax.servlet.http.HttpServletRequest; @@ -36,7 +38,7 @@ import repository.RepositoryManager; @ExtensionProperty(name="path", value="/Api/BlocksResource") } ) -@Tag(name = "blocks") +@Tag(name = "Blocks") public class BlocksResource { @Context @@ -53,10 +55,10 @@ public class BlocksResource { } @GET - @Path("/{signature}") + @Path("/signature/{signature}") @Operation( summary = "Fetch block using base64 signature", - description = "returns the block that matches the given signature", + description = "Returns the block that matches the given signature", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET signature"), @@ -101,7 +103,7 @@ public class BlocksResource { @Path("/first") @Operation( summary = "Fetch genesis block", - description = "returns the genesis block", + description = "Returns the genesis block", extensions = @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET first"), @ExtensionProperty(name="description.key", value="operation:description") @@ -133,7 +135,7 @@ public class BlocksResource { @Path("/last") @Operation( summary = "Fetch last/newest block in blockchain", - description = "returns the last valid block", + description = "Returns the last valid block", extensions = @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET last"), @ExtensionProperty(name="description.key", value="operation:description") @@ -165,7 +167,7 @@ public class BlocksResource { @Path("/child/{signature}") @Operation( summary = "Fetch child block using base64 signature of parent block", - description = "returns the child block of the block that matches the given signature", + description = "Returns the child block of the block that matches the given signature", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET child:signature"), @@ -220,7 +222,8 @@ public class BlocksResource { @GET @Path("/generatingbalance") @Operation( - description = "calculates the generating balance of the block that will follow the last block", + summary = "Generating balance of next block", + description = "Calculates the generating balance of the block that will follow the last block", extensions = @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET generatingbalance"), @ExtensionProperty(name="description.key", value="operation:description") @@ -252,7 +255,8 @@ public class BlocksResource { @GET @Path("/generatingbalance/{signature}") @Operation( - description = "calculates the generating balance of the block that will follow the block that matches the signature", + summary = "Generating balance of block after specific block", + description = "Calculates the generating balance of the block that will follow the block that matches the signature", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET generatingbalance:signature"), @@ -302,7 +306,8 @@ public class BlocksResource { @GET @Path("/time") @Operation( - description = "calculates the time it should take for the network to generate the next block", + summary = "Estimated time to forge next block", + description = "Calculates the time it should take for the network to generate the next block", extensions = @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET time"), @ExtensionProperty(name="description.key", value="operation:description") @@ -333,7 +338,8 @@ public class BlocksResource { @GET @Path("/time/{generatingbalance}") @Operation( - description = "calculates the time it should take for the network to generate blocks when the current generating balance in the network is the specified generating balance", + summary = "Estimated time to forge block given generating balance", + description = "Calculates the time it should take for the network to generate blocks based on specified generating balance", extensions = @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET time:generatingbalance"), @ExtensionProperty(name="description.key", value="operation:description") @@ -357,7 +363,8 @@ public class BlocksResource { @GET @Path("/height") @Operation( - description = "returns the block height of the last block.", + summary = "Current blockchain height", + description = "Returns the block height of the last block.", extensions = @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET height"), @ExtensionProperty(name="description.key", value="operation:description") @@ -387,7 +394,8 @@ public class BlocksResource { @GET @Path("/height/{signature}") @Operation( - description = "returns the block height of the block that matches the given signature", + summary = "Height of specific block", + description = "Returns the block height of the block that matches the given signature", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET height:signature"), @@ -436,7 +444,8 @@ public class BlocksResource { @GET @Path("/byheight/{height}") @Operation( - description = "returns the block whith given height", + summary = "Fetch block using block height", + description = "Returns the block with given height", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET byheight:height"), @@ -458,7 +467,7 @@ public class BlocksResource { ) } ) - public BlockWithTransactions getbyHeight(@PathParam("height") int height, @Parameter(ref = "includeTransactions") @QueryParam("includeTransactions") boolean includeTransactions) { + public BlockWithTransactions getByHeight(@PathParam("height") int height, @Parameter(ref = "includeTransactions") @QueryParam("includeTransactions") boolean includeTransactions) { try (final Repository repository = RepositoryManager.getRepository()) { BlockData blockData = repository.getBlockRepository().fromHeight(height); return new BlockWithTransactions(repository, blockData, includeTransactions); @@ -469,4 +478,53 @@ public class BlocksResource { } } + @GET + @Path("/range/{height}") + @Operation( + summary = "Fetch blocks starting with given height", + description = "Returns blocks starting with given height.", + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="path", value="GET byheight:height"), + @ExtensionProperty(name="description.key", value="operation:description") + }), + @Extension(properties = { + @ExtensionProperty(name="apiErrors", value="[\"BLOCK_NO_EXISTS\"]", parseValue = true), + }) + }, + responses = { + @ApiResponse( + description = "blocks", + content = @Content(schema = @Schema(implementation = BlockWithTransactions.class)), + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="success_response:description") + }) + } + ) + } + ) + public List getBlockRange(@PathParam("height") int height, @Parameter(ref = "count") @QueryParam("count") int count) { + boolean includeTransactions = false; + + try (final Repository repository = RepositoryManager.getRepository()) { + List blocks = new ArrayList(); + + for (/* count already set */; count > 0; --count, ++height) { + BlockData blockData = repository.getBlockRepository().fromHeight(height); + if (blockData == null) + // Run out of blocks! + break; + + blocks.add(new BlockWithTransactions(repository, blockData, includeTransactions)); + } + + return blocks; + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } + } + } diff --git a/src/api/TransactionsResource.java b/src/api/TransactionsResource.java index a7ba240b..cb420212 100644 --- a/src/api/TransactionsResource.java +++ b/src/api/TransactionsResource.java @@ -12,8 +12,10 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import qora.crypto.Crypto; +import qora.transaction.Transaction.TransactionType; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import javax.servlet.http.HttpServletRequest; @@ -31,7 +33,6 @@ import data.transaction.TransactionData; import repository.DataException; import repository.Repository; import repository.RepositoryManager; -import repository.TransactionRepository; import utils.Base58; @Path("transactions") @@ -40,7 +41,7 @@ import utils.Base58; @ExtensionProperty(name="path", value="/Api/TransactionsResource") } ) -@Tag(name = "transactions") +@Tag(name = "Transactions") public class TransactionsResource { @Context @@ -57,26 +58,19 @@ public class TransactionsResource { } @GET - @Path("/address/{address}") + @Path("/signature/{signature}") @Operation( - summary = "Fetch transactions involving address", - description = "Returns list of transactions", - parameters = { - @Parameter(in = ParameterIn.PATH, name = "address", description = "Account's address", schema = @Schema(type = "string")) - }, + summary = "Fetch transaction using transaction signature", + description = "Returns transaction", extensions = { - @Extension(name = "translation", properties = { - @ExtensionProperty(name="path", value="GET block:signature"), - @ExtensionProperty(name="description.key", value="operation:description") - }), - @Extension(properties = { - @ExtensionProperty(name="apiErrors", value="[\"INVALID_ADDRESS\"]", parseValue = true), + @Extension(properties = { + @ExtensionProperty(name="apiErrors", value="[\"INVALID_SIGNATURE\", \"TRANSACTION_NO_EXISTS\"]", parseValue = true), }) - }, + }, responses = { @ApiResponse( - description = "list of transactions", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = TransactionData.class))), + description = "a transaction", + content = @Content(schema = @Schema(implementation = TransactionData.class)), extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="description.key", value="success_response:description") @@ -85,26 +79,21 @@ public class TransactionsResource { ) } ) - public List getAddressTransactions(@PathParam("address") String address, @Parameter(ref = "limit") int limit, @Parameter(ref = "offset") @QueryParam("offset") int offset) { - if (!Crypto.isValidAddress(address)) - throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); + public TransactionData getTransactions(@PathParam("signature") String signature) { + // Decode signature + byte[] signatureBytes; + try { + signatureBytes = Base64.getDecoder().decode(signature); + } catch (NumberFormatException e) { + throw this.apiErrorFactory.createError(ApiError.INVALID_SIGNATURE, e); + } try (final Repository repository = RepositoryManager.getRepository()) { - TransactionRepository txRepo = repository.getTransactionRepository(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signatureBytes); + if (transactionData == null) + throw this.apiErrorFactory.createError(ApiError.TRANSACTION_NO_EXISTS); - List signatures = txRepo.getAllSignaturesInvolvingAddress(address); - - // Pagination would take effect here (or as part of the repository access) - int fromIndex = Integer.min(offset, signatures.size()); - int toIndex = limit == 0 ? signatures.size() : Integer.min(fromIndex + limit, signatures.size()); - signatures = signatures.subList(fromIndex, toIndex); - - // Expand signatures to transactions - List transactions = new ArrayList(signatures.size()); - for (byte[] signature : signatures) - transactions.add(txRepo.fromSignature(signature)); - - return transactions; + return transactionData; } catch (ApiException e) { throw e; } catch (DataException e) { @@ -169,4 +158,89 @@ public class TransactionsResource { } } + @GET + @Path("/unconfirmed") + @Operation( + summary = "List unconfirmed transactions", + description = "Returns transactions", + responses = { + @ApiResponse( + description = "transactions", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TransactionData.class))), + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="success_response:description") + }) + } + ) + } + ) + public List getUnconfirmedTransactions() { + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getTransactionRepository().getAllUnconfirmedTransactions(); + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/search") + @Operation( + summary = "Find matching transactions", + description = "Returns transactions that match criteria. At least either txType or address must be provided.", + /* + parameters = { + @Parameter(in = ParameterIn.QUERY, name = "txType", description = "Transaction type", schema = @Schema(type = "integer")), + @Parameter(in = ParameterIn.QUERY, name = "address", description = "Account's address", schema = @Schema(type = "string")), + @Parameter(in = ParameterIn.QUERY, name = "startBlock", description = "Start block height", schema = @Schema(type = "integer")), + @Parameter(in = ParameterIn.QUERY, name = "blockLimit", description = "Maximum number of blocks to search", schema = @Schema(type = "integer")) + }, + */ + responses = { + @ApiResponse( + description = "transactions", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TransactionData.class))), + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="success_response:description") + }) + } + ) + } + ) + public List searchTransactions(@QueryParam("startBlock") Integer startBlock, @QueryParam("blockLimit") Integer blockLimit, + @QueryParam("txType") Integer txTypeNum, @QueryParam("address") String address, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") int offset) { + if ((txTypeNum == null || txTypeNum == 0) && (address == null || address.isEmpty())) + throw this.apiErrorFactory.createError(ApiError.INVALID_CRITERIA); + + TransactionType txType = null; + if (txTypeNum != null) { + txType = TransactionType.valueOf(txTypeNum); + if (txType == null) + throw this.apiErrorFactory.createError(ApiError.INVALID_CRITERIA); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + List signatures = repository.getTransactionRepository().getAllSignaturesMatchingCriteria(startBlock, blockLimit, txType, address); + + // Pagination would take effect here (or as part of the repository access) + int fromIndex = Integer.min(offset, signatures.size()); + int toIndex = limit == 0 ? signatures.size() : Integer.min(fromIndex + limit, signatures.size()); + signatures = signatures.subList(fromIndex, toIndex); + + // Expand signatures to transactions + List transactions = new ArrayList(signatures.size()); + for (byte[] signature : signatures) + transactions.add(repository.getTransactionRepository().fromSignature(signature)); + + return transactions; + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } + } + } diff --git a/src/api/UtilsResource.java b/src/api/UtilsResource.java index ba77a707..01e3ad97 100644 --- a/src/api/UtilsResource.java +++ b/src/api/UtilsResource.java @@ -19,7 +19,7 @@ import javax.ws.rs.core.MediaType; @Path("/utils") @Produces({MediaType.TEXT_PLAIN}) -@Tag(name = "utils") +@Tag(name = "Utilities") public class UtilsResource { @Context diff --git a/src/data/account/AccountData.java b/src/data/account/AccountData.java index 55e73a61..e8e5481f 100644 --- a/src/data/account/AccountData.java +++ b/src/data/account/AccountData.java @@ -5,16 +5,18 @@ public class AccountData { // Properties protected String address; protected byte[] reference; + protected byte[] publicKey; // Constructors - public AccountData(String address, byte[] reference) { + public AccountData(String address, byte[] reference, byte[] publicKey) { this.address = address; this.reference = reference; + this.publicKey = publicKey; } public AccountData(String address) { - this(address, null); + this(address, null, null); } // Getters/Setters @@ -31,6 +33,14 @@ public class AccountData { this.reference = reference; } + public byte[] getPublicKey() { + return this.publicKey; + } + + public void setPublicKey(byte[] publicKey) { + this.publicKey = publicKey; + } + // Comparison @Override diff --git a/src/qora/account/Account.java b/src/qora/account/Account.java index 78dca2eb..6bbd3347 100644 --- a/src/qora/account/Account.java +++ b/src/qora/account/Account.java @@ -30,6 +30,7 @@ public class Account { protected Account() { } + /** Construct Account business object using account's address */ public Account(Repository repository, String address) { this.repository = repository; this.accountData = new AccountData(address); @@ -118,7 +119,7 @@ public class Account { public void setConfirmedBalance(long assetId, BigDecimal balance) throws DataException { // Can't have a balance without an account - make sure it exists! - this.repository.getAccountRepository().create(this.accountData.getAddress()); + this.repository.getAccountRepository().create(this.accountData); AccountBalanceData accountBalanceData = new AccountBalanceData(this.accountData.getAddress(), assetId, balance); this.repository.getAccountRepository().save(accountBalanceData); diff --git a/src/qora/account/PrivateKeyAccount.java b/src/qora/account/PrivateKeyAccount.java index 317f6f47..2eec5f7a 100644 --- a/src/qora/account/PrivateKeyAccount.java +++ b/src/qora/account/PrivateKeyAccount.java @@ -21,8 +21,9 @@ public class PrivateKeyAccount extends PublicKeyAccount { this.repository = repository; this.seed = seed; this.keyPair = Ed25519.createKeyPair(seed); - this.publicKey = keyPair.getB(); - this.accountData = new AccountData(Crypto.toAddress(this.publicKey)); + + byte[] publicKey = keyPair.getB(); + this.accountData = new AccountData(Crypto.toAddress(publicKey), null, publicKey); } public byte[] getSeed() { diff --git a/src/qora/account/PublicKeyAccount.java b/src/qora/account/PublicKeyAccount.java index 1f28e39b..183a090c 100644 --- a/src/qora/account/PublicKeyAccount.java +++ b/src/qora/account/PublicKeyAccount.java @@ -6,23 +6,21 @@ import repository.Repository; public class PublicKeyAccount extends Account { - protected byte[] publicKey; - public PublicKeyAccount(Repository repository, byte[] publicKey) { super(repository, Crypto.toAddress(publicKey)); - this.publicKey = publicKey; + this.accountData.setPublicKey(publicKey); } protected PublicKeyAccount() { } public byte[] getPublicKey() { - return publicKey; + return this.accountData.getPublicKey(); } public boolean verify(byte[] signature, byte[] message) { - return PublicKeyAccount.verify(this.publicKey, signature, message); + return PublicKeyAccount.verify(this.accountData.getPublicKey(), signature, message); } public static boolean verify(byte[] publicKey, byte[] signature, byte[] message) { diff --git a/src/repository/AccountRepository.java b/src/repository/AccountRepository.java index bdd231a4..1559dc3a 100644 --- a/src/repository/AccountRepository.java +++ b/src/repository/AccountRepository.java @@ -9,7 +9,7 @@ public interface AccountRepository { // General account - public void create(String address) throws DataException; + public void create(AccountData accountData) throws DataException; public AccountData getAccount(String address) throws DataException; diff --git a/src/repository/TransactionRepository.java b/src/repository/TransactionRepository.java index 0413b4ab..c6a36e8c 100644 --- a/src/repository/TransactionRepository.java +++ b/src/repository/TransactionRepository.java @@ -1,6 +1,7 @@ package repository; import data.transaction.TransactionData; +import qora.transaction.Transaction.TransactionType; import java.util.List; @@ -22,6 +23,8 @@ public interface TransactionRepository { public List getAllSignaturesInvolvingAddress(String address) throws DataException; + public List getAllSignaturesMatchingCriteria(Integer startBlock, Integer blockLimit, TransactionType txType, String address) throws DataException; + /** * Returns list of unconfirmed transactions in timestamp-else-signature order. * diff --git a/src/repository/hsqldb/HSQLDBAccountRepository.java b/src/repository/hsqldb/HSQLDBAccountRepository.java index 0daca3d3..0cbaabdb 100644 --- a/src/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/repository/hsqldb/HSQLDBAccountRepository.java @@ -22,10 +22,14 @@ public class HSQLDBAccountRepository implements AccountRepository { // General account @Override - public void create(String address) throws DataException { + public void create(AccountData accountData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("Accounts"); - saveHelper.bind("account", address); + saveHelper.bind("account", accountData.getAddress()); + + byte[] publicKey = accountData.getPublicKey(); + if (publicKey != null) + saveHelper.bind("public_key", publicKey); try { saveHelper.execute(this.repository); @@ -36,11 +40,14 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public AccountData getAccount(String address) throws DataException { - try (ResultSet resultSet = this.repository.checkedExecute("SELECT reference FROM Accounts WHERE account = ?", address)) { + try (ResultSet resultSet = this.repository.checkedExecute("SELECT reference, public_key FROM Accounts WHERE account = ?", address)) { if (resultSet == null) return null; - return new AccountData(address, resultSet.getBytes(1)); + byte[] reference = resultSet.getBytes(1); + byte[] publicKey = resultSet.getBytes(2); + + return new AccountData(address, reference, publicKey); } catch (SQLException e) { throw new DataException("Unable to fetch account info from repository", e); } @@ -50,7 +57,7 @@ public class HSQLDBAccountRepository implements AccountRepository { public void save(AccountData accountData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("Accounts"); - saveHelper.bind("account", accountData.getAddress()).bind("reference", accountData.getReference()); + saveHelper.bind("account", accountData.getAddress()).bind("reference", accountData.getReference()).bind("public_key", accountData.getPublicKey()); try { saveHelper.execute(this.repository); diff --git a/src/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/repository/hsqldb/HSQLDBDatabaseUpdates.java index 0aa8c468..520723eb 100644 --- a/src/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -5,6 +5,8 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import qora.crypto.Crypto; + public class HSQLDBDatabaseUpdates { /** @@ -383,6 +385,35 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE INDEX ATTransactionsIndex on ATTransactions (AT_address)"); break; + case 28: + // Associate public keys with accounts + stmt.execute("ALTER TABLE Accounts add public_key QoraPublicKey"); + // For looking up an account by public key + stmt.execute("CREATE INDEX AccountPublicKeyIndex on Accounts (public_key)"); + + // Do not call close() on this as connection did not come from pool! + HSQLDBRepository repository = new HSQLDBRepository(connection); + + try (ResultSet resultSet = repository.checkedExecute("SELECT DISTINCT creator from Transactions")) { + if (resultSet == null) { + repository = null; + break; + } + + do { + byte[] publicKey = resultSet.getBytes(1); + + String address = Crypto.toAddress(publicKey); + + HSQLDBSaver saveHelper = new HSQLDBSaver("Accounts"); + saveHelper.bind("account", address).bind("public_key", publicKey); + saveHelper.execute(repository); + } while (resultSet.next()); + } + + repository = null; + break; + default: // nothing to do return false; diff --git a/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index a6db489b..b2d2818e 100644 --- a/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -7,6 +7,9 @@ import java.sql.Timestamp; import java.util.ArrayList; import java.util.Calendar; import java.util.List; +import java.util.StringJoiner; + +import com.google.common.base.Strings; import data.PaymentData; import data.block.BlockData; @@ -286,6 +289,87 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + @Override + public List getAllSignaturesMatchingCriteria(Integer startBlock, Integer blockLimit, TransactionType txType, String address) throws DataException { + List signatures = new ArrayList(); + + boolean hasAddress = address != null && !address.isEmpty(); + boolean hasTxType = txType != null; + boolean hasHeightRange = startBlock != null || blockLimit != null; + + if (hasHeightRange && startBlock == null) + startBlock = 1; + + String signatureColumn = "NULL"; + List bindParams = new ArrayList(); + + // Table JOINs first + List tableJoins = new ArrayList(); + + if (hasHeightRange) { + tableJoins.add("Blocks"); + tableJoins.add("BlockTransactions ON BlockTransactions.block_signature = Blocks.signature"); + signatureColumn = "BlockTransactions.transaction_signature"; + } + + if (hasTxType) { + if (hasHeightRange) + tableJoins.add("Transactions ON Transactions.signature = BlockTransactions.transaction_signature"); + else + tableJoins.add("Transactions"); + + signatureColumn = "Transactions.signature"; + } + + if (hasAddress) { + if (hasTxType) + tableJoins.add("TransactionRecipients ON TransactionRecipients.signature = Transactions.signature"); + else if (hasHeightRange) + tableJoins.add("TransactionRecipients ON TransactionRecipients.signature = BlockTransactions.transaction_signature"); + else + tableJoins.add("TransactionRecipients"); + + signatureColumn = "TransactionRecipients.signature"; + } + + // WHERE clauses next + List whereClauses = new ArrayList(); + + if (hasHeightRange) { + whereClauses.add("Blocks.height >= " + startBlock); + + if (blockLimit != null) + whereClauses.add("Blocks.height < " + (startBlock + blockLimit)); + } + + if (hasTxType) + whereClauses.add("Transactions.type = " + txType.value); + + if (hasAddress) { + whereClauses.add("TransactionRecipients.recipient = ?"); + bindParams.add(address); + } + + String sql = "SELECT " + signatureColumn + " FROM " + String.join(" JOIN ", tableJoins) + " WHERE " + String.join(" AND ", whereClauses); + System.out.println("Transaction search SQL:\n" + sql); + + // XXX We need a table for all parties involved in a transaction, not just recipients + try (ResultSet resultSet = this.repository.checkedExecute(sql, bindParams.toArray())) { + if (resultSet == null) + return signatures; + + do { + byte[] signature = resultSet.getBytes(1); + + signatures.add(signature); + } while (resultSet.next()); + + return signatures; + } catch (SQLException e) { + throw new DataException("Unable to fetch matching transaction signatures from repository", e); + } + } + @Override public List getAllUnconfirmedTransactions() throws DataException { List transactions = new ArrayList(); diff --git a/tests/test/TransactionTests.java b/tests/test/TransactionTests.java index cb73ccb2..25922aea 100644 --- a/tests/test/TransactionTests.java +++ b/tests/test/TransactionTests.java @@ -120,7 +120,7 @@ public class TransactionTests { // Create test generator account generator = new PrivateKeyAccount(repository, generatorSeed); - accountRepository.save(new AccountData(generator.getAddress(), generatorSeed)); + accountRepository.save(new AccountData(generator.getAddress(), generatorSeed, generator.getPublicKey())); accountRepository.save(new AccountBalanceData(generator.getAddress(), Asset.QORA, initialGeneratorBalance)); // Create test sender account @@ -128,7 +128,7 @@ public class TransactionTests { // Mock account reference = senderSeed; - accountRepository.save(new AccountData(sender.getAddress(), reference)); + accountRepository.save(new AccountData(sender.getAddress(), reference, sender.getPublicKey())); // Mock balance accountRepository.save(new AccountBalanceData(sender.getAddress(), Asset.QORA, initialSenderBalance));