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));