From 3c8088e4639f27a207d4224adb4a255700a06efc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 19 Jan 2023 19:56:50 +0000 Subject: [PATCH] Removed all code duplication for Q-Apps API endpoints. Requests are now internally routed to the existing API handlers. This should allow new Q-Apps API endpoints to be added much more quickly, as well as removing the need to maintain their code separately from the regular API endpoints. --- src/main/java/org/qortal/api/Security.java | 23 +- .../api/apps/resource/AppsResource.java | 125 ++++---- .../api/resource/ArbitraryResource.java | 6 +- .../java/org/qortal/arbitrary/apps/QApp.java | 276 ------------------ 4 files changed, 87 insertions(+), 343 deletions(-) delete mode 100644 src/main/java/org/qortal/arbitrary/apps/QApp.java diff --git a/src/main/java/org/qortal/api/Security.java b/src/main/java/org/qortal/api/Security.java index ca8783ea..f009d79f 100644 --- a/src/main/java/org/qortal/api/Security.java +++ b/src/main/java/org/qortal/api/Security.java @@ -15,7 +15,21 @@ public abstract class Security { public static final String API_KEY_HEADER = "X-API-KEY"; + /** + * Check API call is allowed, retrieving the API key from the request header or GET/POST parameters where required + * @param request + */ public static void checkApiCallAllowed(HttpServletRequest request) { + checkApiCallAllowed(request, null); + } + + /** + * Check API call is allowed, retrieving the API key first from the passedApiKey parameter, with a fallback + * to the request header or GET/POST parameters when null. + * @param request + * @param passedApiKey - the API key to test, or null if it should be retrieved from the request headers. + */ + public static void checkApiCallAllowed(HttpServletRequest request, String passedApiKey) { // We may want to allow automatic authentication for local requests, if enabled in settings boolean localAuthBypassEnabled = Settings.getInstance().isLocalAuthBypassEnabled(); if (localAuthBypassEnabled) { @@ -38,7 +52,10 @@ public abstract class Security { } // We require an API key to be passed - String passedApiKey = request.getHeader(API_KEY_HEADER); + if (passedApiKey == null) { + // API call not passed as a parameter, so try the header + passedApiKey = request.getHeader(API_KEY_HEADER); + } if (passedApiKey == null) { // Try query string - this is needed to avoid a CORS preflight. See: https://stackoverflow.com/a/43881141 passedApiKey = request.getParameter("apiKey"); @@ -84,9 +101,9 @@ public abstract class Security { } } - public static void requirePriorAuthorizationOrApiKey(HttpServletRequest request, String resourceId, Service service, String identifier) { + public static void requirePriorAuthorizationOrApiKey(HttpServletRequest request, String resourceId, Service service, String identifier, String apiKey) { try { - Security.checkApiCallAllowed(request); + Security.checkApiCallAllowed(request, apiKey); } catch (ApiException e) { // API call wasn't allowed, but maybe it was pre-authorized diff --git a/src/main/java/org/qortal/api/apps/resource/AppsResource.java b/src/main/java/org/qortal/api/apps/resource/AppsResource.java index 9b02b97b..85ffb234 100644 --- a/src/main/java/org/qortal/api/apps/resource/AppsResource.java +++ b/src/main/java/org/qortal/api/apps/resource/AppsResource.java @@ -8,9 +8,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; -import org.qortal.api.ApiError; -import org.qortal.api.ApiExceptionFactory; -import org.qortal.arbitrary.apps.QApp; +import org.qortal.api.*; +import org.qortal.api.model.NameSummary; +import org.qortal.api.resource.*; import org.qortal.arbitrary.misc.Service; import org.qortal.data.account.AccountData; import org.qortal.data.arbitrary.ArbitraryResourceInfo; @@ -19,7 +19,7 @@ import org.qortal.data.at.ATData; import org.qortal.data.chat.ChatMessage; import org.qortal.data.group.GroupData; import org.qortal.data.naming.NameData; -import org.qortal.repository.DataException; +import org.qortal.utils.Base58; import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; @@ -28,6 +28,8 @@ import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import java.io.IOException; +import java.lang.reflect.Field; +import java.math.BigDecimal; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.List; @@ -83,127 +85,128 @@ public class AppsResource { @Path("/account") @Hidden // For internal Q-App API use only public AccountData getAccount(@QueryParam("address") String address) { - try { - return QApp.getAccountData(address); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + AddressesResource addressesResource = (AddressesResource) buildResource(AddressesResource.class, request, response, context); + return addressesResource.getAccountInfo(address); } @GET @Path("/account/names") @Hidden // For internal Q-App API use only - public List getAccountNames(@QueryParam("address") String address) { - try { - return QApp.getAccountNames(address); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + public List getAccountNames(@QueryParam("address") String address) { + NamesResource namesResource = (NamesResource) buildResource(NamesResource.class, request, response, context); + return namesResource.getNamesByAddress(address, 0, 0 ,false); } @GET @Path("/name") @Hidden // For internal Q-App API use only public NameData getName(@QueryParam("name") String name) { - try { - return QApp.getNameData(name); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + NamesResource namesResource = (NamesResource) buildResource(NamesResource.class, request, response, context); + return namesResource.getName(name); } @GET @Path("/chatmessages") @Hidden // For internal Q-App API use only public List searchChatMessages(@QueryParam("before") Long before, @QueryParam("after") Long after, @QueryParam("txGroupId") Integer txGroupId, @QueryParam("involving") List involvingAddresses, @QueryParam("reference") String reference, @QueryParam("chatReference") String chatReference, @QueryParam("hasChatReference") Boolean hasChatReference, @QueryParam("limit") Integer limit, @QueryParam("offset") Integer offset, @QueryParam("reverse") Boolean reverse) { - try { - return QApp.searchChatMessages(before, after, txGroupId, involvingAddresses, reference, chatReference, hasChatReference, limit, offset, reverse); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + ChatResource chatResource = (ChatResource) buildResource(ChatResource.class, request, response, context); + return chatResource.searchChat(before, after, txGroupId, involvingAddresses, reference, chatReference, hasChatReference, limit, offset, reverse); } @GET @Path("/resources") @Hidden // For internal Q-App API use only public List getResources(@QueryParam("service") Service service, @QueryParam("identifier") String identifier, @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource, @Parameter(description = "Filter names by list") @QueryParam("nameListFilter") String nameListFilter, @Parameter(description = "Include status") @QueryParam("includeStatus") Boolean includeStatus, @Parameter(description = "Include metadata") @QueryParam("includeMetadata") Boolean includeMetadata, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { - try { - return QApp.searchQdnResources(service, identifier, defaultResource, nameListFilter, includeStatus, includeMetadata, limit, offset, reverse); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + ArbitraryResource arbitraryResource = (ArbitraryResource) buildResource(ArbitraryResource.class, request, response, context); + return arbitraryResource.getResources(service, identifier, defaultResource, limit, offset, reverse, nameListFilter, includeStatus, includeMetadata); } @GET @Path("/resourcestatus") @Hidden // For internal Q-App API use only public ArbitraryResourceStatus getResourceStatus(@QueryParam("service") Service service, @QueryParam("name") String name, @QueryParam("identifier") String identifier) { - return QApp.getQdnResourceStatus(service, name, identifier); + ArbitraryResource arbitraryResource = (ArbitraryResource) buildResource(ArbitraryResource.class, request, response, context); + ApiKey apiKey = ApiService.getInstance().getApiKey(); + return arbitraryResource.getResourceStatus(apiKey.toString(), service, name, identifier, false); } @GET @Path("/resource") @Hidden // For internal Q-App API use only - public String getResource(@QueryParam("service") Service service, @QueryParam("name") String name, @QueryParam("identifier") String identifier, @QueryParam("filepath") String filepath, @QueryParam("rebuild") boolean rebuild) { - try { - return QApp.fetchQdnResource64(service, name, identifier, filepath, rebuild); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + public HttpServletResponse getResource(@QueryParam("service") Service service, @QueryParam("name") String name, @QueryParam("identifier") String identifier, @QueryParam("filepath") String filepath, @QueryParam("rebuild") boolean rebuild) { + ArbitraryResource arbitraryResource = (ArbitraryResource) buildResource(ArbitraryResource.class, request, response, context); + ApiKey apiKey = ApiService.getInstance().getApiKey(); + return arbitraryResource.get(apiKey.toString(), service, name, identifier, filepath, rebuild, false, 5); } @GET @Path("/groups") @Hidden // For internal Q-App API use only public List listGroups(@Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { - try { - return QApp.listGroups(limit, offset, reverse); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + GroupsResource groupsResource = (GroupsResource) buildResource(GroupsResource.class, request, response, context); + return groupsResource.getAllGroups(limit, offset, reverse); } @GET @Path("/balance") @Hidden // For internal Q-App API use only - public Long getBalance(@QueryParam("assetId") long assetId, @QueryParam("address") String address) { - try { - return QApp.getBalance(assetId, address); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + public BigDecimal getBalance(@QueryParam("assetId") long assetId, @QueryParam("address") String address) { + AddressesResource addressesResource = (AddressesResource) buildResource(AddressesResource.class, request, response, context); + return addressesResource.getBalance(address, assetId); } @GET @Path("/at") @Hidden // For internal Q-App API use only public ATData getAT(@QueryParam("atAddress") String atAddress) { - try { - return QApp.getAtInfo(atAddress); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + AtResource atResource = (AtResource) buildResource(AtResource.class, request, response, context); + return atResource.getByAddress(atAddress); } @GET @Path("/atdata") @Hidden // For internal Q-App API use only public String getATData(@QueryParam("atAddress") String atAddress) { - try { - return QApp.getAtData58(atAddress); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } + AtResource atResource = (AtResource) buildResource(AtResource.class, request, response, context); + return Base58.encode(atResource.getDataByAddress(atAddress)); } @GET @Path("/ats") @Hidden // For internal Q-App API use only public List listATs(@QueryParam("codeHash58") String codeHash58, @QueryParam("isExecutable") Boolean isExecutable, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { + AtResource atResource = (AtResource) buildResource(AtResource.class, request, response, context); + return atResource.getByFunctionality(codeHash58, isExecutable, limit, offset, reverse); + } + + + public static Object buildResource(Class resourceClass, HttpServletRequest request, HttpServletResponse response, ServletContext context) { try { - return QApp.listATs(codeHash58, isExecutable, limit, offset, reverse); - } catch (DataException | IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); + Object resource = resourceClass.getDeclaredConstructor().newInstance(); + + Field requestField = resourceClass.getDeclaredField("request"); + requestField.setAccessible(true); + requestField.set(resource, request); + + try { + Field responseField = resourceClass.getDeclaredField("response"); + responseField.setAccessible(true); + responseField.set(resource, response); + } catch (NoSuchFieldException e) { + // Ignore + } + + try { + Field contextField = resourceClass.getDeclaredField("context"); + contextField.setAccessible(true); + contextField.set(resource, context); + } catch (NoSuchFieldException e) { + // Ignore + } + + return resource; + } catch (Exception e) { + throw new RuntimeException("Failed to build API resource " + resourceClass.getName() + ": " + e.getMessage(), e); } } diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index c26e0188..a6c0afdf 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -266,7 +266,7 @@ public class ArbitraryResource { @PathParam("name") String name, @QueryParam("build") Boolean build) { - Security.requirePriorAuthorizationOrApiKey(request, name, service, null); + Security.requirePriorAuthorizationOrApiKey(request, name, service, null, apiKey); return ArbitraryTransactionUtils.getStatus(service, name, null, build); } @@ -288,7 +288,7 @@ public class ArbitraryResource { @PathParam("identifier") String identifier, @QueryParam("build") Boolean build) { - Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier); + Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey); return ArbitraryTransactionUtils.getStatus(service, name, identifier, build); } @@ -682,7 +682,7 @@ public class ArbitraryResource { // Authentication can be bypassed in the settings, for those running public QDN nodes if (!Settings.getInstance().isQDNAuthBypassEnabled()) { - Security.checkApiCallAllowed(request); + Security.checkApiCallAllowed(request, apiKey); } return this.download(service, name, identifier, filepath, rebuild, async, attempts); diff --git a/src/main/java/org/qortal/arbitrary/apps/QApp.java b/src/main/java/org/qortal/arbitrary/apps/QApp.java deleted file mode 100644 index 5699d290..00000000 --- a/src/main/java/org/qortal/arbitrary/apps/QApp.java +++ /dev/null @@ -1,276 +0,0 @@ -package org.qortal.arbitrary.apps; - -import org.apache.commons.lang3.ArrayUtils; -import org.bouncycastle.util.encoders.Base64; -import org.ciyam.at.MachineState; -import org.qortal.account.Account; -import org.qortal.arbitrary.ArbitraryDataFile; -import org.qortal.arbitrary.ArbitraryDataReader; -import org.qortal.arbitrary.exception.MissingDataException; -import org.qortal.arbitrary.misc.Service; -import org.qortal.asset.Asset; -import org.qortal.controller.Controller; -import org.qortal.controller.LiteNode; -import org.qortal.crypto.Crypto; -import org.qortal.data.account.AccountData; -import org.qortal.data.arbitrary.ArbitraryResourceInfo; -import org.qortal.data.arbitrary.ArbitraryResourceStatus; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.chat.ChatMessage; -import org.qortal.data.group.GroupData; -import org.qortal.data.naming.NameData; -import org.qortal.list.ResourceListManager; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.settings.Settings; -import org.qortal.utils.ArbitraryTransactionUtils; -import org.qortal.utils.Base58; - -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; - -public class QApp { - - public static AccountData getAccountData(String address) throws DataException { - if (!Crypto.isValidAddress(address)) - throw new IllegalArgumentException("Invalid address"); - - try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getAccountRepository().getAccount(address); - } - } - - public static List getAccountNames(String address) throws DataException { - if (!Crypto.isValidAddress(address)) - throw new IllegalArgumentException("Invalid address"); - - try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getNameRepository().getNamesByOwner(address); - } - } - - public static NameData getNameData(String name) throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - if (Settings.getInstance().isLite()) { - return LiteNode.getInstance().fetchNameData(name); - } else { - return repository.getNameRepository().fromName(name); - } - } - } - - public static List searchChatMessages(Long before, Long after, Integer txGroupId, List involvingAddresses, - String reference, String chatReference, Boolean hasChatReference, - Integer limit, Integer offset, Boolean reverse) throws DataException { - // Check args meet expectations - if ((txGroupId == null && involvingAddresses.size() != 2) - || (txGroupId != null && !involvingAddresses.isEmpty())) - throw new IllegalArgumentException("Invalid txGroupId or involvingAddresses"); - - // Check any provided addresses are valid - if (involvingAddresses.stream().anyMatch(address -> !Crypto.isValidAddress(address))) - throw new IllegalArgumentException("Invalid address"); - - if (before != null && before < 1500000000000L) - throw new IllegalArgumentException("Invalid timestamp"); - - byte[] referenceBytes = null; - if (reference != null) - referenceBytes = Base58.decode(reference); - - byte[] chatReferenceBytes = null; - if (chatReference != null) - chatReferenceBytes = Base58.decode(chatReference); - - try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getChatRepository().getMessagesMatchingCriteria( - before, - after, - txGroupId, - referenceBytes, - chatReferenceBytes, - hasChatReference, - involvingAddresses, - limit, offset, reverse); - } - } - - public static List searchQdnResources(Service service, String identifier, Boolean defaultResource, - String nameListFilter, Boolean includeStatus, Boolean includeMetadata, - Integer limit, Integer offset, Boolean reverse) throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - - // Treat empty identifier as null - if (identifier != null && identifier.isEmpty()) { - identifier = null; - } - - // Ensure that "default" and "identifier" parameters cannot coexist - boolean defaultRes = Boolean.TRUE.equals(defaultResource); - if (defaultRes == true && identifier != null) { - throw new IllegalArgumentException("identifier cannot be specified when requesting a default resource"); - } - - // Load filter from list if needed - List names = null; - if (nameListFilter != null) { - names = ResourceListManager.getInstance().getStringsInList(nameListFilter); - if (names.isEmpty()) { - // List doesn't exist or is empty - so there will be no matches - return new ArrayList<>(); - } - } - - List resources = repository.getArbitraryRepository() - .getArbitraryResources(service, identifier, names, defaultRes, limit, offset, reverse); - - if (resources == null) { - return new ArrayList<>(); - } - - if (includeStatus != null && includeStatus) { - resources = ArbitraryTransactionUtils.addStatusToResources(resources); - } - if (includeMetadata != null && includeMetadata) { - resources = ArbitraryTransactionUtils.addMetadataToResources(resources); - } - - return resources; - - } - } - - public static ArbitraryResourceStatus getQdnResourceStatus(Service service, String name, String identifier) { - return ArbitraryTransactionUtils.getStatus(service, name, identifier, false); - } - - public static String fetchQdnResource64(Service service, String name, String identifier, String filepath, boolean rebuild) throws DataException { - ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); - try { - - int attempts = 0; - int maxAttempts = 5; - - // Loop until we have data - while (!Controller.isStopping()) { - attempts++; - if (!arbitraryDataReader.isBuilding()) { - try { - arbitraryDataReader.loadSynchronously(rebuild); - break; - } catch (MissingDataException e) { - if (attempts > maxAttempts) { - // Give up after 5 attempts - throw new DataException("Data unavailable. Please try again later."); - } - } - } - Thread.sleep(3000L); - } - - java.nio.file.Path outputPath = arbitraryDataReader.getFilePath(); - if (outputPath == null) { - // Assume the resource doesn't exist - throw new DataException("File not found"); - } - - if (filepath == null || filepath.isEmpty()) { - // No file path supplied - so check if this is a single file resource - String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal"); - if (files.length == 1) { - // This is a single file resource - filepath = files[0]; - } - else { - throw new IllegalArgumentException("filepath is required for resources containing more than one file"); - } - } - - // TODO: limit file size that can be read into memory - java.nio.file.Path path = Paths.get(outputPath.toString(), filepath); - if (!Files.exists(path)) { - return null; - } - byte[] bytes = Files.readAllBytes(path); - if (bytes != null) { - return Base64.toBase64String(bytes); - } - throw new DataException("File contents could not be read"); - - } catch (Exception e) { - throw new DataException(String.format("Unable to fetch resource: %s", e.getMessage())); - } - } - - public static List listGroups(Integer limit, Integer offset, Boolean reverse) throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - List allGroupData = repository.getGroupRepository().getAllGroups(limit, offset, reverse); - allGroupData.forEach(groupData -> { - try { - groupData.memberCount = repository.getGroupRepository().countGroupMembers(groupData.getGroupId()); - } catch (DataException e) { - // Exclude memberCount for this group - } - }); - return allGroupData; - } - } - - public static Long getBalance(Long assetId, String address) throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - if (assetId == null) - assetId = Asset.QORT; - - Account account = new Account(repository, address); - return account.getConfirmedBalance(assetId); - } - } - - public static ATData getAtInfo(String atAddress) throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - if (atData == null) { - throw new IllegalArgumentException("AT not found"); - } - return atData; - } - } - - public static String getAtData58(String atAddress) throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); - if (atStateData == null) { - throw new IllegalArgumentException("AT not found"); - } - byte[] stateData = atStateData.getStateData(); - byte[] dataBytes = MachineState.extractDataBytes(stateData); - return Base58.encode(dataBytes); - } - } - - public static List listATs(String codeHash58, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException { - // Decode codeHash - byte[] codeHash; - try { - codeHash = Base58.decode(codeHash58); - } catch (NumberFormatException e) { - throw new IllegalArgumentException(e); - } - - // codeHash must be present and have correct length - if (codeHash == null || codeHash.length != 32) - throw new IllegalArgumentException("Invalid code hash"); - - // Impose a limit on 'limit' - if (limit != null && limit > 100) - throw new IllegalArgumentException("Limit is too high"); - - try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse); - } - } -}