From 4c463f65b77fcc25295142140ba4084910940fdb Mon Sep 17 00:00:00 2001 From: DrewMPeacock Date: Mon, 8 Aug 2022 15:58:46 -0600 Subject: [PATCH 001/215] Add API handles to build CREATE_POLL and VOTE_ON_POLL transactions. --- .../qortal/api/resource/VotingResource.java | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/main/java/org/qortal/api/resource/VotingResource.java diff --git a/src/main/java/org/qortal/api/resource/VotingResource.java b/src/main/java/org/qortal/api/resource/VotingResource.java new file mode 100644 index 00000000..bd57c9f7 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/VotingResource.java @@ -0,0 +1,130 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.data.transaction.CreatePollTransactionData; +import org.qortal.data.transaction.PaymentTransactionData; +import org.qortal.data.transaction.VoteOnPollTransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.CreatePollTransactionTransformer; +import org.qortal.transform.transaction.PaymentTransactionTransformer; +import org.qortal.transform.transaction.VoteOnPollTransactionTransformer; +import org.qortal.utils.Base58; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +@Path("/Voting") +@Tag(name = "Voting") +public class VotingResource { + @Context + HttpServletRequest request; + + @POST + @Path("/CreatePoll") + @Operation( + summary = "Build raw, unsigned, CREATE_POLL transaction", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CreatePollTransactionData.class + ) + ) + ), + responses = { + @ApiResponse( + description = "raw, unsigned, CREATE_POLL transaction encoded in Base58", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.NON_PRODUCTION, ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + public String CreatePoll(CreatePollTransactionData transactionData) { + if (Settings.getInstance().isApiRestricted()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION); + + try (final Repository repository = RepositoryManager.getRepository()) { + Transaction transaction = Transaction.fromData(repository, transactionData); + + Transaction.ValidationResult result = transaction.isValidUnconfirmed(); + if (result != Transaction.ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + byte[] bytes = CreatePollTransactionTransformer.toBytes(transactionData); + return Base58.encode(bytes); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/VoteOnPoll") + @Operation( + summary = "Build raw, unsigned, VOTE_ON_POLL transaction", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = VoteOnPollTransactionData.class + ) + ) + ), + responses = { + @ApiResponse( + description = "raw, unsigned, VOTE_ON_POLL transaction encoded in Base58", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.NON_PRODUCTION, ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + public String VoteOnPoll(VoteOnPollTransactionData transactionData) { + if (Settings.getInstance().isApiRestricted()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION); + + try (final Repository repository = RepositoryManager.getRepository()) { + Transaction transaction = Transaction.fromData(repository, transactionData); + + Transaction.ValidationResult result = transaction.isValidUnconfirmed(); + if (result != Transaction.ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + byte[] bytes = VoteOnPollTransactionTransformer.toBytes(transactionData); + return Base58.encode(bytes); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + +} From 1abceada209d391f9b466a74b1ba9758e6bc4bf6 Mon Sep 17 00:00:00 2001 From: DrewMPeacock Date: Fri, 9 Sep 2022 11:20:46 -0600 Subject: [PATCH 002/215] Fix up CREATE_POLL and VOTE_ON_POLL transactions to process and validate. Added rule to enforce that a poll creator is also its owner. --- .../CreatePollTransactionData.java | 13 ++++++++++++ .../VoteOnPollTransactionData.java | 3 +++ .../transaction/CreatePollTransaction.java | 21 +++++++++++++------ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/data/transaction/CreatePollTransactionData.java b/src/main/java/org/qortal/data/transaction/CreatePollTransactionData.java index 4df7d79d..8b904aa0 100644 --- a/src/main/java/org/qortal/data/transaction/CreatePollTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/CreatePollTransactionData.java @@ -2,9 +2,11 @@ package org.qortal.data.transaction; import java.util.List; +import javax.xml.bind.Unmarshaller; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue; import org.qortal.data.voting.PollOptionData; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.TransactionType; @@ -14,8 +16,13 @@ import io.swagger.v3.oas.annotations.media.Schema; // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) @Schema(allOf = { TransactionData.class }) +@XmlDiscriminatorValue("CREATE_POLL") public class CreatePollTransactionData extends TransactionData { + + @Schema(description = "Poll creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + private byte[] pollCreatorPublicKey; + // Properties private String owner; private String pollName; @@ -29,10 +36,15 @@ public class CreatePollTransactionData extends TransactionData { super(TransactionType.CREATE_POLL); } + public void afterUnmarshal(Unmarshaller u, Object parent) { + this.creatorPublicKey = this.pollCreatorPublicKey; + } + public CreatePollTransactionData(BaseTransactionData baseTransactionData, String owner, String pollName, String description, List pollOptions) { super(Transaction.TransactionType.CREATE_POLL, baseTransactionData); + this.creatorPublicKey = baseTransactionData.creatorPublicKey; this.owner = owner; this.pollName = pollName; this.description = description; @@ -41,6 +53,7 @@ public class CreatePollTransactionData extends TransactionData { // Getters/setters + public byte[] getPollCreatorPublicKey() { return this.creatorPublicKey; } public String getOwner() { return this.owner; } diff --git a/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java b/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java index 6145d741..ac467255 100644 --- a/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java @@ -4,6 +4,7 @@ import javax.xml.bind.Unmarshaller; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue; import org.qortal.transaction.Transaction.TransactionType; import io.swagger.v3.oas.annotations.media.Schema; @@ -11,9 +12,11 @@ import io.swagger.v3.oas.annotations.media.Schema; // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) @Schema(allOf = { TransactionData.class }) +@XmlDiscriminatorValue("VOTE_ON_POLL") public class VoteOnPollTransactionData extends TransactionData { // Properties + @Schema(description = "Vote creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") private byte[] voterPublicKey; private String pollName; private int optionIndex; diff --git a/src/main/java/org/qortal/transaction/CreatePollTransaction.java b/src/main/java/org/qortal/transaction/CreatePollTransaction.java index a56322a7..1d969965 100644 --- a/src/main/java/org/qortal/transaction/CreatePollTransaction.java +++ b/src/main/java/org/qortal/transaction/CreatePollTransaction.java @@ -51,6 +51,21 @@ public class CreatePollTransaction extends Transaction { if (!Crypto.isValidAddress(this.createPollTransactionData.getOwner())) return ValidationResult.INVALID_ADDRESS; + Account creator = getCreator(); + Account owner = getOwner(); + + String creatorAddress = creator.getAddress(); + String ownerAddress = owner.getAddress(); + + // Check Owner address is the same as the creator public key + if (!creatorAddress.equals(ownerAddress)) { + return ValidationResult.INVALID_ADDRESS; + } + + // Check creator has enough funds + if (creator.getConfirmedBalance(Asset.QORT) < this.createPollTransactionData.getFee()) + return ValidationResult.NO_BALANCE; + // Check name size bounds String pollName = this.createPollTransactionData.getPollName(); int pollNameLength = Utf8.encodedLength(pollName); @@ -88,12 +103,6 @@ public class CreatePollTransaction extends Transaction { optionNames.add(pollOptionData.getOptionName()); } - Account creator = getCreator(); - - // Check creator has enough funds - if (creator.getConfirmedBalance(Asset.QORT) < this.createPollTransactionData.getFee()) - return ValidationResult.NO_BALANCE; - return ValidationResult.OK; } From 8ddcae249c09452120e70a6b286ad4398740218d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 12:05:57 +0000 Subject: [PATCH 003/215] Added gatewayLoopbackEnabled setting (default false) to allow serving gateway requests via localhost. Useful for testing, but not recommended for production environments. --- src/main/java/org/qortal/api/Security.java | 2 +- src/main/java/org/qortal/settings/Settings.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/Security.java b/src/main/java/org/qortal/api/Security.java index 4aca2c49..ca8783ea 100644 --- a/src/main/java/org/qortal/api/Security.java +++ b/src/main/java/org/qortal/api/Security.java @@ -56,7 +56,7 @@ public abstract class Security { public static void disallowLoopbackRequests(HttpServletRequest request) { try { InetAddress remoteAddr = InetAddress.getByName(request.getRemoteAddr()); - if (remoteAddr.isLoopbackAddress()) { + if (remoteAddr.isLoopbackAddress() && !Settings.getInstance().isGatewayLoopbackEnabled()) { throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Local requests not allowed"); } } catch (UnknownHostException e) { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 0423f855..bc4f4204 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -104,6 +104,7 @@ public class Settings { private Integer gatewayPort; private boolean gatewayEnabled = false; private boolean gatewayLoggingEnabled = false; + private boolean gatewayLoopbackEnabled = false; // Specific to this node private boolean wipeUnconfirmedOnStart = false; @@ -633,6 +634,10 @@ public class Settings { return this.gatewayLoggingEnabled; } + public boolean isGatewayLoopbackEnabled() { + return this.gatewayLoopbackEnabled; + } + public boolean getWipeUnconfirmedOnStart() { return this.wipeUnconfirmedOnStart; From 4232616a5fa90024cab40fe146461b06ae389a42 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 12:07:24 +0000 Subject: [PATCH 004/215] Fixed QDN website preview functionality. --- .../java/org/qortal/api/resource/RenderResource.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/RenderResource.java b/src/main/java/org/qortal/api/resource/RenderResource.java index 519e722d..ac8c9cec 100644 --- a/src/main/java/org/qortal/api/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/resource/RenderResource.java @@ -8,7 +8,6 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import java.io.*; import java.nio.file.Paths; -import java.util.Map; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -28,7 +27,6 @@ import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.controller.arbitrary.ArbitraryDataRenderManager; import org.qortal.data.transaction.ArbitraryTransactionData.*; import org.qortal.repository.DataException; -import org.qortal.settings.Settings; import org.qortal.arbitrary.ArbitraryDataFile.*; import org.qortal.utils.Base58; @@ -81,17 +79,21 @@ public class RenderResource { arbitraryDataWriter.save(); } catch (IOException | DataException | InterruptedException | MissingDataException e) { LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); } catch (RuntimeException e) { LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); } ArbitraryDataFile arbitraryDataFile = arbitraryDataWriter.getArbitraryDataFile(); if (arbitraryDataFile != null) { String digest58 = arbitraryDataFile.digest58(); if (digest58 != null) { - return "http://localhost:12393/render/hash/" + digest58 + "?secret=" + Base58.encode(arbitraryDataFile.getSecret()); + // Pre-authorize resource + ArbitraryDataResource resource = new ArbitraryDataResource(digest58, null, null, null); + ArbitraryDataRenderManager.getInstance().addToAuthorizedResources(resource); + + return "http://localhost:12391/render/hash/" + digest58 + "?secret=" + Base58.encode(arbitraryDataFile.getSecret()); } } return "Unable to generate preview URL"; From 32c2f68cb159104f6f7c82db2fe5c3c5767e7d4c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 17:36:27 +0000 Subject: [PATCH 005/215] Initial APIs and core support for Q-Apps --- Q-Apps.md | 401 ++++++++++++++++++ TestNets.md | 1 + src/main/java/org/qortal/api/ApiService.java | 2 +- .../java/org/qortal/api/GatewayService.java | 2 +- src/main/java/org/qortal/api/HTMLParser.java | 4 + .../api/apps/resource/AppsResource.java | 210 +++++++++ .../api/resource/ArbitraryResource.java | 54 +-- .../java/org/qortal/arbitrary/apps/QApp.java | 276 ++++++++++++ .../utils/ArbitraryTransactionUtils.java | 45 +- src/main/resources/q-apps/q-apps.js | 206 +++++++++ 10 files changed, 1149 insertions(+), 52 deletions(-) create mode 100644 Q-Apps.md create mode 100644 src/main/java/org/qortal/api/apps/resource/AppsResource.java create mode 100644 src/main/java/org/qortal/arbitrary/apps/QApp.java create mode 100644 src/main/resources/q-apps/q-apps.js diff --git a/Q-Apps.md b/Q-Apps.md new file mode 100644 index 00000000..18c57682 --- /dev/null +++ b/Q-Apps.md @@ -0,0 +1,401 @@ +# Qortal Project - Q-Apps Documentation + +## Introduction + +Q-Apps are static web apps written in javascript, HTML, CSS, and other static assets. The key difference between a Q-App and a fully static site is its ability to interact with both the logged-in user and on-chain data. This is achieved using the API described in this document. + + + +## Making a request + +Qortal core will automatically inject a `qortalRequest()` javascript function (a Promise) to all websites/apps. This can be used to fetch or publish data to or from the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling. + +``` +async function myfunction() { + try { + let res = await qortalRequest({ + action: "GET_ACCOUNT_DATA", + address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2" + }); + console.log(JSON.stringify(res)); // Log the response to the console + + } catch(e) { + console.log("Error: " + JSON.stringify(e)); + } +} +myfunction(); +``` + +## Timeouts + +By default, all requests will timeout after 10 seconds, and will throw an error - `The request timed out`. If you need a longer timeout - e.g. when fetching large QDN resources that may take a long time to be retried, you can use `qortalRequestWithTimeout(request, timeout)` as an alternative to `qortalRequest(request)`. + +``` +async function myfunction() { + try { + let timeout = 60000; // 60 seconds + let res = await qortalRequestWithTimeout({ + action: "FETCH_QDN_RESOURCE", + name: "QortalDemo", + service: "THUMBNAIL", + identifier: "qortal_avatar" + }, timeout); + + // Do something with the avatar here + + } catch(e) { + console.log("Error: " + JSON.stringify(e)); + } +} +myfunction(); +``` + +## Supported methods + +Here is a list of currently supported methods: +- GET_ACCOUNT_DATA +- GET_ACCOUNT_NAMES +- GET_NAME_DATA +- SEARCH_QDN_RESOURCES +- GET_QDN_RESOURCE_STATUS +- FETCH_QDN_RESOURCE +- PUBLISH_QDN_RESOURCE +- GET_WALLET_BALANCE +- GET_BALANCE +- SEND_COIN +- SEARCH_CHAT_MESSAGES +- SEND_CHAT_MESSAGE +- LIST_GROUPS +- JOIN_GROUP +- DEPLOY_AT +- GET_AT +- GET_AT_DATA + +More functionality will be added in the future. + +## Example Requests + +Here is some example requests for each of the above: + +### Get account data +``` +let res = await qortalRequest({ + action: "GET_ACCOUNT_DATA", + address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2" + }); +``` + +### Get names owned by account +``` +let res = await qortalRequest({ + action: "GET_ACCOUNT_NAMES", + address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2" + }); +``` + +### Get name data +``` +let res = await qortalRequest({ + action: "GET_NAME_DATA", + name: "QortalDemo" +}); +``` + + +### Search QDN resources +``` +let res = await qortalRequest({ + action: "SEARCH_QDN_RESOURCES", + service: "THUMBNAIL", + identifier: "qortal_avatar", // Optional + default: true, // Optional + nameListFilter: "FollowedNames", // Optional + includeStatus: false, + includeMetadata: false, + limit: 100, + offset: 0, + reverse: true +}); +``` + +### Fetch QDN single file resource +Data is returned in the base64 format +``` +let res = await qortalRequest({ + action: "FETCH_QDN_RESOURCE", + name: "QortalDemo", + service: "THUMBNAIL", + identifier: "qortal_avatar", // Optional. If omitted, the default resource is returned, or you can alternatively use the keyword "default" + rebuild: false +}); +``` + +### Fetch file from multi file QDN resource +Data is returned in the base64 format +``` +let res = await qortalRequest({ + action: "FETCH_QDN_RESOURCE", + name: "QortalDemo", + service: "WEBSITE", + identifier: "default", // Optional. If omitted, the default resource is returned, or you can alternatively request that using the keyword "default", as shown here + filepath: "index.html", // Required only for resources containing more than one file + rebuild: false +}); +``` + +### Get QDN resource status +``` +let res = await qortalRequest({ + action: "GET_QDN_RESOURCE_STATUS", + name: "QortalDemo", + service: "THUMBNAIL", + identifier: "qortal_avatar" // Optional +}); +``` + +### Publish QDN resource +_Requires user approval_ +``` +await qortalRequest({ + action: "PUBLISH_QDN_RESOURCE", + name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list + service: "WEBSITE", + data64: "base64_encoded_data", + title: "Title", + description: "Description", + category: "TECHNOLOGY", + tags: ["tag1", "tag2", "tag3", "tag4", "tag5"] +}); +``` + +### Get wallet balance (QORT) +_Requires user approval_ +``` +await qortalRequest({ + action: "GET_WALLET_BALANCE", + coin: "QORT" +}); +``` + +### Get wallet balance (foreign coin) +_Requires user approval_ +``` +await qortalRequest({ + action: "GET_WALLET_BALANCE", + coin: "LTC" +}); +``` + +### Get address or asset balance +``` +let res = await qortalRequest({ + action: "GET_BALANCE", + address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2" +}); +``` +``` +let res = await qortalRequest({ + action: "GET_BALANCE", + assetId: 1, + address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2" +}); +``` + +### Send coin to address +_Requires user approval_ +``` +await qortalRequest({ + action: "SEND_COIN", + coin: "QORT", + destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", + amount: 100000000, // 1 QORT + fee: 10000 // 0.0001 QORT +}); +``` + +### Send coin to address +_Requires user approval_ +``` +await qortalRequest({ + action: "SEND_COIN", + coin: "LTC", + destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y", + amount: 100000000, // 1 LTC + fee: 20 // 0.00000020 LTC per byte +}); +``` + +### Search or list chat messages +``` +let res = await qortalRequest({ + action: "SEARCH_CHAT_MESSAGES", + before: 999999999999999, + after: 0, + txGroupId: 0, // Optional (must specify either txGroupId or two involving addresses) + // involving: ["QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", "QSefrppsDCsZebcwrqiM1gNbWq7YMDXtG2"], // Optional (must specify either txGroupId or two involving addresses) + // reference: "reference", // Optional + // chatReference: "chatreference", // Optional + // hasChatReference: true, // Optional + limit: 100, + offset: 0, + reverse: true +}); +``` + +### Send a group chat message +_Requires user approval_ +``` +await qortalRequest({ + action: "SEND_CHAT_MESSAGE", + groupId: 0, + message: "Test" +}); +``` + +### Send a private chat message +_Requires user approval_ +``` +await qortalRequest({ + action: "SEND_CHAT_MESSAGE", + destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", + message: "Test" +}); +``` + +### List groups +``` +let res = await qortalRequest({ + action: "LIST_GROUPS", + limit: 100, + offset: 0, + reverse: true +}); +``` + +### Join a group +_Requires user approval_ +``` +await qortalRequest({ + action: "JOIN_GROUP", + groupId: 100 +}); +``` + + +### Deploy an AT +_Requires user approval_ +``` +let res = await qortalRequest({ + action: "DEPLOY_AT", + creationBytes: "12345", + name: "test name", + description: "test description", + type: "test type", + tags: "test tags", + amount: 100000000, // 1 QORT + assetId: 0, + fee: 20000 // 0.0002 QORT +}); +``` + +### Get AT info +``` +let res = await qortalRequest({ + action: "GET_AT", + atAddress: "ASRUsCjk6fa5bujv3oWYmWaVqNtvxydpPH" +}); +``` + +### Get AT data bytes (base58 encoded) +``` +let res = await qortalRequest({ + action: "GET_AT_DATA", + atAddress: "ASRUsCjk6fa5bujv3oWYmWaVqNtvxydpPH" +}); +``` + +### List ATs by functionality +``` +let res = await qortalRequest({ + action: "LIST_ATS", + codeHash58: "4KdJETRAdymE7dodDmJbf5d9L1bp4g5Nxky8m47TBkvA", + isExecutable: true, + limit: 100, + offset: 0, + reverse: true +}); +``` + + +## Sample App + +Here is a sample application to display the logged-in user's avatar: +``` + + + + + + + + +``` + + +## Testing and Development + +Publishing an in-development app to mainnet isn't recommended. There are several options for developing and testing a Q-app before publishing to mainnet: + +### Preview mode + +All read-only operations can be tested using preview mode. It can be used as follows: + +1. Ensure Qortal core is running locally on the machine you are developing on. Previewing via a remote node is not currently possible. +2. Make a local API call to `POST /render/preview`, passing in the API key (found in apikey.txt), and the path to the root of your Q-App, for example: +``` +curl -X POST "http://localhost:12391/render/preview" -H "X-API-KEY: apiKeyGoesHere" -d "/home/username/Websites/MyApp" +``` +3. This returns a URL, which can be copied and pasted into a browser to view the preview +4. Modify the Q-App as required, then repeat from step 2 to generate a new preview URL + +This is a short term method until preview functionality has been implemented within the UI. + + +### Single node testnet + +For full read/write testing of a Q-App, you can set up a single node testnet (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](TestNets.md#quick-start). \ No newline at end of file diff --git a/TestNets.md b/TestNets.md index b4b9feed..dd84e1a1 100644 --- a/TestNets.md +++ b/TestNets.md @@ -110,6 +110,7 @@ Your options are: } ``` + ## Quick start Here are some steps to quickly get a single node testnet up and running with a generic minting account: 1. Start with template `settings-test.json`, and create a `testchain.json` based on mainnet's blockchain.json (or obtain one from Qortal developers). These should be in the same directory as the jar. diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 78c9250c..78bccb6a 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -53,7 +53,7 @@ public class ApiService { private ApiService() { this.config = new ResourceConfig(); - this.config.packages("org.qortal.api.resource"); + this.config.packages("org.qortal.api.resource", "org.qortal.api.apps.resource"); this.config.register(OpenApiResource.class); this.config.register(ApiDefinition.class); this.config.register(AnnotationPostProcessor.class); diff --git a/src/main/java/org/qortal/api/GatewayService.java b/src/main/java/org/qortal/api/GatewayService.java index 030a0f2f..0c8f471d 100644 --- a/src/main/java/org/qortal/api/GatewayService.java +++ b/src/main/java/org/qortal/api/GatewayService.java @@ -37,7 +37,7 @@ public class GatewayService { private GatewayService() { this.config = new ResourceConfig(); - this.config.packages("org.qortal.api.gateway.resource"); + this.config.packages("org.qortal.api.gateway.resource", "org.qortal.api.apps.resource"); this.config.register(OpenApiResource.class); this.config.register(ApiDefinition.class); this.config.register(AnnotationPostProcessor.class); diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 026d9210..a80b0b1e 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -25,6 +25,10 @@ public class HTMLParser { String baseUrl = this.linkPrefix + "/"; Elements head = document.getElementsByTag("head"); if (!head.isEmpty()) { + // Add q-apps script tag + String qAppsScriptElement = String.format(" From 613ce84df8ebd68fd8bae8dfd26873df35d4d122 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 18:11:44 +0000 Subject: [PATCH 008/215] More documentation updates --- Q-Apps.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index d1f503f7..d1e59383 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -8,7 +8,7 @@ Q-Apps are static web apps written in javascript, HTML, CSS, and other static as ## Making a request -Qortal core will automatically inject a `qortalRequest()` javascript function (a Promise) to all websites/apps. This can be used to fetch or publish data to or from the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling. +Qortal core will automatically inject a `qortalRequest()` javascript function to all websites/apps, which returns a Promise. This can be used to fetch data or publish data to the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling. ``` async function myfunction() { @@ -28,7 +28,7 @@ myfunction(); ## Timeouts -By default, all requests will timeout after 10 seconds, and will throw an error - `The request timed out`. If you need a longer timeout - e.g. when fetching large QDN resources that may take a long time to be retried, you can use `qortalRequestWithTimeout(request, timeout)` as an alternative to `qortalRequest(request)`. +By default, all requests will timeout after 10 seconds, and will throw an error - `The request timed out`. If you need a longer timeout - e.g. when fetching large QDN resources that may take a long time to be retrieved, you can use `qortalRequestWithTimeout(request, timeout)` as an alternative to `qortalRequest(request)`. ``` async function myfunction() { @@ -72,6 +72,7 @@ Here is a list of currently supported methods: - DEPLOY_AT - GET_AT - GET_AT_DATA +- LIST_ATS More functionality will be added in the future. From 2c78f4b45b0102f60da3764be409a60d3f8f67fe Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 18:25:30 +0000 Subject: [PATCH 009/215] Fixed typo and reworded "methods" to "actions", for consistency with the code. --- Q-Apps.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index d1e59383..0e60e7e0 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -50,9 +50,9 @@ async function myfunction() { myfunction(); ``` -## Supported methods +## Supported actions -Here is a list of currently supported methods: +Here is a list of currently supported actions: - GET_ACCOUNT_ADDRESS - GET_ACCOUNT_PUBLIC_KEY - GET_ACCOUNT_DATA @@ -78,7 +78,7 @@ More functionality will be added in the future. ## Example Requests -Here is some example requests for each of the above: +Here are some example requests for each of the above: ### Get address of logged in account _Will likely require user approval_ From 3c8088e4639f27a207d4224adb4a255700a06efc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 19 Jan 2023 19:56:50 +0000 Subject: [PATCH 010/215] 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); - } - } -} From ca80fd5f9ccadad71398639b18e5fa5b15c927f4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 19 Jan 2023 20:05:46 +0000 Subject: [PATCH 011/215] Added "FETCH_BLOCK" and "FETCH_BLOCK_RANGE" Q-Apps actions. --- Q-Apps.md | 28 +++++++++++++++++++ .../api/apps/resource/AppsResource.java | 25 +++++++++++++++++ src/main/resources/q-apps/q-apps.js | 22 +++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 0e60e7e0..1c11eecb 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -73,6 +73,8 @@ Here is a list of currently supported actions: - GET_AT - GET_AT_DATA - LIST_ATS +- FETCH_BLOCK +- FETCH_BLOCK_RANGE More functionality will be added in the future. @@ -345,6 +347,32 @@ let res = await qortalRequest({ }); ``` +### Fetch block by signature +``` +let res = await qortalRequest({ + action: "FETCH_BLOCK", + signature: "875yGFUy1zHV2hmxNWzrhtn9S1zkeD7SQppwdXFysvTXrankCHCz4iyAUgCBM3GjvibbnyRQpriuy1cyu953U1u5uQdzuH3QjQivi9UVwz86z1Akn17MGd5Z5STjpDT7248K6vzMamuqDei57Znonr8GGgn8yyyABn35CbZUCeAuXju" +}); +``` + +### Fetch block by height +``` +let res = await qortalRequest({ + action: "FETCH_BLOCK", + height: "1139850" +}); +``` + +### Fetch a range of blocks +``` +let res = await qortalRequest({ + action: "FETCH_BLOCK_RANGE", + height: "1139800", + count: 20, + reverse: false +}); +``` + ## Sample App 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 85ffb234..4d82804a 100644 --- a/src/main/java/org/qortal/api/apps/resource/AppsResource.java +++ b/src/main/java/org/qortal/api/apps/resource/AppsResource.java @@ -16,6 +16,7 @@ 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.block.BlockData; import org.qortal.data.chat.ChatMessage; import org.qortal.data.group.GroupData; import org.qortal.data.naming.NameData; @@ -179,6 +180,30 @@ public class AppsResource { return atResource.getByFunctionality(codeHash58, isExecutable, limit, offset, reverse); } + @GET + @Path("/block") + @Hidden // For internal Q-App API use only + public BlockData fetchBlockByHeight(@QueryParam("signature") String signature58, @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { + BlocksResource blocksResource = (BlocksResource) buildResource(BlocksResource.class, request, response, context); + return blocksResource.getBlock(signature58, includeOnlineSignatures); + } + + @GET + @Path("/block/byheight") + @Hidden // For internal Q-App API use only + public BlockData fetchBlockByHeight(@QueryParam("height") int height, @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { + BlocksResource blocksResource = (BlocksResource) buildResource(BlocksResource.class, request, response, context); + return blocksResource.getByHeight(height, includeOnlineSignatures); + } + + @GET + @Path("/block/range") + @Hidden // For internal Q-App API use only + public List getBlockRange(@QueryParam("height") int height, @Parameter(ref = "count") @QueryParam("count") int count, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse, @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { + BlocksResource blocksResource = (BlocksResource) buildResource(BlocksResource.class, request, response, context); + return blocksResource.getBlockRange(height, count, reverse, includeOnlineSignatures); + } + public static Object buildResource(Class resourceClass, HttpServletRequest request, HttpServletResponse response, ServletContext context) { try { diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 1a108d68..40a3731d 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -155,6 +155,28 @@ window.addEventListener("message", (event) => { response = httpGet(url); break; + case "FETCH_BLOCK": + if (data.signature != null) { + url = "/apps/block?"; + url = url.concat("&signature=" + data.signature); + } + else if (data.height != null) { + url = "/apps/block/byheight?"; + url = url.concat("&height=" + data.height); + } + if (data.includeOnlineSignatures != null) url = url.concat("&includeOnlineSignatures=" + data.includeOnlineSignatures); + response = httpGet(url); + break; + + case "FETCH_BLOCK_RANGE": + url = "/apps/block/range?"; + if (data.height != null) url = url.concat("&height=" + data.height); + if (data.count != null) url = url.concat("&count=" + data.count); + if (data.reverse != null) url = url.concat("&reverse=" + data.reverse); + if (data.includeOnlineSignatures != null) url = url.concat("&includeOnlineSignatures=" + data.includeOnlineSignatures); + response = httpGet(url); + break; + default: // Pass to parent (UI), in case they can fulfil this request event.data.requestedHandler = "UI"; From 86d6037af3a63fb517148dc326e3b71f230dd675 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 19 Jan 2023 20:22:29 +0000 Subject: [PATCH 012/215] Added "SEARCH_TRANSACTIONS" action. --- Q-Apps.md | 19 +++++++++++++++++++ .../api/apps/resource/AppsResource.java | 10 ++++++++++ src/main/resources/q-apps/q-apps.js | 13 +++++++++++++ 3 files changed, 42 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 1c11eecb..72b4f34b 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -75,6 +75,7 @@ Here is a list of currently supported actions: - LIST_ATS - FETCH_BLOCK - FETCH_BLOCK_RANGE +- SEARCH_TRANSACTIONS More functionality will be added in the future. @@ -373,6 +374,24 @@ let res = await qortalRequest({ }); ``` +### Search transactions +``` +let res = await qortalRequest({ + action: "SEARCH_TRANSACTIONS", + // startBlock: 1139000, + // blockLimit: 1000, + txGroupId: 0, + txType: [ + "PAYMENT", + "REWARD_SHARE" + ], + confirmationStatus: "CONFIRMED", + limit: 10, + offset: 0, + reverse: false +}); +``` + ## Sample App 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 4d82804a..32b364b2 100644 --- a/src/main/java/org/qortal/api/apps/resource/AppsResource.java +++ b/src/main/java/org/qortal/api/apps/resource/AppsResource.java @@ -20,6 +20,8 @@ import org.qortal.data.block.BlockData; import org.qortal.data.chat.ChatMessage; import org.qortal.data.group.GroupData; import org.qortal.data.naming.NameData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.transaction.Transaction; import org.qortal.utils.Base58; import javax.servlet.ServletContext; @@ -204,6 +206,14 @@ public class AppsResource { return blocksResource.getBlockRange(height, count, reverse, includeOnlineSignatures); } + @GET + @Path("/transactions/search") + @Hidden // For internal Q-App API use only + public List searchTransactions(@QueryParam("startBlock") Integer startBlock, @QueryParam("blockLimit") Integer blockLimit, @QueryParam("txGroupId") Integer txGroupId, @QueryParam("txType") List txTypes, @QueryParam("address") String address, @Parameter() @QueryParam("confirmationStatus") TransactionsResource.ConfirmationStatus confirmationStatus, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { + TransactionsResource transactionsResource = (TransactionsResource) buildResource(TransactionsResource.class, request, response, context); + return transactionsResource.searchTransactions(startBlock, blockLimit, txGroupId, txTypes, address, confirmationStatus, limit, offset, reverse); + } + public static Object buildResource(Class resourceClass, HttpServletRequest request, HttpServletResponse response, ServletContext context) { try { diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 40a3731d..b6e75404 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -177,6 +177,19 @@ window.addEventListener("message", (event) => { response = httpGet(url); break; + case "SEARCH_TRANSACTIONS": + url = "/apps/transactions/search?"; + if (data.startBlock != null) url = url.concat("&startBlock=" + data.startBlock); + if (data.blockLimit != null) url = url.concat("&blockLimit=" + data.blockLimit); + if (data.txGroupId != null) url = url.concat("&txGroupId=" + data.txGroupId); + if (data.txType != null) data.txType.forEach((x, i) => url = url.concat("&txType=" + x)); + if (data.confirmationStatus != null) url = url.concat("&confirmationStatus=" + data.confirmationStatus); + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + response = httpGet(url); + break; + default: // Pass to parent (UI), in case they can fulfil this request event.data.requestedHandler = "UI"; From 57eacbdd59ac28822474bb6de2d144a382700b6d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 19 Jan 2023 20:47:06 +0000 Subject: [PATCH 013/215] Added "GET_PRICE" action. --- Q-Apps.md | 11 +++++++++++ .../org/qortal/api/apps/resource/AppsResource.java | 9 +++++++++ src/main/resources/q-apps/q-apps.js | 8 ++++++++ 3 files changed, 28 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 72b4f34b..12a49e3d 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -76,6 +76,7 @@ Here is a list of currently supported actions: - FETCH_BLOCK - FETCH_BLOCK_RANGE - SEARCH_TRANSACTIONS +- GET_PRICE More functionality will be added in the future. @@ -392,6 +393,16 @@ let res = await qortalRequest({ }); ``` +### Get an estimate of the QORT price +``` +let res = await qortalRequest({ + action: "GET_PRICE", + blockchain: "LITECOIN", + // maxtrades: 10, + inverse: true +}); +``` + ## Sample App 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 32b364b2..db72a13c 100644 --- a/src/main/java/org/qortal/api/apps/resource/AppsResource.java +++ b/src/main/java/org/qortal/api/apps/resource/AppsResource.java @@ -12,6 +12,7 @@ import org.qortal.api.*; import org.qortal.api.model.NameSummary; import org.qortal.api.resource.*; import org.qortal.arbitrary.misc.Service; +import org.qortal.crosschain.SupportedBlockchain; import org.qortal.data.account.AccountData; import org.qortal.data.arbitrary.ArbitraryResourceInfo; import org.qortal.data.arbitrary.ArbitraryResourceStatus; @@ -214,6 +215,14 @@ public class AppsResource { return transactionsResource.searchTransactions(startBlock, blockLimit, txGroupId, txTypes, address, confirmationStatus, limit, offset, reverse); } + @GET + @Path("/price") + @Hidden // For internal Q-App API use only + public long getPrice(@QueryParam("blockchain") SupportedBlockchain foreignBlockchain, @QueryParam("maxtrades") Integer maxtrades, @QueryParam("inverse") Boolean inverse) { + CrossChainResource crossChainResource = (CrossChainResource) buildResource(CrossChainResource.class, request, response, context); + return crossChainResource.getTradePriceEstimate(foreignBlockchain, maxtrades, inverse); + } + public static Object buildResource(Class resourceClass, HttpServletRequest request, HttpServletResponse response, ServletContext context) { try { diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index b6e75404..2a2c04a5 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -190,6 +190,14 @@ window.addEventListener("message", (event) => { response = httpGet(url); break; + case "GET_PRICE": + url = "/apps/price?"; + if (data.blockchain != null) url = url.concat("&blockchain=" + data.blockchain); + if (data.maxtrades != null) url = url.concat("&maxtrades=" + data.maxtrades); + if (data.inverse != null) url = url.concat("&inverse=" + data.inverse); + response = httpGet(url); + break; + default: // Pass to parent (UI), in case they can fulfil this request event.data.requestedHandler = "UI"; From 8dffe1e3ac884161c08234cd5859475fcf02fde4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Jan 2023 18:59:46 +0000 Subject: [PATCH 014/215] Another rewrite of Q-App APIs, which removes the /apps/* redirects and instead calls the main APIs directly. - All APIs are now served over the gateway and domain map, with the exception of /admin/* - AdminResource moved to a "restricted" folder, so that it isn't served over the gateway/domainMap ports. - This opens the door to websites/apps calling core APIs directly for certain read-only functions, as an alternative to using qortalRequest(). --- src/main/java/org/qortal/api/ApiService.java | 3 +- .../java/org/qortal/api/DomainMapService.java | 3 +- .../java/org/qortal/api/GatewayService.java | 2 +- .../api/apps/resource/AppsResource.java | 257 ------------------ .../org/qortal/api/resource/AppsResource.java | 57 ++++ .../resource/AdminResource.java | 2 +- src/main/resources/q-apps/q-apps.js | 63 ++--- .../org/qortal/test/api/AdminApiTests.java | 2 +- 8 files changed, 89 insertions(+), 300 deletions(-) delete mode 100644 src/main/java/org/qortal/api/apps/resource/AppsResource.java create mode 100644 src/main/java/org/qortal/api/resource/AppsResource.java rename src/main/java/org/qortal/api/{ => restricted}/resource/AdminResource.java (99%) diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 78bccb6a..4676fa49 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -14,7 +14,6 @@ import java.security.SecureRandom; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; -import org.checkerframework.checker.units.qual.A; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.rewrite.handler.RedirectPatternRule; import org.eclipse.jetty.rewrite.handler.RewriteHandler; @@ -53,7 +52,7 @@ public class ApiService { private ApiService() { this.config = new ResourceConfig(); - this.config.packages("org.qortal.api.resource", "org.qortal.api.apps.resource"); + this.config.packages("org.qortal.api.resource", "org.qortal.api.restricted.resource"); this.config.register(OpenApiResource.class); this.config.register(ApiDefinition.class); this.config.register(AnnotationPostProcessor.class); diff --git a/src/main/java/org/qortal/api/DomainMapService.java b/src/main/java/org/qortal/api/DomainMapService.java index ba0fa067..f5eb8105 100644 --- a/src/main/java/org/qortal/api/DomainMapService.java +++ b/src/main/java/org/qortal/api/DomainMapService.java @@ -3,7 +3,6 @@ package org.qortal.api; import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.rewrite.handler.RewriteHandler; -import org.eclipse.jetty.rewrite.handler.RewritePatternRule; import org.eclipse.jetty.server.*; import org.eclipse.jetty.server.handler.ErrorHandler; import org.eclipse.jetty.server.handler.InetAccessHandler; @@ -38,7 +37,7 @@ public class DomainMapService { private DomainMapService() { this.config = new ResourceConfig(); - this.config.packages("org.qortal.api.domainmap.resource"); + this.config.packages("org.qortal.api.resource", "org.qortal.api.domainmap.resource"); this.config.register(OpenApiResource.class); this.config.register(ApiDefinition.class); this.config.register(AnnotationPostProcessor.class); diff --git a/src/main/java/org/qortal/api/GatewayService.java b/src/main/java/org/qortal/api/GatewayService.java index 0c8f471d..cebec61b 100644 --- a/src/main/java/org/qortal/api/GatewayService.java +++ b/src/main/java/org/qortal/api/GatewayService.java @@ -37,7 +37,7 @@ public class GatewayService { private GatewayService() { this.config = new ResourceConfig(); - this.config.packages("org.qortal.api.gateway.resource", "org.qortal.api.apps.resource"); + this.config.packages("org.qortal.api.resource", "org.qortal.api.gateway.resource"); this.config.register(OpenApiResource.class); this.config.register(ApiDefinition.class); this.config.register(AnnotationPostProcessor.class); diff --git a/src/main/java/org/qortal/api/apps/resource/AppsResource.java b/src/main/java/org/qortal/api/apps/resource/AppsResource.java deleted file mode 100644 index db72a13c..00000000 --- a/src/main/java/org/qortal/api/apps/resource/AppsResource.java +++ /dev/null @@ -1,257 +0,0 @@ -package org.qortal.api.apps.resource; - -import com.google.common.io.Resources; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -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.*; -import org.qortal.api.model.NameSummary; -import org.qortal.api.resource.*; -import org.qortal.arbitrary.misc.Service; -import org.qortal.crosschain.SupportedBlockchain; -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.block.BlockData; -import org.qortal.data.chat.ChatMessage; -import org.qortal.data.group.GroupData; -import org.qortal.data.naming.NameData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.transaction.Transaction; -import org.qortal.utils.Base58; - -import javax.servlet.ServletContext; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -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; - - -@Path("/apps") -@Tag(name = "Apps") -public class AppsResource { - - @Context HttpServletRequest request; - @Context HttpServletResponse response; - @Context ServletContext context; - - @GET - @Path("/q-apps.js") - @Hidden // For internal Q-App API use only - @Operation( - summary = "Javascript interface for Q-Apps", - responses = { - @ApiResponse( - description = "javascript", - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string" - ) - ) - ) - } - ) - public String getQAppsJs() { - URL url = Resources.getResource("q-apps/q-apps.js"); - try { - return Resources.toString(url, StandardCharsets.UTF_8); - } catch (IOException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND); - } - } - - @GET - @Path("/q-apps-helper.js") - @Hidden // For testing only - public String getQAppsHelperJs() { - URL url = Resources.getResource("q-apps/q-apps-helper.js"); - try { - return Resources.toString(url, StandardCharsets.UTF_8); - } catch (IOException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND); - } - } - - @GET - @Path("/account") - @Hidden // For internal Q-App API use only - public AccountData getAccount(@QueryParam("address") String address) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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 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) { - 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 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) { - 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) { - 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); - } - - @GET - @Path("/block") - @Hidden // For internal Q-App API use only - public BlockData fetchBlockByHeight(@QueryParam("signature") String signature58, @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { - BlocksResource blocksResource = (BlocksResource) buildResource(BlocksResource.class, request, response, context); - return blocksResource.getBlock(signature58, includeOnlineSignatures); - } - - @GET - @Path("/block/byheight") - @Hidden // For internal Q-App API use only - public BlockData fetchBlockByHeight(@QueryParam("height") int height, @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { - BlocksResource blocksResource = (BlocksResource) buildResource(BlocksResource.class, request, response, context); - return blocksResource.getByHeight(height, includeOnlineSignatures); - } - - @GET - @Path("/block/range") - @Hidden // For internal Q-App API use only - public List getBlockRange(@QueryParam("height") int height, @Parameter(ref = "count") @QueryParam("count") int count, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse, @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { - BlocksResource blocksResource = (BlocksResource) buildResource(BlocksResource.class, request, response, context); - return blocksResource.getBlockRange(height, count, reverse, includeOnlineSignatures); - } - - @GET - @Path("/transactions/search") - @Hidden // For internal Q-App API use only - public List searchTransactions(@QueryParam("startBlock") Integer startBlock, @QueryParam("blockLimit") Integer blockLimit, @QueryParam("txGroupId") Integer txGroupId, @QueryParam("txType") List txTypes, @QueryParam("address") String address, @Parameter() @QueryParam("confirmationStatus") TransactionsResource.ConfirmationStatus confirmationStatus, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { - TransactionsResource transactionsResource = (TransactionsResource) buildResource(TransactionsResource.class, request, response, context); - return transactionsResource.searchTransactions(startBlock, blockLimit, txGroupId, txTypes, address, confirmationStatus, limit, offset, reverse); - } - - @GET - @Path("/price") - @Hidden // For internal Q-App API use only - public long getPrice(@QueryParam("blockchain") SupportedBlockchain foreignBlockchain, @QueryParam("maxtrades") Integer maxtrades, @QueryParam("inverse") Boolean inverse) { - CrossChainResource crossChainResource = (CrossChainResource) buildResource(CrossChainResource.class, request, response, context); - return crossChainResource.getTradePriceEstimate(foreignBlockchain, maxtrades, inverse); - } - - - public static Object buildResource(Class resourceClass, HttpServletRequest request, HttpServletResponse response, ServletContext context) { - try { - 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/AppsResource.java b/src/main/java/org/qortal/api/resource/AppsResource.java new file mode 100644 index 00000000..2d048c00 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/AppsResource.java @@ -0,0 +1,57 @@ +package org.qortal.api.resource; + +import com.google.common.io.Resources; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +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.*; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.*; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; + + +@Path("/apps") +@Tag(name = "Apps") +public class AppsResource { + + @Context HttpServletRequest request; + @Context HttpServletResponse response; + @Context ServletContext context; + + @GET + @Path("/q-apps.js") + @Hidden // For internal Q-App API use only + @Operation( + summary = "Javascript interface for Q-Apps", + responses = { + @ApiResponse( + description = "javascript", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + public String getQAppsJs() { + URL url = Resources.getResource("q-apps/q-apps.js"); + try { + return Resources.toString(url, StandardCharsets.UTF_8); + } catch (IOException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND); + } + } + +} diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java similarity index 99% rename from src/main/java/org/qortal/api/resource/AdminResource.java rename to src/main/java/org/qortal/api/restricted/resource/AdminResource.java index 9cff1bbb..06bafcc6 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java @@ -1,4 +1,4 @@ -package org.qortal.api.resource; +package org.qortal.api.restricted.resource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 2a2c04a5..745c750d 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -60,25 +60,25 @@ window.addEventListener("message", (event) => { switch (data.action) { case "GET_ACCOUNT_DATA": - response = httpGet("/apps/account?address=" + data.address); + response = httpGet("/addresses/" + data.address); break; case "GET_ACCOUNT_NAMES": - response = httpGet("/apps/account/names?address=" + data.address); + response = httpGet("/names/address/" + data.address); break; case "GET_NAME_DATA": - response = httpGet("/apps/name?name=" + data.name); + response = httpGet("/names/" + data.name); break; case "SEARCH_QDN_RESOURCES": - url = "/apps/resources?"; + url = "/arbitrary/resources?"; if (data.service != null) url = url.concat("&service=" + data.service); if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); if (data.default != null) url = url.concat("&default=" + data.default); - if (data.nameListFilter != null) url = url.concat("&nameListFilter=" + data.nameListFilter); - if (data.includeStatus != null) url = url.concat("&includeStatus=" + new Boolean(data.includeStatus).toString()); - if (data.includeMetadata != null) url = url.concat("&includeMetadata=" + new Boolean(data.includeMetadata).toString()); + if (data.nameListFilter != null) url = url.concat("&namefilter=" + data.nameListFilter); + if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); + if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); @@ -86,32 +86,29 @@ window.addEventListener("message", (event) => { break; case "FETCH_QDN_RESOURCE": - url = "/apps/resource?"; - if (data.service != null) url = url.concat("&service=" + data.service); - if (data.name != null) url = url.concat("&name=" + data.name); - if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); + url = "/arbitrary/" + data.service + "/" + data.name; + if (data.identifier != null) url = url.concat("/" + data.identifier); + url = url.concat("?"); if (data.filepath != null) url = url.concat("&filepath=" + data.filepath); if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString()) response = httpGet(url); break; case "GET_QDN_RESOURCE_STATUS": - url = "/apps/resourcestatus?"; - if (data.service != null) url = url.concat("&service=" + data.service); - if (data.name != null) url = url.concat("&name=" + data.name); - if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); + url = "/arbitrary/resource/status/" + data.service + "/" + data.name; + if (data.identifier != null) url = url.concat("/" + data.identifier); response = httpGet(url); break; case "SEARCH_CHAT_MESSAGES": - url = "/apps/chatmessages?"; + url = "/chat/messages?"; if (data.before != null) url = url.concat("&before=" + data.before); if (data.after != null) url = url.concat("&after=" + data.after); if (data.txGroupId != null) url = url.concat("&txGroupId=" + data.txGroupId); if (data.involving != null) data.involving.forEach((x, i) => url = url.concat("&involving=" + x)); if (data.reference != null) url = url.concat("&reference=" + data.reference); - if (data.chatReference != null) url = url.concat("&chatReference=" + data.chatReference); - if (data.hasChatReference != null) url = url.concat("&hasChatReference=" + new Boolean(data.hasChatReference).toString()); + if (data.chatReference != null) url = url.concat("&chatreference=" + data.chatReference); + if (data.hasChatReference != null) url = url.concat("&haschatreference=" + new Boolean(data.hasChatReference).toString()); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); @@ -119,7 +116,7 @@ window.addEventListener("message", (event) => { break; case "LIST_GROUPS": - url = "/apps/groups?"; + url = "/groups?"; if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); @@ -127,27 +124,23 @@ window.addEventListener("message", (event) => { break; case "GET_BALANCE": - url = "/apps/balance?"; + url = "/addresses/balance/" + data.address; if (data.assetId != null) url = url.concat("&assetId=" + data.assetId); - if (data.address != null) url = url.concat("&address=" + data.address); response = httpGet(url); break; case "GET_AT": - url = "/apps/at?"; - if (data.atAddress != null) url = url.concat("&atAddress=" + data.atAddress); + url = "/at" + data.atAddress; response = httpGet(url); break; case "GET_AT_DATA": - url = "/apps/atdata?"; - if (data.atAddress != null) url = url.concat("&atAddress=" + data.atAddress); + url = "/at/" + data.atAddress + "/data"; response = httpGet(url); break; case "LIST_ATS": - url = "/apps/ats?"; - if (data.codeHash58 != null) url = url.concat("&codeHash58=" + data.codeHash58); + url = "/at/byfunction/" + data.codeHash58 + "?"; if (data.isExecutable != null) url = url.concat("&isExecutable=" + data.isExecutable); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); @@ -157,20 +150,18 @@ window.addEventListener("message", (event) => { case "FETCH_BLOCK": if (data.signature != null) { - url = "/apps/block?"; - url = url.concat("&signature=" + data.signature); + url = "/blocks/" + data.signature; } else if (data.height != null) { - url = "/apps/block/byheight?"; - url = url.concat("&height=" + data.height); + url = "/blocks/byheight/" + data.height; } + url = url.concat("?"); if (data.includeOnlineSignatures != null) url = url.concat("&includeOnlineSignatures=" + data.includeOnlineSignatures); response = httpGet(url); break; case "FETCH_BLOCK_RANGE": - url = "/apps/block/range?"; - if (data.height != null) url = url.concat("&height=" + data.height); + url = "/blocks/range/" + data.height + "?"; if (data.count != null) url = url.concat("&count=" + data.count); if (data.reverse != null) url = url.concat("&reverse=" + data.reverse); if (data.includeOnlineSignatures != null) url = url.concat("&includeOnlineSignatures=" + data.includeOnlineSignatures); @@ -178,11 +169,12 @@ window.addEventListener("message", (event) => { break; case "SEARCH_TRANSACTIONS": - url = "/apps/transactions/search?"; + url = "/transactions/search?"; if (data.startBlock != null) url = url.concat("&startBlock=" + data.startBlock); if (data.blockLimit != null) url = url.concat("&blockLimit=" + data.blockLimit); if (data.txGroupId != null) url = url.concat("&txGroupId=" + data.txGroupId); if (data.txType != null) data.txType.forEach((x, i) => url = url.concat("&txType=" + x)); + if (data.address != null) url = url.concat("&address=" + data.address); if (data.confirmationStatus != null) url = url.concat("&confirmationStatus=" + data.confirmationStatus); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); @@ -191,8 +183,7 @@ window.addEventListener("message", (event) => { break; case "GET_PRICE": - url = "/apps/price?"; - if (data.blockchain != null) url = url.concat("&blockchain=" + data.blockchain); + url = "/crosschain/price/" + data.blockchain + "?"; if (data.maxtrades != null) url = url.concat("&maxtrades=" + data.maxtrades); if (data.inverse != null) url = url.concat("&inverse=" + data.inverse); response = httpGet(url); diff --git a/src/test/java/org/qortal/test/api/AdminApiTests.java b/src/test/java/org/qortal/test/api/AdminApiTests.java index b3e6da03..01f2ebc9 100644 --- a/src/test/java/org/qortal/test/api/AdminApiTests.java +++ b/src/test/java/org/qortal/test/api/AdminApiTests.java @@ -5,7 +5,7 @@ import static org.junit.Assert.*; import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.Before; import org.junit.Test; -import org.qortal.api.resource.AdminResource; +import org.qortal.api.restricted.resource.AdminResource; import org.qortal.repository.DataException; import org.qortal.settings.Settings; import org.qortal.test.common.ApiCommon; From 8c41a4a6b3463c8a25c537aa707b59c67345aa4e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Jan 2023 21:08:42 +0000 Subject: [PATCH 015/215] Moved BootstrapResource to restricted resources --- .../qortal/api/{ => restricted}/resource/BootstrapResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/org/qortal/api/{ => restricted}/resource/BootstrapResource.java (98%) diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/restricted/resource/BootstrapResource.java similarity index 98% rename from src/main/java/org/qortal/api/resource/BootstrapResource.java rename to src/main/java/org/qortal/api/restricted/resource/BootstrapResource.java index b9382dcb..bbe03c61 100644 --- a/src/main/java/org/qortal/api/resource/BootstrapResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/BootstrapResource.java @@ -1,4 +1,4 @@ -package org.qortal.api.resource; +package org.qortal.api.restricted.resource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; From d7b1615d4f9985cc8a644f26b548e058c43324c2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Jan 2023 16:26:36 +0000 Subject: [PATCH 016/215] qdnAuthBypassEnabled defaulted to true, as it is needed for Q-Apps. --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 1ced0ae6..c39cb9f5 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -351,7 +351,7 @@ public class Settings { private Long maxStorageCapacity = null; /** Whether to serve QDN data without authentication */ - private boolean qdnAuthBypassEnabled = false; + private boolean qdnAuthBypassEnabled = true; // Domain mapping public static class DomainMap { From 4a42dc2d00babddbe6bbe9d5ba9c94e693e2880b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 14:14:44 +0000 Subject: [PATCH 017/215] Don't require prior authorization of QDN resources if qdnAuthBypassEnabled is true. Necessary for resource linking. --- .../api/resource/ArbitraryResource.java | 8 ++++-- .../qortal/api/resource/RenderResource.java | 25 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 63b2ee2f..e8b5f8e5 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -266,7 +266,9 @@ public class ArbitraryResource { @PathParam("name") String name, @QueryParam("build") Boolean build) { - Security.requirePriorAuthorizationOrApiKey(request, name, service, null, apiKey); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorizationOrApiKey(request, name, service, null, apiKey); + return ArbitraryTransactionUtils.getStatus(service, name, null, build); } @@ -288,7 +290,9 @@ public class ArbitraryResource { @PathParam("identifier") String identifier, @QueryParam("build") Boolean build) { - Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey); + return ArbitraryTransactionUtils.getStatus(service, name, identifier, build); } diff --git a/src/main/java/org/qortal/api/resource/RenderResource.java b/src/main/java/org/qortal/api/resource/RenderResource.java index ac8c9cec..fa05a655 100644 --- a/src/main/java/org/qortal/api/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/resource/RenderResource.java @@ -28,6 +28,7 @@ import org.qortal.controller.arbitrary.ArbitraryDataRenderManager; import org.qortal.data.transaction.ArbitraryTransactionData.*; import org.qortal.repository.DataException; import org.qortal.arbitrary.ArbitraryDataFile.*; +import org.qortal.settings.Settings; import org.qortal.utils.Base58; @@ -142,7 +143,9 @@ public class RenderResource { @SecurityRequirement(name = "apiKey") public HttpServletResponse getIndexBySignature(@PathParam("signature") String signature, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); + return this.get(signature, ResourceIdType.SIGNATURE, null, "/", null, "/render/signature", true, true, theme); } @@ -151,7 +154,9 @@ public class RenderResource { @SecurityRequirement(name = "apiKey") public HttpServletResponse getPathBySignature(@PathParam("signature") String signature, @PathParam("path") String inPath, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); + return this.get(signature, ResourceIdType.SIGNATURE, null, inPath,null, "/render/signature", true, true, theme); } @@ -160,7 +165,9 @@ public class RenderResource { @SecurityRequirement(name = "apiKey") public HttpServletResponse getIndexByHash(@PathParam("hash") String hash58, @QueryParam("secret") String secret58, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); + return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, "/", secret58, "/render/hash", true, false, theme); } @@ -170,7 +177,9 @@ public class RenderResource { public HttpServletResponse getPathByHash(@PathParam("hash") String hash58, @PathParam("path") String inPath, @QueryParam("secret") String secret58, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); + return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, inPath, secret58, "/render/hash", true, false, theme); } @@ -181,7 +190,9 @@ public class RenderResource { @PathParam("name") String name, @PathParam("path") String inPath, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, name, service, null); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, name, service, null); + String prefix = String.format("/render/%s", service); return this.get(name, ResourceIdType.NAME, service, inPath, null, prefix, true, true, theme); } @@ -192,7 +203,9 @@ public class RenderResource { public HttpServletResponse getIndexByName(@PathParam("service") Service service, @PathParam("name") String name, @QueryParam("theme") String theme) { - Security.requirePriorAuthorization(request, name, service, null); + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorization(request, name, service, null); + String prefix = String.format("/render/%s", service); return this.get(name, ResourceIdType.NAME, service, "/", null, prefix, true, true, theme); } From 5e750b42832236d33f824bf8466703ffb87ce2df Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 14:15:54 +0000 Subject: [PATCH 018/215] Added new ArbitraryResourceStatus "NOT_PUBLISHED" - for when a non-existent resource is attempted to be loaded. --- .../java/org/qortal/arbitrary/ArbitraryDataResource.java | 5 +++++ .../org/qortal/data/arbitrary/ArbitraryResourceStatus.java | 1 + 2 files changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java index 2720e4b2..ef068858 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java @@ -63,6 +63,11 @@ public class ArbitraryDataResource { this.calculateChunkCounts(); } + if (this.totalChunkCount == 0) { + // Assume not published + return new ArbitraryResourceStatus(Status.NOT_PUBLISHED, this.localChunkCount, this.totalChunkCount); + } + if (resourceIdType != ResourceIdType.NAME) { // We only support statuses for resources with a name return new ArbitraryResourceStatus(Status.UNSUPPORTED, this.localChunkCount, this.totalChunkCount); diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java index b1fbbd3c..5f49d8ba 100644 --- a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java @@ -8,6 +8,7 @@ public class ArbitraryResourceStatus { public enum Status { PUBLISHED("Published", "Published but not yet downloaded"), + NOT_PUBLISHED("Not published", "Resource does not exist"), DOWNLOADING("Downloading", "Locating and downloading files..."), DOWNLOADED("Downloaded", "Files downloaded"), BUILDING("Building", "Building..."), From bede5a71f8fa13e281e494cb3590ce6cbe08821d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 14:17:23 +0000 Subject: [PATCH 019/215] Fixed various NPEs when checking statuses of non-existent resources. --- .../arbitrary/ArbitraryDataResource.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java index ef068858..b9842976 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java @@ -143,6 +143,9 @@ public class ArbitraryDataResource { public boolean delete() { try { this.fetchTransactions(); + if (this.transactions == null) { + return false; + } List transactionDataList = new ArrayList<>(this.transactions); @@ -198,6 +201,9 @@ public class ArbitraryDataResource { try { this.fetchTransactions(); + if (this.transactions == null) { + return false; + } List transactionDataList = new ArrayList<>(this.transactions); @@ -217,6 +223,11 @@ public class ArbitraryDataResource { private void calculateChunkCounts() { try { this.fetchTransactions(); + if (this.transactions == null) { + this.localChunkCount = 0; + this.totalChunkCount = 0; + return; + } List transactionDataList = new ArrayList<>(this.transactions); int localChunkCount = 0; @@ -236,6 +247,9 @@ public class ArbitraryDataResource { private boolean isRateLimited() { try { this.fetchTransactions(); + if (this.transactions == null) { + return true; + } List transactionDataList = new ArrayList<>(this.transactions); @@ -259,6 +273,10 @@ public class ArbitraryDataResource { private boolean isDataPotentiallyAvailable() { try { this.fetchTransactions(); + if (this.transactions == null) { + return false; + } + Long now = NTP.getTime(); if (now == null) { return false; @@ -290,6 +308,10 @@ public class ArbitraryDataResource { private boolean isDownloading() { try { this.fetchTransactions(); + if (this.transactions == null) { + return false; + } + Long now = NTP.getTime(); if (now == null) { return false; From 0ec5e39517fd288cf4336701d38f59d59ea9a7de Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 14:31:04 +0000 Subject: [PATCH 020/215] Fixed additional NPE --- src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java index b9842976..4d3e5665 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java @@ -63,7 +63,7 @@ public class ArbitraryDataResource { this.calculateChunkCounts(); } - if (this.totalChunkCount == 0) { + if (this.totalChunkCount == null || this.totalChunkCount == 0) { // Assume not published return new ArbitraryResourceStatus(Status.NOT_PUBLISHED, this.localChunkCount, this.totalChunkCount); } From 5a1cc7a0de13e6fcd76efde3097896f9d117f022 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 14:32:17 +0000 Subject: [PATCH 021/215] Fixed/improved logging when an exception is caught whilst adding statuses to resources. --- .../org/qortal/data/arbitrary/ArbitraryResourceInfo.java | 5 +++++ .../java/org/qortal/utils/ArbitraryTransactionUtils.java | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceInfo.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceInfo.java index 135065aa..d6161526 100644 --- a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceInfo.java +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceInfo.java @@ -20,6 +20,11 @@ public class ArbitraryResourceInfo { public ArbitraryResourceInfo() { } + @Override + public String toString() { + return String.format("%s %s %s", name, service, identifier); + } + @Override public boolean equals(Object o) { if (o == this) diff --git a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java index b1536e79..986b761c 100644 --- a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java +++ b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java @@ -455,7 +455,7 @@ public class ArbitraryTransactionUtils { } catch (Exception e) { // Catch and log all exceptions, since some systems are experiencing 500 errors when including statuses - LOGGER.info("Caught exception when adding status to resource %s: %s", resourceInfo, e.toString()); + LOGGER.info("Caught exception when adding status to resource {}: {}", resourceInfo, e.toString()); } } return updatedResources; From 3b6e1ea27fa6fc00af9991b32c9443584cffba26 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 14:42:29 +0000 Subject: [PATCH 022/215] Added "qdnContext" variable, with possible values of "render", "gateway", or "domainMap". This is used internally to allow Q-Apps to determine how to handle certain requests. --- src/main/java/org/qortal/api/HTMLParser.java | 8 +++++++- .../qortal/api/domainmap/resource/DomainMapResource.java | 2 +- .../org/qortal/api/gateway/resource/GatewayResource.java | 2 +- src/main/java/org/qortal/api/resource/RenderResource.java | 2 +- .../java/org/qortal/arbitrary/ArbitraryDataRenderer.java | 6 ++++-- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index a80b0b1e..8b2d1116 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -12,11 +12,13 @@ public class HTMLParser { private String linkPrefix; private byte[] data; + private String qdnContext; - public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data) { + public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data, String qdnContext) { String inPathWithoutFilename = inPath.substring(0, inPath.lastIndexOf('/')); this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; this.data = data; + this.qdnContext = qdnContext; } public void addAdditionalHeaderTags() { @@ -29,6 +31,10 @@ public class HTMLParser { String qAppsScriptElement = String.format("", this.qdnContext); + head.get(0).prepend(qdnContextVar); + // Add base href tag String baseElement = String.format("", baseUrl); head.get(0).prepend(baseElement); diff --git a/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java b/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java index cc21587d..31d216dc 100644 --- a/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java +++ b/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java @@ -51,7 +51,7 @@ public class DomainMapResource { String secret58, String prefix, boolean usePrefix, boolean async) { ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, - secret58, prefix, usePrefix, async, request, response, context); + secret58, prefix, usePrefix, async, "domainMap", request, response, context); return renderer.render(); } diff --git a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java index a73de1fb..07e1cfb4 100644 --- a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java +++ b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java @@ -119,7 +119,7 @@ public class GatewayResource { String secret58, String prefix, boolean usePrefix, boolean async) { ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, - secret58, prefix, usePrefix, async, request, response, context); + secret58, prefix, usePrefix, async, "gateway", request, response, context); return renderer.render(); } diff --git a/src/main/java/org/qortal/api/resource/RenderResource.java b/src/main/java/org/qortal/api/resource/RenderResource.java index fa05a655..f4a4a750 100644 --- a/src/main/java/org/qortal/api/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/resource/RenderResource.java @@ -216,7 +216,7 @@ public class RenderResource { String secret58, String prefix, boolean usePrefix, boolean async, String theme) { ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, - secret58, prefix, usePrefix, async, request, response, context); + secret58, prefix, usePrefix, async, "render", request, response, context); if (theme != null) { renderer.setTheme(theme); diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 847f2aa8..4b804f51 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -40,12 +40,13 @@ public class ArbitraryDataRenderer { private final String prefix; private final boolean usePrefix; private final boolean async; + private final String qdnContext; private final HttpServletRequest request; private final HttpServletResponse response; private final ServletContext context; public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, - String secret58, String prefix, boolean usePrefix, boolean async, + String secret58, String prefix, boolean usePrefix, boolean async, String qdnContext, HttpServletRequest request, HttpServletResponse response, ServletContext context) { this.resourceId = resourceId; @@ -56,6 +57,7 @@ public class ArbitraryDataRenderer { this.prefix = prefix; this.usePrefix = usePrefix; this.async = async; + this.qdnContext = qdnContext; this.request = request; this.response = response; this.context = context; @@ -118,7 +120,7 @@ public class ArbitraryDataRenderer { if (HTMLParser.isHtmlFile(filename)) { // HTML file - needs to be parsed byte[] data = Files.readAllBytes(Paths.get(filePath)); // TODO: limit file size that can be read into memory - HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data); + HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext); htmlParser.addAdditionalHeaderTags(); response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob:; img-src 'self' data: blob:;"); response.setContentType(context.getMimeType(filename)); From 46e8baac98d6ce2464cabee8cc2de562be229b65 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 15:22:03 +0000 Subject: [PATCH 023/215] Added linking between QDN websites / apps. The simplest way to link to another QDN website is to include a link with the format: link text This can be expanded to link to a specific path, e.g: link text Or it can be initiated programatically, via qortalRequest(): let res = await qortalRequest({ action: "LINK_TO_QDN_RESOURCE", service: "WEBSITE", name: "QortalDemo", path: "/minting-leveling/index.html" // Optional }); Note that qortal:// links don't yet support identifiers, so the above format is not confirmed. --- Q-Apps.md | 22 +++++++++++++ src/main/resources/q-apps/q-apps.js | 48 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 12a49e3d..eaca2e5c 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -77,6 +77,7 @@ Here is a list of currently supported actions: - FETCH_BLOCK_RANGE - SEARCH_TRANSACTIONS - GET_PRICE +- LINK_TO_QDN_RESOURCE More functionality will be added in the future. @@ -403,6 +404,27 @@ let res = await qortalRequest({ }); ``` +### Link/redirect to another QDN website +Note: an alternate method is to include `link text` within your HTML code. +``` +let res = await qortalRequest({ + action: "LINK_TO_QDN_RESOURCE", + service: "WEBSITE", + name: "QortalDemo", +}); +``` + +### Link/redirect to a specific path of another QDN website +Note: an alternate method is to include `link text` within your HTML code. +``` +let res = await qortalRequest({ + action: "LINK_TO_QDN_RESOURCE", + service: "WEBSITE", + name: "QortalDemo", + path: "/minting-leveling/index.html" +}); +``` + ## Sample App diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 745c750d..e5fb3fea 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -71,6 +71,20 @@ window.addEventListener("message", (event) => { response = httpGet("/names/" + data.name); break; + case "LINK_TO_QDN_RESOURCE": + if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE + if (qdnContext == "render") { + url = "/render/" + data.service + "/" + data.name; + } + else { + // gateway / domainMap only serve websites right now + url = "/" + data.name; + } + if (data.path != null) url = url.concat((data.path.startsWith("/") ? "" : "/") + data.path); + window.location = url; + response = true; + break; + case "SEARCH_QDN_RESOURCES": url = "/arbitrary/resources?"; if (data.service != null) url = url.concat("&service=" + data.service); @@ -200,6 +214,40 @@ window.addEventListener("message", (event) => { }, false); + +/** + * Listen for and intercept all link click events + */ +function interceptClickEvent(e) { + var target = e.target || e.srcElement; + if (target.tagName === 'A') { + let href = target.getAttribute('href'); + if (href.startsWith("qortal://")) { + href = href.replace(/^(qortal\:\/\/)/,""); + if (href.includes("/")) { + let parts = href.split("/"); + const service = parts[0].toUpperCase(); parts.shift(); + const name = parts[0]; parts.shift(); + const path = parts.join("/"); + qortalRequest({ + action: "LINK_TO_QDN_RESOURCE", + service: service, + name: name, + path: path + }); + } + e.preventDefault(); + } + } +} +if (document.addEventListener) { + document.addEventListener('click', interceptClickEvent); +} +else if (document.attachEvent) { + document.attachEvent('onclick', interceptClickEvent); +} + + const awaitTimeout = (timeout, reason) => new Promise((resolve, reject) => setTimeout( From e1e52b31657458bae4cfc156c7808d096d12b2ff Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 15:52:46 +0000 Subject: [PATCH 024/215] RenderResource moved to restricted resources, as /render/* endpoints shouldn't ever need to be served over the gateway. --- .../qortal/api/{ => restricted}/resource/RenderResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/org/qortal/api/{ => restricted}/resource/RenderResource.java (99%) diff --git a/src/main/java/org/qortal/api/resource/RenderResource.java b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java similarity index 99% rename from src/main/java/org/qortal/api/resource/RenderResource.java rename to src/main/java/org/qortal/api/restricted/resource/RenderResource.java index f4a4a750..519e02ab 100644 --- a/src/main/java/org/qortal/api/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java @@ -1,4 +1,4 @@ -package org.qortal.api.resource; +package org.qortal.api.restricted.resource; import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; From 37b20aac667a8e888c04ec25ec064c15dce063c5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 16:55:04 +0000 Subject: [PATCH 025/215] Upgraded rendering to support identifiers, as well as single file resources. This allows any QDN resource (e.g. an IMAGE) to be linked to from a website/app and then rendered on screen. It isn't yet supported in gateway or domain map mode, as these need some more thought. --- .../domainmap/resource/DomainMapResource.java | 8 ++++---- .../api/gateway/resource/GatewayResource.java | 14 ++++++------- .../restricted/resource/RenderResource.java | 19 +++++++++--------- .../arbitrary/ArbitraryDataRenderer.java | 20 ++++++++++++++----- src/main/resources/loading/index.html | 3 ++- src/main/resources/q-apps/q-apps.js | 17 ++++++++++++++++ 6 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java b/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java index 31d216dc..4cb9f8e5 100644 --- a/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java +++ b/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java @@ -42,15 +42,15 @@ public class DomainMapResource { // Build synchronously, so that we don't need to make the summary API endpoints available over // the domain map server. This means that there will be no loading screen, but this is potentially // preferred in this situation anyway (e.g. to avoid confusing search engine robots). - return this.get(domainMap.get(request.getServerName()), ResourceIdType.NAME, Service.WEBSITE, inPath, null, "", false, false); + return this.get(domainMap.get(request.getServerName()), ResourceIdType.NAME, Service.WEBSITE, null, inPath, null, "", false, false); } return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found"); } - private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, - String secret58, String prefix, boolean usePrefix, boolean async) { + private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, + String inPath, String secret58, String prefix, boolean usePrefix, boolean async) { - ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, + ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath, secret58, prefix, usePrefix, async, "domainMap", request, response, context); return renderer.render(); } diff --git a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java index 07e1cfb4..354631c0 100644 --- a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java +++ b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java @@ -82,7 +82,7 @@ public class GatewayResource { @PathParam("path") String inPath) { // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data Security.disallowLoopbackRequests(request); - return this.get(name, ResourceIdType.NAME, Service.WEBSITE, inPath, null, "", true, true); + return this.get(name, ResourceIdType.NAME, Service.WEBSITE, null, inPath, null, "", true, true); } @GET @@ -91,7 +91,7 @@ public class GatewayResource { public HttpServletResponse getIndexByName(@PathParam("name") String name) { // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data Security.disallowLoopbackRequests(request); - return this.get(name, ResourceIdType.NAME, Service.WEBSITE, "/", null, "", true, true); + return this.get(name, ResourceIdType.NAME, Service.WEBSITE, null, "/", null, "", true, true); } @@ -103,7 +103,7 @@ public class GatewayResource { @PathParam("path") String inPath) { // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data Security.disallowLoopbackRequests(request); - return this.get(name, ResourceIdType.NAME, Service.WEBSITE, inPath, null, "/site", true, true); + return this.get(name, ResourceIdType.NAME, Service.WEBSITE, null, inPath, null, "/site", true, true); } @GET @@ -111,14 +111,14 @@ public class GatewayResource { public HttpServletResponse getSiteIndexByName(@PathParam("name") String name) { // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data Security.disallowLoopbackRequests(request); - return this.get(name, ResourceIdType.NAME, Service.WEBSITE, "/", null, "/site", true, true); + return this.get(name, ResourceIdType.NAME, Service.WEBSITE, null, "/", null, "/site", true, true); } - private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, - String secret58, String prefix, boolean usePrefix, boolean async) { + private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, + String inPath, String secret58, String prefix, boolean usePrefix, boolean async) { - ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, + ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath, secret58, prefix, usePrefix, async, "gateway", request, response, context); return renderer.render(); } diff --git a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java index 519e02ab..60ec23d5 100644 --- a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java @@ -146,7 +146,7 @@ public class RenderResource { if (!Settings.getInstance().isQDNAuthBypassEnabled()) Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); - return this.get(signature, ResourceIdType.SIGNATURE, null, "/", null, "/render/signature", true, true, theme); + return this.get(signature, ResourceIdType.SIGNATURE, null, null, "/", null, "/render/signature", true, true, theme); } @GET @@ -157,7 +157,7 @@ public class RenderResource { if (!Settings.getInstance().isQDNAuthBypassEnabled()) Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); - return this.get(signature, ResourceIdType.SIGNATURE, null, inPath,null, "/render/signature", true, true, theme); + return this.get(signature, ResourceIdType.SIGNATURE, null, null, inPath,null, "/render/signature", true, true, theme); } @GET @@ -168,7 +168,7 @@ public class RenderResource { if (!Settings.getInstance().isQDNAuthBypassEnabled()) Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); - return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, "/", secret58, "/render/hash", true, false, theme); + return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, null, "/", secret58, "/render/hash", true, false, theme); } @GET @@ -180,7 +180,7 @@ public class RenderResource { if (!Settings.getInstance().isQDNAuthBypassEnabled()) Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); - return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, inPath, secret58, "/render/hash", true, false, theme); + return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, null, inPath, secret58, "/render/hash", true, false, theme); } @GET @@ -189,12 +189,13 @@ public class RenderResource { public HttpServletResponse getPathByName(@PathParam("service") Service service, @PathParam("name") String name, @PathParam("path") String inPath, + @QueryParam("identifier") String identifier, @QueryParam("theme") String theme) { if (!Settings.getInstance().isQDNAuthBypassEnabled()) Security.requirePriorAuthorization(request, name, service, null); String prefix = String.format("/render/%s", service); - return this.get(name, ResourceIdType.NAME, service, inPath, null, prefix, true, true, theme); + return this.get(name, ResourceIdType.NAME, service, identifier, inPath, null, prefix, true, true, theme); } @GET @@ -207,15 +208,15 @@ public class RenderResource { Security.requirePriorAuthorization(request, name, service, null); String prefix = String.format("/render/%s", service); - return this.get(name, ResourceIdType.NAME, service, "/", null, prefix, true, true, theme); + return this.get(name, ResourceIdType.NAME, service, null, "/", null, prefix, true, true, theme); } - private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, - String secret58, String prefix, boolean usePrefix, boolean async, String theme) { + private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, + String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String theme) { - ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, + ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath, secret58, prefix, usePrefix, async, "render", request, response, context); if (theme != null) { diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 4b804f51..2df13b8c 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -2,6 +2,7 @@ package org.qortal.arbitrary; import com.google.common.io.Resources; import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.ArrayUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.api.HTMLParser; @@ -34,6 +35,7 @@ public class ArbitraryDataRenderer { private final String resourceId; private final ResourceIdType resourceIdType; private final Service service; + private final String identifier; private String theme = "light"; private String inPath; private final String secret58; @@ -45,13 +47,14 @@ public class ArbitraryDataRenderer { private final HttpServletResponse response; private final ServletContext context; - public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, - String secret58, String prefix, boolean usePrefix, boolean async, String qdnContext, + public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, + String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String qdnContext, HttpServletRequest request, HttpServletResponse response, ServletContext context) { this.resourceId = resourceId; this.resourceIdType = resourceIdType; this.service = service; + this.identifier = identifier != null ? identifier : "default"; this.inPath = inPath; this.secret58 = secret58; this.prefix = prefix; @@ -73,14 +76,14 @@ public class ArbitraryDataRenderer { return ArbitraryDataRenderer.getResponse(response, 500, "QDN is disabled in settings"); } - ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(resourceId, resourceIdType, service, null); + ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(resourceId, resourceIdType, service, identifier); arbitraryDataReader.setSecret58(secret58); // Optional, used for loading encrypted file hashes only try { if (!arbitraryDataReader.isCachedDataAvailable()) { // If async is requested, show a loading screen whilst build is in progress if (async) { arbitraryDataReader.loadAsynchronously(false, 10); - return this.getLoadingResponse(service, resourceId, theme); + return this.getLoadingResponse(service, resourceId, identifier, theme); } // Otherwise, loop until we have data @@ -113,6 +116,12 @@ public class ArbitraryDataRenderer { } String unzippedPath = path.toString(); + String[] files = ArrayUtils.removeElement(new File(unzippedPath).list(), ".qortal"); + if (files.length == 1) { + // This is a single file resource + inPath = files[0]; + } + try { String filename = this.getFilename(unzippedPath, inPath); String filePath = Paths.get(unzippedPath, filename).toString(); @@ -174,7 +183,7 @@ public class ArbitraryDataRenderer { return userPath; } - private HttpServletResponse getLoadingResponse(Service service, String name, String theme) { + private HttpServletResponse getLoadingResponse(Service service, String name, String identifier, String theme) { String responseString = ""; URL url = Resources.getResource("loading/index.html"); try { @@ -183,6 +192,7 @@ public class ArbitraryDataRenderer { // Replace vars responseString = responseString.replace("%%SERVICE%%", service.toString()); responseString = responseString.replace("%%NAME%%", name); + responseString = responseString.replace("%%IDENTIFIER%%", identifier); responseString = responseString.replace("%%THEME%%", theme); } catch (IOException e) { diff --git a/src/main/resources/loading/index.html b/src/main/resources/loading/index.html index a828e04e..8e992049 100644 --- a/src/main/resources/loading/index.html +++ b/src/main/resources/loading/index.html @@ -43,8 +43,9 @@ var host = location.protocol + '//' + location.host; var service = "%%SERVICE%%" var name = "%%NAME%%" + var identifier = "%%IDENTIFIER%%" - var url = host + '/arbitrary/resource/status/' + service + '/' + name + '?build=true'; + var url = host + '/arbitrary/resource/status/' + service + '/' + name + '/' + identifier + '?build=true'; var textStatus = "Loading..."; var textProgress = ""; var retryInterval = 2500; diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index e5fb3fea..5b6e9c15 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -81,6 +81,8 @@ window.addEventListener("message", (event) => { url = "/" + data.name; } if (data.path != null) url = url.concat((data.path.startsWith("/") ? "" : "/") + data.path); + if (data.identifier != null) url = url.concat("?identifier=" + data.identifier); + window.location = url; response = true; break; @@ -228,11 +230,26 @@ function interceptClickEvent(e) { let parts = href.split("/"); const service = parts[0].toUpperCase(); parts.shift(); const name = parts[0]; parts.shift(); + let identifier; + + if (parts.length > 0) { + identifier = parts[0]; // Do not shift yet + // Check if a resource exists with this service, name and identifier combination + const url = "/arbitrary/resource/status/" + service + "/" + name + "/" + identifier; + const response = httpGet(url); + const responseObj = JSON.parse(response); + if (responseObj.totalChunkCount > 0) { + // Identifier exists, so don't include it in the path + parts.shift(); + } + } + const path = parts.join("/"); qortalRequest({ action: "LINK_TO_QDN_RESOURCE", service: service, name: name, + identifier: identifier, path: path }); } From 04f248bcdd3386a7e4546141facbfa7e3664b77a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 17:56:24 +0000 Subject: [PATCH 026/215] Upgraded gateway to support service and identifier. The URL used to access the gateway is now interpreted, and the most appropriate resource is served. This means it can be used in different ways to retrieve any type of content from QDN. For example: /QortalDemo /QortalDemo/minting-leveling/index.html /WEBSITE/QortalDemo /WEBSITE/QortalDemo/minting-leveling/index.html /APP/QortalDemo /THUMBNAIL/QortalDemo/qortal_avatar /QCHAT_IMAGE/birtydasterd/qchat_BfBeCz /ARBITRARY_DATA/PirateChainWallet/LiteWalletJNI/coinparams.json --- .../api/gateway/resource/GatewayResource.java | 102 +++++++++++------- 1 file changed, 65 insertions(+), 37 deletions(-) diff --git a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java index 354631c0..091a0f19 100644 --- a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java +++ b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java @@ -16,6 +16,9 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.*; import javax.ws.rs.core.Context; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; @Path("/") @@ -76,50 +79,75 @@ public class GatewayResource { @GET - @Path("{name}/{path:.*}") + @Path("{path:.*}") @SecurityRequirement(name = "apiKey") - public HttpServletResponse getPathByName(@PathParam("name") String name, - @PathParam("path") String inPath) { + public HttpServletResponse getPath(@PathParam("path") String inPath) { // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data Security.disallowLoopbackRequests(request); - return this.get(name, ResourceIdType.NAME, Service.WEBSITE, null, inPath, null, "", true, true); - } - - @GET - @Path("{name}") - @SecurityRequirement(name = "apiKey") - public HttpServletResponse getIndexByName(@PathParam("name") String name) { - // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data - Security.disallowLoopbackRequests(request); - return this.get(name, ResourceIdType.NAME, Service.WEBSITE, null, "/", null, "", true, true); - } - - - // Optional /site alternative for backwards support - - @GET - @Path("/site/{name}/{path:.*}") - public HttpServletResponse getSitePathByName(@PathParam("name") String name, - @PathParam("path") String inPath) { - // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data - Security.disallowLoopbackRequests(request); - return this.get(name, ResourceIdType.NAME, Service.WEBSITE, null, inPath, null, "/site", true, true); - } - - @GET - @Path("/site/{name}") - public HttpServletResponse getSiteIndexByName(@PathParam("name") String name) { - // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data - Security.disallowLoopbackRequests(request); - return this.get(name, ResourceIdType.NAME, Service.WEBSITE, null, "/", null, "/site", true, true); + return this.parsePath(inPath, "gateway", null, "", true, true); } - private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, - String inPath, String secret58, String prefix, boolean usePrefix, boolean async) { + private HttpServletResponse parsePath(String inPath, String qdnContext, String secret58, String prefix, boolean usePrefix, boolean async) { - ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath, - secret58, prefix, usePrefix, async, "gateway", request, response, context); + if (inPath == null || inPath.equals("")) { + // Assume not a real file + return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found"); + } + + // Default service is WEBSITE + Service service = Service.WEBSITE; + String name = null; + String identifier = null; + String outPath = ""; + + if (!inPath.contains("/")) { + // Assume entire inPath is a registered name + name = inPath; + } + else { + // Parse the path to determine what we need to load + List parts = new LinkedList<>(Arrays.asList(inPath.split("/"))); + + // Check if the first element is a service + try { + Service parsedService = Service.valueOf(parts.get(0).toUpperCase()); + if (parsedService != null) { + // First element matches a service, so we can assume it is one + service = parsedService; + parts.remove(0); + } + } catch (IllegalArgumentException e) { + // Not a service + } + + if (parts.isEmpty()) { + // We need more than just a service + return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found"); + } + + // Service is removed, so assume first element is now a registered name + name = parts.get(0); + parts.remove(0); + + if (!parts.isEmpty()) { + // Name is removed, so check if the first element is now an identifier + ArbitraryResourceStatus status = this.getStatus(service, name, parts.get(0), false); + if (status.getTotalChunkCount() > 0) { + // Matched service, name and identifier combination - so assume this is an identifier and can be removed + identifier = parts.get(0); + parts.remove(0); + } + } + + if (!parts.isEmpty()) { + // outPath can be built by combining any remaining parts + outPath = String.join("/", parts); + } + } + + ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(name, ResourceIdType.NAME, service, identifier, outPath, + secret58, prefix, usePrefix, async, qdnContext, request, response, context); return renderer.render(); } From 380ba5b8c21bb3bc09ed7ea0476a2e70d7ff0254 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 18:01:52 +0000 Subject: [PATCH 027/215] Show "File not found" on the loading screen when navigating to a non-existent resource. --- src/main/resources/loading/index.html | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/resources/loading/index.html b/src/main/resources/loading/index.html index 8e992049..4ed16b53 100644 --- a/src/main/resources/loading/index.html +++ b/src/main/resources/loading/index.html @@ -97,8 +97,14 @@ else if (status.id == "DOWNLOADED") { textStatus = status.description; } + else if (status.id == "NOT_PUBLISHED") { + document.getElementById("title").innerHTML = "File not found"; + document.getElementById("description").innerHTML = ""; + document.getElementById("c").style.opacity = "0.5"; + textStatus = status.description; + } - if (status.localChunkCount != null && status.totalChunkCount != null) { + if (status.localChunkCount != null && status.totalChunkCount != null && status.totalChunkCount > 0) { textProgress = "Files downloaded: " + status.localChunkCount + " / " + status.totalChunkCount; } @@ -276,8 +282,8 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
-

Loading

-

+

Loading

+

Files are being retrieved from the Qortal Data Network. This page will refresh automatically when the content becomes available.

From 3cdfa4e276d744fe13cdee5bedee633ff3245160 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 18:03:00 +0000 Subject: [PATCH 028/215] Increased loading screen refresh interval from 1s to 2s. --- src/main/resources/loading/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/loading/index.html b/src/main/resources/loading/index.html index 4ed16b53..574645cc 100644 --- a/src/main/resources/loading/index.html +++ b/src/main/resources/loading/index.html @@ -75,18 +75,18 @@ } else if (status.id == "BUILDING") { textStatus = status.description; - retryInterval = 1000; + retryInterval = 2000; } else if (status.id == "BUILD_FAILED") { textStatus = status.description; } else if (status.id == "NOT_STARTED") { textStatus = status.description; - retryInterval = 1000; + retryInterval = 2000; } else if (status.id == "DOWNLOADING") { textStatus = status.description; - retryInterval = 1000; + retryInterval = 2000; } else if (status.id == "MISSING_DATA") { textStatus = status.description; From 9c58faa7c2c3d6a964f6bcc257b56f6bda92e982 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 18:36:55 +0000 Subject: [PATCH 029/215] Added LINK_TO_QDN_RESOURCE support in the gateway. --- src/main/resources/q-apps/q-apps.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 5b6e9c15..fb7bbb55 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -75,13 +75,19 @@ window.addEventListener("message", (event) => { if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE if (qdnContext == "render") { url = "/render/" + data.service + "/" + data.name; + if (data.path != null) url = url.concat((data.path.startsWith("/") ? "" : "/") + data.path); + if (data.identifier != null) url = url.concat("?identifier=" + data.identifier); + } + else if (qdnContext == "gateway") { + url = "/" + data.service + "/" + data.name; + if (data.identifier != null) url = url.concat("/" + data.identifier); + if (data.path != null) url = url.concat((data.path.startsWith("/") ? "" : "/") + data.path); } else { - // gateway / domainMap only serve websites right now + // domainMap only serves websites right now url = "/" + data.name; + if (data.path != null) url = url.concat((data.path.startsWith("/") ? "" : "/") + data.path); } - if (data.path != null) url = url.concat((data.path.startsWith("/") ? "" : "/") + data.path); - if (data.identifier != null) url = url.concat("?identifier=" + data.identifier); window.location = url; response = true; From eea98d0bc73b646ce33615e313c830466cd8718b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 18:37:04 +0000 Subject: [PATCH 030/215] Fixed bugs. --- .../org/qortal/api/restricted/resource/RenderResource.java | 3 ++- src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java index 60ec23d5..95360419 100644 --- a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java @@ -203,12 +203,13 @@ public class RenderResource { @SecurityRequirement(name = "apiKey") public HttpServletResponse getIndexByName(@PathParam("service") Service service, @PathParam("name") String name, + @QueryParam("identifier") String identifier, @QueryParam("theme") String theme) { if (!Settings.getInstance().isQDNAuthBypassEnabled()) Security.requirePriorAuthorization(request, name, service, null); String prefix = String.format("/render/%s", service); - return this.get(name, ResourceIdType.NAME, service, null, "/", null, prefix, true, true, theme); + return this.get(name, ResourceIdType.NAME, service, identifier, "/", null, prefix, true, true, theme); } diff --git a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java index 986b761c..0ae1026f 100644 --- a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java +++ b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java @@ -426,7 +426,7 @@ public class ArbitraryTransactionUtils { // If "build" has been specified, build the resource before returning its status if (build != null && build == true) { - ArbitraryDataReader reader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, null); + ArbitraryDataReader reader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); try { if (!reader.isBuilding()) { reader.loadSynchronously(false); From 6ba6c58843dc1358015ab97abb2f9f5e72ff2910 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 11:18:00 +0000 Subject: [PATCH 031/215] Added support for qortal:// protocol links when loading images from the DOM. Example: --- src/main/resources/q-apps/q-apps.js | 144 ++++++++++++++++++---------- 1 file changed, 95 insertions(+), 49 deletions(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index fb7bbb55..5e2b1b31 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -39,6 +39,69 @@ function handleResponse(event, response) { } } +function buildResourceUrl(service, name, identifier, path) { + if (qdnContext == "render") { + url = "/render/" + service + "/" + name; + if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); + if (identifier != null) url = url.concat("?identifier=" + identifier); + } + else if (qdnContext == "gateway") { + url = "/" + service + "/" + name; + if (identifier != null) url = url.concat("/" + identifier); + if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); + } + else { + // domainMap only serves websites right now + url = "/" + name; + if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); + } + return url; +} + +function extractComponents(url) { + url = url.replace(/^(qortal\:\/\/)/,""); + if (url.includes("/")) { + let parts = url.split("/"); + const service = parts[0].toUpperCase(); + parts.shift(); + const name = parts[0]; + parts.shift(); + let identifier; + + if (parts.length > 0) { + identifier = parts[0]; // Do not shift yet + // Check if a resource exists with this service, name and identifier combination + const url = "/arbitrary/resource/status/" + service + "/" + name + "/" + identifier; + const response = httpGet(url); + const responseObj = JSON.parse(response); + if (responseObj.totalChunkCount > 0) { + // Identifier exists, so don't include it in the path + parts.shift(); + } + } + + const path = parts.join("/"); + + const components = []; + components["service"] = service; + components["name"] = name; + components["identifier"] = identifier; + components["path"] = path; + return components; + } + + return null; +} + +function convertToResourceUrl(url) { + const c = extractComponents(url); + if (c == null) { + return null; + } + + return buildResourceUrl(c.service, c.name, c.identifier, c.path); +} + window.addEventListener("message", (event) => { if (event == null || event.data == null || event.data.length == 0) { return; @@ -73,23 +136,7 @@ window.addEventListener("message", (event) => { case "LINK_TO_QDN_RESOURCE": if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE - if (qdnContext == "render") { - url = "/render/" + data.service + "/" + data.name; - if (data.path != null) url = url.concat((data.path.startsWith("/") ? "" : "/") + data.path); - if (data.identifier != null) url = url.concat("?identifier=" + data.identifier); - } - else if (qdnContext == "gateway") { - url = "/" + data.service + "/" + data.name; - if (data.identifier != null) url = url.concat("/" + data.identifier); - if (data.path != null) url = url.concat((data.path.startsWith("/") ? "" : "/") + data.path); - } - else { - // domainMap only serves websites right now - url = "/" + data.name; - if (data.path != null) url = url.concat((data.path.startsWith("/") ? "" : "/") + data.path); - } - - window.location = url; + window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path); response = true; break; @@ -228,39 +275,25 @@ window.addEventListener("message", (event) => { */ function interceptClickEvent(e) { var target = e.target || e.srcElement; - if (target.tagName === 'A') { - let href = target.getAttribute('href'); - if (href.startsWith("qortal://")) { - href = href.replace(/^(qortal\:\/\/)/,""); - if (href.includes("/")) { - let parts = href.split("/"); - const service = parts[0].toUpperCase(); parts.shift(); - const name = parts[0]; parts.shift(); - let identifier; - - if (parts.length > 0) { - identifier = parts[0]; // Do not shift yet - // Check if a resource exists with this service, name and identifier combination - const url = "/arbitrary/resource/status/" + service + "/" + name + "/" + identifier; - const response = httpGet(url); - const responseObj = JSON.parse(response); - if (responseObj.totalChunkCount > 0) { - // Identifier exists, so don't include it in the path - parts.shift(); - } - } - - const path = parts.join("/"); - qortalRequest({ - action: "LINK_TO_QDN_RESOURCE", - service: service, - name: name, - identifier: identifier, - path: path - }); - } - e.preventDefault(); + if (target.tagName !== 'A') { + target = target.closest('A'); + } + if (target == null || target.getAttribute('href') == null) { + return; + } + let href = target.getAttribute('href'); + if (href.startsWith("qortal://")) { + const c = extractComponents(href); + if (c != null) { + qortalRequest({ + action: "LINK_TO_QDN_RESOURCE", + service: c.service, + name: c.name, + identifier: c.identifier, + path: c.path + }); } + e.preventDefault(); } } if (document.addEventListener) { @@ -270,6 +303,19 @@ else if (document.attachEvent) { document.attachEvent('onclick', interceptClickEvent); } +/** + * Intercept image loads from the DOM + */ +document.addEventListener('DOMContentLoaded', () => { + let url = document.querySelector('img').src; + if (url.startsWith("qortal://")) { + const newUrl = convertToResourceUrl(url); + console.log("Loading newUrl " + newUrl); + document.querySelector('img').src = newUrl; + } +}); + + const awaitTimeout = (timeout, reason) => new Promise((resolve, reject) => From 7af551fbc51fb263b15757adcba01d1048bbd095 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 11:44:59 +0000 Subject: [PATCH 032/215] Added "GET_QDN_RESOURCE_URL" Q-Apps action, to allow a website/app to programmatically determine the URL to retrieve any QDN resource it needs to access. Examples: ### Get URL to load a QDN resource ``` let url = await qortalRequest({ action: "GET_QDN_RESOURCE_URL", service: "THUMBNAIL", name: "QortalDemo", identifier: "qortal_avatar" // path: "filename.jpg" // optional - not needed if resource contains only one file }); ``` ### Get URL to load a QDN website ``` let url = await qortalRequest({ action: "GET_QDN_RESOURCE_URL", service: "WEBSITE", name: "QortalDemo", }); ``` ### Get URL to load a specific file from a QDN website ``` let url = await qortalRequest({ action: "GET_QDN_RESOURCE_URL", service: "WEBSITE", name: "AlphaX", path: "/assets/img/logo.png" }); ``` --- Q-Apps.md | 31 +++++++++++++++++++++++++++++ src/main/resources/q-apps/q-apps.js | 4 ++++ 2 files changed, 35 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index eaca2e5c..2accbb4d 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -77,6 +77,7 @@ Here is a list of currently supported actions: - FETCH_BLOCK_RANGE - SEARCH_TRANSACTIONS - GET_PRICE +- GET_QDN_RESOURCE_URL - LINK_TO_QDN_RESOURCE More functionality will be added in the future. @@ -404,6 +405,36 @@ let res = await qortalRequest({ }); ``` +### Get URL to load a QDN resource +``` +let url = await qortalRequest({ + action: "GET_QDN_RESOURCE_URL", + service: "THUMBNAIL", + name: "QortalDemo", + identifier: "qortal_avatar" + // path: "filename.jpg" // optional - not needed if resource contains only one file +}); +``` + +### Get URL to load a QDN website +``` +let url = await qortalRequest({ + action: "GET_QDN_RESOURCE_URL", + service: "WEBSITE", + name: "QortalDemo", +}); +``` + +### Get URL to load a specific file from a QDN website +``` +let url = await qortalRequest({ + action: "GET_QDN_RESOURCE_URL", + service: "WEBSITE", + name: "AlphaX", + path: "/assets/img/logo.png" +}); +``` + ### Link/redirect to another QDN website Note: an alternate method is to include `link text` within your HTML code. ``` diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 5e2b1b31..4e73931f 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -134,6 +134,10 @@ window.addEventListener("message", (event) => { response = httpGet("/names/" + data.name); break; + case "GET_QDN_RESOURCE_URL": + response = buildResourceUrl(data.service, data.name, data.identifier, data.path); + break; + case "LINK_TO_QDN_RESOURCE": if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path); From 1be3ae267e7ff8dbbd5363cb85272b5e2a159250 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 11:45:09 +0000 Subject: [PATCH 033/215] Reduce log spam. --- .../java/org/qortal/arbitrary/ArbitraryDataResource.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java index 4d3e5665..42a01c2a 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java @@ -364,7 +364,10 @@ public class ArbitraryDataResource { this.transactions = transactionDataList; this.layerCount = transactionDataList.size(); - } catch (DataException e) { + } catch (DataNotPublishedException e) { + // Ignore without logging + } + catch (DataException e) { LOGGER.info(String.format("Repository error when fetching transactions for resource %s: %s", this, e.getMessage())); } } From 566c6a3f4beef466234a5b6b12dfd4d4461420e5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 12:04:39 +0000 Subject: [PATCH 034/215] Added support for img src updates from a Q-App. Example: document.getElementById("logo").src = "qortal://thumbnail/QortalDemo/qortal_avatar"; --- src/main/resources/q-apps/q-apps.js | 30 ++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 4e73931f..3967d7a9 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -59,6 +59,10 @@ function buildResourceUrl(service, name, identifier, path) { } function extractComponents(url) { + if (!url.startsWith("qortal://")) { + return null; + } + url = url.replace(/^(qortal\:\/\/)/,""); if (url.includes("/")) { let parts = url.split("/"); @@ -94,6 +98,9 @@ function extractComponents(url) { } function convertToResourceUrl(url) { + if (!url.startsWith("qortal://")) { + return null; + } const c = extractComponents(url); if (c == null) { return null; @@ -312,13 +319,30 @@ else if (document.attachEvent) { */ document.addEventListener('DOMContentLoaded', () => { let url = document.querySelector('img').src; - if (url.startsWith("qortal://")) { - const newUrl = convertToResourceUrl(url); - console.log("Loading newUrl " + newUrl); + const newUrl = convertToResourceUrl(url); + if (newUrl != null) { document.querySelector('img').src = newUrl; } }); +/** + * Intercept img src updates + */ +document.addEventListener('DOMContentLoaded', () => { + let img = document.querySelector('img'); + let observer = new MutationObserver((changes) => { + changes.forEach(change => { + if (change.attributeName.includes('src')) { + const newUrl = convertToResourceUrl(img.src); + if (newUrl != null) { + document.querySelector('img').src = newUrl; + } + } + }); + }); + observer.observe(img, {attributes: true}); +}); + const awaitTimeout = (timeout, reason) => From 8beffd4daed2b181529a218804fd39cc07bfc80a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 12:12:47 +0000 Subject: [PATCH 035/215] Switched to document.querySelectorAll() as otherwise we were only intercepting the first image on the page. --- src/main/resources/q-apps/q-apps.js | 33 +++++++++++++++++------------ 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 3967d7a9..374d2c46 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -318,29 +318,34 @@ else if (document.attachEvent) { * Intercept image loads from the DOM */ document.addEventListener('DOMContentLoaded', () => { - let url = document.querySelector('img').src; - const newUrl = convertToResourceUrl(url); - if (newUrl != null) { - document.querySelector('img').src = newUrl; - } + const imgElements = document.querySelectorAll('img'); + imgElements.forEach((img) => { + let url = img.src; + const newUrl = convertToResourceUrl(url); + if (newUrl != null) { + document.querySelector('img').src = newUrl; + } + }); }); /** * Intercept img src updates */ document.addEventListener('DOMContentLoaded', () => { - let img = document.querySelector('img'); - let observer = new MutationObserver((changes) => { - changes.forEach(change => { - if (change.attributeName.includes('src')) { - const newUrl = convertToResourceUrl(img.src); - if (newUrl != null) { - document.querySelector('img').src = newUrl; + const imgElements = document.querySelectorAll('img'); + imgElements.forEach((img) => { + let observer = new MutationObserver((changes) => { + changes.forEach(change => { + if (change.attributeName.includes('src')) { + const newUrl = convertToResourceUrl(img.src); + if (newUrl != null) { + document.querySelector('img').src = newUrl; + } } - } + }); }); + observer.observe(img, {attributes: true}); }); - observer.observe(img, {attributes: true}); }); From 4d9cece9fa1d254e94e73c5e059370bd4f987612 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 13:07:26 +0000 Subject: [PATCH 036/215] Timeouts are specified by action, rather than using 10 second for every request. This allows certain requests to wait for longer before timing out, such as ones that create transactions. --- Q-Apps.md | 2 +- src/main/resources/q-apps/q-apps.js | 32 ++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 2accbb4d..eaa5b9ff 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -28,7 +28,7 @@ myfunction(); ## Timeouts -By default, all requests will timeout after 10 seconds, and will throw an error - `The request timed out`. If you need a longer timeout - e.g. when fetching large QDN resources that may take a long time to be retrieved, you can use `qortalRequestWithTimeout(request, timeout)` as an alternative to `qortalRequest(request)`. +By default, all requests will timeout after a certain amount of time (default 10 seconds, but some actions use a higher value), and will throw an error - `The request timed out`. If you need a longer timeout - e.g. when fetching large QDN resources that may take a long time to be retrieved, you can use `qortalRequestWithTimeout(request, timeout)` as an alternative to `qortalRequest(request)`. ``` async function myfunction() { diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 374d2c46..170496a6 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -358,6 +358,36 @@ const awaitTimeout = (timeout, reason) => ) ); +function getDefaultTimeout(action) { + if (action != null) { + // Some actions need longer default timeouts, especially those that create transactions + switch (action) { + case "FETCH_QDN_RESOURCE": + // Fetching data can take a while, especially if the status hasn't been checked first + return 60 * 1000; + + case "PUBLISH_QDN_RESOURCE": + // Publishing could take a very long time on slow system, due to the proof-of-work computation + // It's best not to timeout + return 60 * 60 * 1000; + + case "SEND_CHAT_MESSAGE": + // Chat messages rely on PoW computations, so allow extra time + return 60 * 1000; + + case "JOIN_GROUP": + case "DEPLOY_AT": + case "SEND_COIN": + // Allow extra time for other actions that create transactions, even if there is no PoW + return 30 * 1000; + + default: + break; + } + } + return 10 * 1000; +} + /** * Make a Qortal (Q-Apps) request with no timeout */ @@ -381,7 +411,7 @@ const qortalRequestWithNoTimeout = (request) => new Promise((res, rej) => { * Make a Qortal (Q-Apps) request with the default timeout (10 seconds) */ const qortalRequest = (request) => - Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(10000, "The request timed out")]); + Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(getDefaultTimeout(request.action), "The request timed out")]); /** * Make a Qortal (Q-Apps) request with a custom timeout, specified in milliseconds From 6c445ff6469f29dc532b6265ae9ebbb0b9afb9f4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 13:23:01 +0000 Subject: [PATCH 037/215] GET_ACCOUNT_ADDRESS and GET_ACCOUNT_PUBLIC_KEY replaced with a single action: GET_USER_ACCOUNT, as it doesn't make sense to request address and public key separately (they are essentially the same thing). --- Q-Apps.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index eaa5b9ff..8177e020 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -53,8 +53,7 @@ myfunction(); ## Supported actions Here is a list of currently supported actions: -- GET_ACCOUNT_ADDRESS -- GET_ACCOUNT_PUBLIC_KEY +- GET_USER_ACCOUNT - GET_ACCOUNT_DATA - GET_ACCOUNT_NAMES - GET_NAME_DATA @@ -89,17 +88,19 @@ Here are some example requests for each of the above: ### Get address of logged in account _Will likely require user approval_ ``` -let address = await qortalRequest({ - action: "GET_ACCOUNT_ADDRESS" +let account = await qortalRequest({ + action: "GET_USER_ACCOUNT" }); +let address = account.address; ``` ### Get public key of logged in account _Will likely require user approval_ ``` let pubkey = await qortalRequest({ - action: "GET_ACCOUNT_PUBLIC_KEY" + action: "GET_USER_ACCOUNT" }); +let publicKey = account.publicKey; ``` ### Get account data @@ -467,9 +468,10 @@ Here is a sample application to display the logged-in user's avatar: async function showAvatar() { try { // Get QORT address of logged in account - let address = await qortalRequest({ - action: "GET_ACCOUNT_ADDRESS" + let account = await qortalRequest({ + action: "GET_USER_ACCOUNT" }); + let address = account.address; console.log("address: " + address); // Get names owned by this account From eb07e6613f671d2a68d7603bedf2aa955f5a59f9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 13:23:12 +0000 Subject: [PATCH 038/215] Fixed small bug --- src/main/resources/q-apps/q-apps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 170496a6..823c549d 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -86,7 +86,7 @@ function extractComponents(url) { const path = parts.join("/"); - const components = []; + const components = {}; components["service"] = service; components["name"] = name; components["identifier"] = identifier; From 600f98ddabec8c8d00c3e709f1d55c1c3c77bce6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 13:38:08 +0000 Subject: [PATCH 039/215] Fixed bug in extractComponents() --- src/main/resources/q-apps/q-apps.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 823c549d..626f2b4b 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -82,6 +82,9 @@ function extractComponents(url) { // Identifier exists, so don't include it in the path parts.shift(); } + else { + identifier = null; + } } const path = parts.join("/"); From 8eba0f89fe9121da8cfaf6c4e1577792c10d19e7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 17:09:28 +0000 Subject: [PATCH 040/215] Added to Q-Apps documentation --- Q-Apps.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 8177e020..ec7d8a39 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -5,10 +5,88 @@ Q-Apps are static web apps written in javascript, HTML, CSS, and other static assets. The key difference between a Q-App and a fully static site is its ability to interact with both the logged-in user and on-chain data. This is achieved using the API described in this document. +# Section 1: Simple links and image loading via HTML -## Making a request +## Section 1a: Linking to other QDN websites / resources -Qortal core will automatically inject a `qortalRequest()` javascript function to all websites/apps, which returns a Promise. This can be used to fetch data or publish data to the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling. +The `qortal://` protocol can be used to access QDN data from within Qortal websites and apps. The basic format is as follows: +``` +link text +``` + +However, the system will support the omission of the `identifier` and/or `path` components to allow for simpler URL formats. + +A simple link to another website can be achieved with this HTML code: +``` +link text +``` + +To link to a specific page of another website: +``` +link text +``` + +To link to a standalone resource, such as an avatar +``` +avatar +``` + +For cases where you would prefer to explicitly include an identifier (to remove ambiguity) you can use the keyword `default` to access a resource that doesn't have an identifier. For instance: +``` +link to root of website +link to subpage of website +``` + + +## Section 1b: Linking to other QDN images + +The same applies for images, such as displaying an avatar: +``` + +``` + +...or even an image from an entirely different website: +``` + +``` + + +# Section 2: Integrating a Javascript app + +Javascript apps allow for much more complex integrations with Qortal's blockchain data. + +## Section 2a: Direct API calls + +The standard [Qortal Core API](http://localhost:12391/api-documentation) is available to websites and apps, and can be called directly using standard AJAX request, such as: +``` +async function getNameInfo(name) { + const response = await fetch("/names/" + name); + const nameData = await response.json(); + console.log("nameData: " + JSON.stringify(nameData)); +} +getNameInfo("QortalDemo"); +``` + +However, this only works to for read-only data, such as looking up transactions, names, balances, etc. + + +## Section 2b: User interaction via qortalRequest() + +To take things a step further, the qortalRequest() function can be used to interact with the user, in order to: + +- Request address and public key of the logged in account +- Publish data to QDN +- Send chat messages +- Join groups +- Deploy ATs (smart contracts) +- Send QORT or any supported foreign coin + +In addition to the above, qortalRequest() also supports many read-only functions that are also available via direct core API calls. Using qortalRequest helps with futureproofing, as the core APIs can be modified without breaking functionality of existing Q-Apps. + + +### Making a request + +Qortal core will automatically inject a `qortalRequest()` javascript function to all websites/apps, which returns a Promise. This can be used to fetch data or publish data to the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling. ``` async function myfunction() { @@ -26,7 +104,7 @@ async function myfunction() { myfunction(); ``` -## Timeouts +### Timeouts By default, all requests will timeout after a certain amount of time (default 10 seconds, but some actions use a higher value), and will throw an error - `The request timed out`. If you need a longer timeout - e.g. when fetching large QDN resources that may take a long time to be retrieved, you can use `qortalRequestWithTimeout(request, timeout)` as an alternative to `qortalRequest(request)`. @@ -50,6 +128,8 @@ async function myfunction() { myfunction(); ``` +# Section 3: qortalRequest Documentation + ## Supported actions Here is a list of currently supported actions: @@ -458,6 +538,8 @@ let res = await qortalRequest({ ``` +# Section 4: Examples + ## Sample App Here is a sample application to display the logged-in user's avatar: @@ -512,7 +594,7 @@ Here is a sample application to display the logged-in user's avatar: ``` -## Testing and Development +# Section 5: Testing and Development Publishing an in-development app to mainnet isn't recommended. There are several options for developing and testing a Q-app before publishing to mainnet: From 4ba2f7ad6a91888cf0a3114c9ebc15b9c7af5b19 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 17:20:25 +0000 Subject: [PATCH 041/215] Small documentation updates --- Q-Apps.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index ec7d8a39..7416a126 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -57,7 +57,7 @@ Javascript apps allow for much more complex integrations with Qortal's blockchai ## Section 2a: Direct API calls -The standard [Qortal Core API](http://localhost:12391/api-documentation) is available to websites and apps, and can be called directly using standard AJAX request, such as: +The standard [Qortal Core API](http://localhost:12391/api-documentation) is available to websites and apps, and can be called directly using a standard AJAX request, such as: ``` async function getNameInfo(name) { const response = await fetch("/names/" + name); @@ -67,7 +67,7 @@ async function getNameInfo(name) { getNameInfo("QortalDemo"); ``` -However, this only works to for read-only data, such as looking up transactions, names, balances, etc. +However, this only works for read-only data, such as looking up transactions, names, balances, etc. Also, since the address of the logged in account can't be retrieved from the core, apps can't show personalized data with this approach. ## Section 2b: User interaction via qortalRequest() @@ -81,12 +81,12 @@ To take things a step further, the qortalRequest() function can be used to inter - Deploy ATs (smart contracts) - Send QORT or any supported foreign coin -In addition to the above, qortalRequest() also supports many read-only functions that are also available via direct core API calls. Using qortalRequest helps with futureproofing, as the core APIs can be modified without breaking functionality of existing Q-Apps. +In addition to the above, qortalRequest() also supports many read-only functions that are also available via direct core API calls. Using qortalRequest() helps with futureproofing, as the core APIs can be modified without breaking functionality of existing Q-Apps. ### Making a request -Qortal core will automatically inject a `qortalRequest()` javascript function to all websites/apps, which returns a Promise. This can be used to fetch data or publish data to the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling. +Qortal core will automatically inject the `qortalRequest()` javascript function to all websites/apps, which returns a Promise. This can be used to fetch data or publish data to the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling. ``` async function myfunction() { From 3077810ea86beee7df3e1ab1d9fb9eb7c10d8d18 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 29 Jan 2023 18:05:04 +0000 Subject: [PATCH 042/215] Fixed bugs causing websites to report as "Not published" when listed in the UI. --- .../org/qortal/arbitrary/ArbitraryDataResource.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java index 42a01c2a..7e3c4ea8 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java @@ -43,6 +43,7 @@ public class ArbitraryDataResource { private int layerCount; private Integer localChunkCount = null; private Integer totalChunkCount = null; + private boolean exists = false; public ArbitraryDataResource(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) { this.resourceId = resourceId.toLowerCase(); @@ -61,11 +62,10 @@ public class ArbitraryDataResource { // Avoid this for "quick" statuses, to speed things up if (!quick) { this.calculateChunkCounts(); - } - if (this.totalChunkCount == null || this.totalChunkCount == 0) { - // Assume not published - return new ArbitraryResourceStatus(Status.NOT_PUBLISHED, this.localChunkCount, this.totalChunkCount); + if (!this.exists) { + return new ArbitraryResourceStatus(Status.NOT_PUBLISHED, this.localChunkCount, this.totalChunkCount); + } } if (resourceIdType != ResourceIdType.NAME) { @@ -224,11 +224,14 @@ public class ArbitraryDataResource { try { this.fetchTransactions(); if (this.transactions == null) { + this.exists = false; this.localChunkCount = 0; this.totalChunkCount = 0; return; } + this.exists = true; + List transactionDataList = new ArrayList<>(this.transactions); int localChunkCount = 0; int totalChunkCount = 0; From 5b7e9666dc740f89b511005623dbc42132f702f0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Feb 2023 15:40:06 +0000 Subject: [PATCH 043/215] Send URL updates to the UI when pages are loaded. --- src/main/java/org/qortal/api/HTMLParser.java | 16 ++++++++++--- .../arbitrary/ArbitraryDataRenderer.java | 2 +- src/main/resources/q-apps/q-apps.js | 23 +++++++++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 8b2d1116..86f0c19e 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -5,6 +5,7 @@ import org.apache.logging.log4j.Logger; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.select.Elements; +import org.qortal.arbitrary.misc.Service; public class HTMLParser { @@ -13,12 +14,21 @@ public class HTMLParser { private String linkPrefix; private byte[] data; private String qdnContext; + private String resourceId; + private Service service; + private String identifier; + private String path; - public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data, String qdnContext) { + public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data, + String qdnContext, Service service, String identifier) { String inPathWithoutFilename = inPath.substring(0, inPath.lastIndexOf('/')); this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; this.data = data; this.qdnContext = qdnContext; + this.resourceId = resourceId; + this.service = service; + this.identifier = identifier; + this.path = inPath; } public void addAdditionalHeaderTags() { @@ -31,8 +41,8 @@ public class HTMLParser { String qAppsScriptElement = String.format("", this.qdnContext); + // Add vars + String qdnContextVar = String.format("", this.qdnContext, this.service.toString(), this.resourceId, this.identifier, this.path); head.get(0).prepend(qdnContextVar); // Add base href tag diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 2df13b8c..9ad021c1 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -129,7 +129,7 @@ public class ArbitraryDataRenderer { if (HTMLParser.isHtmlFile(filename)) { // HTML file - needs to be parsed byte[] data = Files.readAllBytes(Paths.get(filePath)); // TODO: limit file size that can be read into memory - HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext); + HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext, service, identifier); htmlParser.addAdditionalHeaderTags(); response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob:; img-src 'self' data: blob:;"); response.setContentType(context.getMimeType(filename)); diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 626f2b4b..88be3d37 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -10,7 +10,7 @@ function handleResponse(event, response) { return; } - // Handle emmpty or missing responses + // Handle empty or missing responses if (response == null || response.length == 0) { response = "{\"error\": \"Empty response\"}" } @@ -151,7 +151,6 @@ window.addEventListener("message", (event) => { case "LINK_TO_QDN_RESOURCE": if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path); - response = true; break; case "SEARCH_QDN_RESOURCES": @@ -279,6 +278,13 @@ window.addEventListener("message", (event) => { return; } + if (response == null) { + // Pass to parent (UI), in case they can fulfil this request + event.data.requestedHandler = "UI"; + parent.postMessage(event.data, '*', [event.ports[0]]); + return; + } + handleResponse(event, response); }, false); @@ -317,6 +323,19 @@ else if (document.attachEvent) { document.attachEvent('onclick', interceptClickEvent); } +/** + * Send current page details to UI + */ +document.addEventListener('DOMContentLoaded', () => { + qortalRequest({ + action: "QDN_RESOURCE_DISPLAYED", + service: qdnService, + name: qdnName, + identifier: qdnIdentifier, + path: qdnPath + }); +}); + /** * Intercept image loads from the DOM */ From 7f23ef64a2bbf73b259816de53f6457c9ea1dd18 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Feb 2023 17:37:30 +0000 Subject: [PATCH 044/215] Updated /arbitrary/metadata/* response when not found. --- src/main/java/org/qortal/api/resource/ArbitraryResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index e8b5f8e5..9569017c 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -737,7 +737,7 @@ public class ArbitraryResource { throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage()); } - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND); } From c5a0b00cde97a48ad2e2dcde32e95d34a756dd28 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Feb 2023 12:15:22 +0000 Subject: [PATCH 045/215] Q-Apps documentation updates based on UI development progress. --- Q-Apps.md | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 7416a126..33bf3325 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -307,29 +307,17 @@ let res = await qortalRequest({ }); ``` -### Send coin to address +### Send QORT to address _Requires user approval_ ``` await qortalRequest({ action: "SEND_COIN", coin: "QORT", destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", - amount: 100000000, // 1 QORT - fee: 10000 // 0.0001 QORT + amount: 1.00000000 // 1 QORT }); ``` -### Send coin to address -_Requires user approval_ -``` -await qortalRequest({ - action: "SEND_COIN", - coin: "LTC", - destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y", - amount: 100000000, // 1 LTC - fee: 20 // 0.00000020 LTC per byte -}); -``` ### Search or list chat messages ``` @@ -393,14 +381,14 @@ _Requires user approval_ ``` let res = await qortalRequest({ action: "DEPLOY_AT", - creationBytes: "12345", + creationBytes: "12345", // Must be Base58 encoded name: "test name", description: "test description", type: "test type", tags: "test tags", - amount: 100000000, // 1 QORT + amount: 1.00000000, // 1 QORT assetId: 0, - fee: 20000 // 0.0002 QORT + // fee: 0.002 // optional - will use default fee if excluded }); ``` From c310a7c5e8f178250be14e3d27d3fceb8f9a3edb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Feb 2023 13:41:52 +0000 Subject: [PATCH 046/215] Added "X-API-VERSION" header support in POST /transactions/process. Default is version "1". If version "2" is specified, the API will return the full transaction JSON on success, rather than just "true". Example usage: curl -X POST "http://localhost:12391/transactions/process" -H "X-API-VERSION: 2" -d "signedTransactionBytesHere" --- src/main/java/org/qortal/api/ApiRequest.java | 37 +++++++++++++++++-- src/main/java/org/qortal/api/ApiService.java | 18 +++++++++ .../api/resource/TransactionsResource.java | 35 ++++++++++++------ 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/qortal/api/ApiRequest.java b/src/main/java/org/qortal/api/ApiRequest.java index 5517ff53..b9fbf1dc 100644 --- a/src/main/java/org/qortal/api/ApiRequest.java +++ b/src/main/java/org/qortal/api/ApiRequest.java @@ -3,6 +3,7 @@ package org.qortal.api; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; +import java.io.Writer; import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.Socket; @@ -20,14 +21,12 @@ import javax.net.ssl.SNIHostName; import javax.net.ssl.SNIServerName; import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; -import javax.xml.bind.JAXBContext; -import javax.xml.bind.JAXBException; -import javax.xml.bind.UnmarshalException; -import javax.xml.bind.Unmarshaller; +import javax.xml.bind.*; import javax.xml.transform.stream.StreamSource; import org.eclipse.persistence.exceptions.XMLMarshalException; import org.eclipse.persistence.jaxb.JAXBContextFactory; +import org.eclipse.persistence.jaxb.MarshallerProperties; import org.eclipse.persistence.jaxb.UnmarshallerProperties; public class ApiRequest { @@ -107,6 +106,36 @@ public class ApiRequest { } } + private static Marshaller createMarshaller(Class objectClass) { + try { + // Create JAXB context aware of object's class + JAXBContext jc = JAXBContextFactory.createContext(new Class[] { objectClass }, null); + + // Create marshaller + Marshaller marshaller = jc.createMarshaller(); + + // Set the marshaller media type to JSON + marshaller.setProperty(MarshallerProperties.MEDIA_TYPE, "application/json"); + + // Tell marshaller not to include JSON root element in the output + marshaller.setProperty(MarshallerProperties.JSON_INCLUDE_ROOT, false); + + return marshaller; + } catch (JAXBException e) { + throw new RuntimeException("Unable to create websocket marshaller", e); + } + } + + public static void marshall(Writer writer, Object object) throws IOException { + Marshaller marshaller = createMarshaller(object.getClass()); + + try { + marshaller.marshal(object, writer); + } catch (JAXBException e) { + throw new IOException("Unable to create marshall object for websocket", e); + } + } + public static String getParamsString(Map params) { StringBuilder result = new StringBuilder(); diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 4676fa49..79bfd216 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -13,6 +13,7 @@ import java.security.SecureRandom; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; +import javax.servlet.http.HttpServletRequest; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.rewrite.handler.RedirectPatternRule; @@ -50,6 +51,8 @@ public class ApiService { private Server server; private ApiKey apiKey; + public static final String API_VERSION_HEADER = "X-API-VERSION"; + private ApiService() { this.config = new ResourceConfig(); this.config.packages("org.qortal.api.resource", "org.qortal.api.restricted.resource"); @@ -229,4 +232,19 @@ public class ApiService { this.server = null; } + public static int getApiVersion(HttpServletRequest request) { + // Get API version + String apiVersionString = request.getHeader(API_VERSION_HEADER); + if (apiVersionString == null) { + // Try query string - this is needed to avoid a CORS preflight. See: https://stackoverflow.com/a/43881141 + apiVersionString = request.getParameter("apiVersion"); + } + + int apiVersion = 1; + if (apiVersionString != null) { + apiVersion = Integer.parseInt(apiVersionString); + } + return apiVersion; + } + } diff --git a/src/main/java/org/qortal/api/resource/TransactionsResource.java b/src/main/java/org/qortal/api/resource/TransactionsResource.java index 2b9b28a1..1311c4ad 100644 --- a/src/main/java/org/qortal/api/resource/TransactionsResource.java +++ b/src/main/java/org/qortal/api/resource/TransactionsResource.java @@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.IOException; +import java.io.StringWriter; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -18,19 +20,12 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.QueryParam; +import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import org.qortal.account.PrivateKeyAccount; -import org.qortal.api.ApiError; -import org.qortal.api.ApiErrors; -import org.qortal.api.ApiException; -import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.*; import org.qortal.api.model.SimpleTransactionSignRequest; import org.qortal.controller.Controller; import org.qortal.controller.LiteNode; @@ -709,7 +704,7 @@ public class TransactionsResource { ), responses = { @ApiResponse( - description = "true if accepted, false otherwise", + description = "For API version 1, this returns true if accepted.\nFor API version 2, the transactionData is returned as a JSON string if accepted.", content = @Content( mediaType = MediaType.TEXT_PLAIN, schema = @Schema( @@ -722,7 +717,9 @@ public class TransactionsResource { @ApiErrors({ ApiError.BLOCKCHAIN_NEEDS_SYNC, ApiError.INVALID_SIGNATURE, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE }) - public String processTransaction(String rawBytes58) { + public String processTransaction(String rawBytes58, @HeaderParam(ApiService.API_VERSION_HEADER) String apiVersionHeader) { + int apiVersion = ApiService.getApiVersion(request); + // Only allow a transaction to be processed if our latest block is less than 60 minutes old // If older than this, we should first wait until the blockchain is synced final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L); @@ -759,13 +756,27 @@ public class TransactionsResource { blockchainLock.unlock(); } - return "true"; + switch (apiVersion) { + case 1: + return "true"; + + case 2: + default: + // Marshall transactionData to string + StringWriter stringWriter = new StringWriter(); + ApiRequest.marshall(stringWriter, transactionData); + return stringWriter.toString(); + } + + } catch (NumberFormatException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } catch (InterruptedException e) { throw createTransactionInvalidException(request, ValidationResult.NO_BLOCKCHAIN_LOCK); + } catch (IOException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); } } From c1ffe557e1871797d735c869204d893ba31cda88 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Feb 2023 13:42:59 +0000 Subject: [PATCH 047/215] Fixed wording in marshaller exceptions. --- src/main/java/org/qortal/api/ApiRequest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/ApiRequest.java b/src/main/java/org/qortal/api/ApiRequest.java index b9fbf1dc..a51a117e 100644 --- a/src/main/java/org/qortal/api/ApiRequest.java +++ b/src/main/java/org/qortal/api/ApiRequest.java @@ -122,7 +122,7 @@ public class ApiRequest { return marshaller; } catch (JAXBException e) { - throw new RuntimeException("Unable to create websocket marshaller", e); + throw new RuntimeException("Unable to create API marshaller", e); } } @@ -132,7 +132,7 @@ public class ApiRequest { try { marshaller.marshal(object, writer); } catch (JAXBException e) { - throw new IOException("Unable to create marshall object for websocket", e); + throw new IOException("Unable to create marshall object for API", e); } } From fa14568cb9c3000dd7351cde8e95714a57230f65 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 10:42:43 +0000 Subject: [PATCH 048/215] Fixed issue causing "totalChunkCount" to exclude the metadata file in some cases. ArbitraryDataFile now has a fileCount() method which returns the total number of files associated with that piece of data - i.e. chunks, metadata, and the complete file in cases where it isn't chunked. --- .../org/qortal/arbitrary/ArbitraryDataFile.java | 16 ++++++++++++++++ .../qortal/utils/ArbitraryTransactionUtils.java | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java index 1e86ee98..53560e5f 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java @@ -612,6 +612,22 @@ public class ArbitraryDataFile { return this.chunks.size(); } + public int fileCount() { + int fileCount = this.chunkCount(); + + if (fileCount == 0) { + // Transactions without any chunks can already be treated as a complete file + fileCount++; + } + + if (this.getMetadataHash() != null) { + // Add the metadata file + fileCount++; + } + + return fileCount; + } + public List getChunks() { return this.chunks; } diff --git a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java index 0ae1026f..68909dee 100644 --- a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java +++ b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java @@ -193,7 +193,7 @@ public class ArbitraryTransactionUtils { ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); arbitraryDataFile.setMetadataHash(metadataHash); - return arbitraryDataFile.chunkCount() + 1; // +1 for the metadata file + return arbitraryDataFile.fileCount(); } public static boolean isFileRecent(Path filePath, long now, long cleanupAfter) { From b17035c864d196aa2510c54b28e3650c75165e0c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 11:57:07 +0000 Subject: [PATCH 049/215] Escape QDN vars and prefix with underscores. --- src/main/java/org/qortal/api/HTMLParser.java | 8 ++++++-- src/main/resources/q-apps/q-apps.js | 12 ++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 86f0c19e..9e7bb2d2 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -41,8 +41,12 @@ public class HTMLParser { String qAppsScriptElement = String.format("", this.qdnContext, this.service.toString(), this.resourceId, this.identifier, this.path); + // Escape and add vars + String service = this.service.toString().replace("\"","\\\""); + String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"") : ""; + String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : ""; + String path = this.path != null ? this.path.replace("\"","\\\"") : ""; + String qdnContextVar = String.format("", this.qdnContext, service, name, identifier, path); head.get(0).prepend(qdnContextVar); // Add base href tag diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 88be3d37..7abf4787 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -40,12 +40,12 @@ function handleResponse(event, response) { } function buildResourceUrl(service, name, identifier, path) { - if (qdnContext == "render") { + if (_qdnContext == "render") { url = "/render/" + service + "/" + name; if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); if (identifier != null) url = url.concat("?identifier=" + identifier); } - else if (qdnContext == "gateway") { + else if (_qdnContext == "gateway") { url = "/" + service + "/" + name; if (identifier != null) url = url.concat("/" + identifier); if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); @@ -329,10 +329,10 @@ else if (document.attachEvent) { document.addEventListener('DOMContentLoaded', () => { qortalRequest({ action: "QDN_RESOURCE_DISPLAYED", - service: qdnService, - name: qdnName, - identifier: qdnIdentifier, - path: qdnPath + service: _qdnService, + name: _qdnName, + identifier: _qdnIdentifier, + path: _qdnPath }); }); From d51f9368ef34549e19e19b70c7a170eb2ca9c80c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 12:39:44 +0000 Subject: [PATCH 050/215] Fixed bug in HTML parser --- src/main/java/org/qortal/api/HTMLParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 9e7bb2d2..3339ffd3 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -21,7 +21,7 @@ public class HTMLParser { public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data, String qdnContext, Service service, String identifier) { - String inPathWithoutFilename = inPath.substring(0, inPath.lastIndexOf('/')); + String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : ""; this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; this.data = data; this.qdnContext = qdnContext; From 8e2dd60ea08f99420baeb7f1ed0bddc4921f5e3f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 13:20:17 +0000 Subject: [PATCH 051/215] Increased default timeout for GET_USER_ACCOUNT from 30 seconds to 1 hour, to give the user more time to grant permissions. --- src/main/resources/q-apps/q-apps.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 7abf4787..afaa2986 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -384,6 +384,10 @@ function getDefaultTimeout(action) { if (action != null) { // Some actions need longer default timeouts, especially those that create transactions switch (action) { + case "GET_USER_ACCOUNT": + // User may take a long time to accept/deny the popup + return 60 * 60 * 1000; + case "FETCH_QDN_RESOURCE": // Fetching data can take a while, especially if the status hasn't been checked first return 60 * 1000; From d166f625d0a715ef334faf0da1d4e32caf2a1311 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 14:20:45 +0000 Subject: [PATCH 052/215] Rework of preview mode. All /arbitrary endpoints responsible for publishing data now support an optional "preview" query string parameter. If true, these endpoints will return a URL path to open the preview, rather than returning transaction bytes. --- Q-Apps.md | 16 +---- .../api/resource/ArbitraryResource.java | 65 ++++++++++++++++--- .../restricted/resource/RenderResource.java | 58 ----------------- 3 files changed, 59 insertions(+), 80 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 33bf3325..9a6e47b9 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -588,19 +588,9 @@ Publishing an in-development app to mainnet isn't recommended. There are several ### Preview mode -All read-only operations can be tested using preview mode. It can be used as follows: - -1. Ensure Qortal core is running locally on the machine you are developing on. Previewing via a remote node is not currently possible. -2. Make a local API call to `POST /render/preview`, passing in the API key (found in apikey.txt), and the path to the root of your Q-App, for example: -``` -curl -X POST "http://localhost:12391/render/preview" -H "X-API-KEY: apiKeyGoesHere" -d "/home/username/Websites/MyApp" -``` -3. This returns a URL, which can be copied and pasted into a browser to view the preview -4. Modify the Q-App as required, then repeat from step 2 to generate a new preview URL - -This is a short term method until preview functionality has been implemented within the UI. +Select "Preview" in the UI after choosing the zip. This allows for full Q-App testing without the need to publish any data. -### Single node testnet +### Testnets -For full read/write testing of a Q-App, you can set up a single node testnet (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](TestNets.md#quick-start). \ No newline at end of file +For an end-to-end test of Q-App publishing, you can use the official testnet, or set up a single node testnet of your own (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](TestNets.md#quick-start). \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 9569017c..79efc55f 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -38,6 +38,7 @@ import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.Controller; +import org.qortal.controller.arbitrary.ArbitraryDataRenderManager; import org.qortal.controller.arbitrary.ArbitraryDataStorageManager; import org.qortal.controller.arbitrary.ArbitraryMetadataManager; import org.qortal.data.account.AccountData; @@ -777,6 +778,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("preview") Boolean preview, String path) { Security.checkApiCallAllowed(request); @@ -785,7 +787,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, path, null, null, false, - title, description, tags, category); + title, description, tags, category, preview); } @POST @@ -822,6 +824,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("preview") Boolean preview, String path) { Security.checkApiCallAllowed(request); @@ -830,7 +833,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, path, null, null, false, - title, description, tags, category); + title, description, tags, category, preview); } @@ -868,6 +871,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("preview") Boolean preview, String base64) { Security.checkApiCallAllowed(request); @@ -876,7 +880,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, null, null, base64, false, - title, description, tags, category); + title, description, tags, category, preview); } @POST @@ -911,6 +915,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("preview") Boolean preview, String base64) { Security.checkApiCallAllowed(request); @@ -919,7 +924,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64, false, - title, description, tags, category); + title, description, tags, category, preview); } @@ -956,6 +961,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("preview") Boolean preview, String base64Zip) { Security.checkApiCallAllowed(request); @@ -964,7 +970,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, null, null, base64Zip, true, - title, description, tags, category); + title, description, tags, category, preview); } @POST @@ -999,6 +1005,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("preview") Boolean preview, String base64Zip) { Security.checkApiCallAllowed(request); @@ -1007,7 +1014,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64Zip, true, - title, description, tags, category); + title, description, tags, category, preview); } @@ -1047,6 +1054,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("preview") Boolean preview, String string) { Security.checkApiCallAllowed(request); @@ -1055,7 +1063,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, null, string, null, false, - title, description, tags, category); + title, description, tags, category, preview); } @POST @@ -1092,6 +1100,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("preview") Boolean preview, String string) { Security.checkApiCallAllowed(request); @@ -1100,15 +1109,48 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, null, string, null, false, - title, description, tags, category); + title, description, tags, category, preview); } // Shared methods + private String preview(String directoryPath, Service service) { + Security.checkApiCallAllowed(request); + ArbitraryTransactionData.Method method = ArbitraryTransactionData.Method.PUT; + ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.ZIP; + + ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(Paths.get(directoryPath), + null, service, null, method, compression, + null, null, null, null); + try { + arbitraryDataWriter.save(); + } catch (IOException | DataException | InterruptedException | MissingDataException e) { + LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); + } catch (RuntimeException e) { + LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); + } + + ArbitraryDataFile arbitraryDataFile = arbitraryDataWriter.getArbitraryDataFile(); + if (arbitraryDataFile != null) { + String digest58 = arbitraryDataFile.digest58(); + if (digest58 != null) { + // Pre-authorize resource + ArbitraryDataResource resource = new ArbitraryDataResource(digest58, null, null, null); + ArbitraryDataRenderManager.getInstance().addToAuthorizedResources(resource); + + return "/render/hash/" + digest58 + "?secret=" + Base58.encode(arbitraryDataFile.getSecret()); + } + } + return "Unable to generate preview URL"; + } + private String upload(Service service, String name, String identifier, String path, String string, String base64, boolean zipped, - String title, String description, List tags, Category category) { + String title, String description, List tags, Category category, + Boolean preview) { // Fetch public key from registered name try (final Repository repository = RepositoryManager.getRepository()) { NameData nameData = repository.getNameRepository().fromName(name); @@ -1171,6 +1213,11 @@ public class ArbitraryResource { } } + // Finish here if user has requested a preview + if (preview != null && preview == true) { + return this.preview(path, service); + } + try { ArbitraryDataTransactionBuilder transactionBuilder = new ArbitraryDataTransactionBuilder( repository, publicKey58, Paths.get(path), name, null, service, identifier, diff --git a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java index 95360419..53c56f7b 100644 --- a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java @@ -42,64 +42,6 @@ public class RenderResource { @Context HttpServletResponse response; @Context ServletContext context; - @POST - @Path("/preview") - @Operation( - summary = "Generate preview URL based on a user-supplied path and service", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string", example = "/Users/user/Documents/MyStaticWebsite" - ) - ) - ), - responses = { - @ApiResponse( - description = "a temporary URL to preview the website", - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string" - ) - ) - ) - } - ) - @SecurityRequirement(name = "apiKey") - public String preview(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String directoryPath) { - Security.checkApiCallAllowed(request); - Method method = Method.PUT; - Compression compression = Compression.ZIP; - - ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(Paths.get(directoryPath), - null, Service.WEBSITE, null, method, compression, - null, null, null, null); - try { - arbitraryDataWriter.save(); - } catch (IOException | DataException | InterruptedException | MissingDataException e) { - LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); - } catch (RuntimeException e) { - LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); - } - - ArbitraryDataFile arbitraryDataFile = arbitraryDataWriter.getArbitraryDataFile(); - if (arbitraryDataFile != null) { - String digest58 = arbitraryDataFile.digest58(); - if (digest58 != null) { - // Pre-authorize resource - ArbitraryDataResource resource = new ArbitraryDataResource(digest58, null, null, null); - ArbitraryDataRenderManager.getInstance().addToAuthorizedResources(resource); - - return "http://localhost:12391/render/hash/" + digest58 + "?secret=" + Base58.encode(arbitraryDataFile.getSecret()); - } - } - return "Unable to generate preview URL"; - } - @POST @Path("/authorize/{resourceId}") @SecurityRequirement(name = "apiKey") From 9ea2d7ab09defb9160199c31b4dc737022379be4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 14:24:10 +0000 Subject: [PATCH 053/215] Updated documentation to remove an action that isn't supported in Q-Apps v1. --- Q-Apps.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 9a6e47b9..b551239e 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -283,14 +283,6 @@ await qortalRequest({ }); ``` -### Get wallet balance (foreign coin) -_Requires user approval_ -``` -await qortalRequest({ - action: "GET_WALLET_BALANCE", - coin: "LTC" -}); -``` ### Get address or asset balance ``` From b254ca7706e90d4ab08b0e36f3204d205bd77f1d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 15:39:37 +0000 Subject: [PATCH 054/215] Added support for optional Base64 encoding in FETCH_QDN_RESOURCE. --- Q-Apps.md | 17 +++++++++-------- .../qortal/api/resource/ArbitraryResource.java | 16 +++++++++++++--- src/main/resources/q-apps/q-apps.js | 1 + 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index b551239e..566112fd 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -116,7 +116,8 @@ async function myfunction() { action: "FETCH_QDN_RESOURCE", name: "QortalDemo", service: "THUMBNAIL", - identifier: "qortal_avatar" + identifier: "qortal_avatar", + encoding: "base64" }, timeout); // Do something with the avatar here @@ -225,13 +226,13 @@ let res = await qortalRequest({ ``` ### Fetch QDN single file resource -Data is returned in the base64 format ``` let res = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: "QortalDemo", service: "THUMBNAIL", identifier: "qortal_avatar", // Optional. If omitted, the default resource is returned, or you can alternatively use the keyword "default" + encoding: "base64", // Optional. If omitted, data is returned in raw form rebuild: false }); ``` @@ -548,26 +549,26 @@ Here is a sample application to display the logged-in user's avatar: return; } - // Download the avatar of the first registered name + // Download base64-encoded avatar of the first registered name let avatar = await qortalRequest({ action: "FETCH_QDN_RESOURCE", name: names[0].name, service: "THUMBNAIL", - identifier: "qortal_avatar" + identifier: "qortal_avatar", + encoding: "base64" }); - console.log("avatar: " + JSON.stringify(avatar)); + console.log("Avatar size: " + avatar.length + " bytes"); // Display the avatar image on the screen - document.getElementsById("avatar").src = "data:image/png;base64," + avatar; + document.getElementById("avatar").src = "data:image/png;base64," + avatar; } catch(e) { console.log("Error: " + JSON.stringify(e)); } } - showAvatar(); - + diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 79efc55f..2abc07e8 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -17,6 +17,7 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; @@ -646,6 +647,7 @@ public class ArbitraryResource { @PathParam("service") Service service, @PathParam("name") String name, @QueryParam("filepath") String filepath, + @QueryParam("encoding") String encoding, @QueryParam("rebuild") boolean rebuild, @QueryParam("async") boolean async, @QueryParam("attempts") Integer attempts) { @@ -655,7 +657,7 @@ public class ArbitraryResource { Security.checkApiCallAllowed(request); } - return this.download(service, name, null, filepath, rebuild, async, attempts); + return this.download(service, name, null, filepath, encoding, rebuild, async, attempts); } @GET @@ -681,6 +683,7 @@ public class ArbitraryResource { @PathParam("name") String name, @PathParam("identifier") String identifier, @QueryParam("filepath") String filepath, + @QueryParam("encoding") String encoding, @QueryParam("rebuild") boolean rebuild, @QueryParam("async") boolean async, @QueryParam("attempts") Integer attempts) { @@ -690,7 +693,7 @@ public class ArbitraryResource { Security.checkApiCallAllowed(request, apiKey); } - return this.download(service, name, identifier, filepath, rebuild, async, attempts); + return this.download(service, name, identifier, filepath, encoding, rebuild, async, attempts); } @@ -1239,7 +1242,7 @@ public class ArbitraryResource { } } - private HttpServletResponse download(Service service, String name, String identifier, String filepath, boolean rebuild, boolean async, Integer maxAttempts) { + private HttpServletResponse download(Service service, String name, String identifier, String filepath, String encoding, boolean rebuild, boolean async, Integer maxAttempts) { ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); try { @@ -1298,7 +1301,14 @@ public class ArbitraryResource { String message = String.format("No file exists at filepath: %s", filepath); throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, message); } + byte[] data = Files.readAllBytes(path); + + // Encode the data if requested + if (encoding != null && Objects.equals(encoding.toLowerCase(), "base64")) { + data = Base64.encode(data); + } + response.setContentType(context.getMimeType(path.toString())); response.setContentLength(data.length); response.getOutputStream().write(data); diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index afaa2986..8315c6c4 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -173,6 +173,7 @@ window.addEventListener("message", (event) => { url = url.concat("?"); if (data.filepath != null) url = url.concat("&filepath=" + data.filepath); if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString()) + if (data.encoding != null) url = url.concat("&encoding=" + data.encoding); response = httpGet(url); break; From 308196250e18f9e6f086d87afe8decc905ce2031 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 16:13:49 +0000 Subject: [PATCH 055/215] Updated documentation. --- Q-Apps.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 566112fd..08adaba5 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -544,7 +544,7 @@ Here is a sample application to display the logged-in user's avatar: }); console.log("names: " + JSON.stringify(names)); - if (names.size == 0) { + if (names.length == 0) { console.log("User has no registered names"); return; } @@ -586,4 +586,13 @@ Select "Preview" in the UI after choosing the zip. This allows for full Q-App te ### Testnets -For an end-to-end test of Q-App publishing, you can use the official testnet, or set up a single node testnet of your own (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](TestNets.md#quick-start). \ No newline at end of file +For an end-to-end test of Q-App publishing, you can use the official testnet, or set up a single node testnet of your own (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](TestNets.md#quick-start). + + +### Debugging + +It is recommended that you develop and test in a web browser, to allow access to the javascript console. To do this: +1. Open the UI app, then minimise it. +2. In a Chromium-based web browser, visit: http://localhost:12388/ +3. Log in to your account and then preview your app/website. +4. Go to `View > Developer > JavaScript Console`. Here you can monitor console logs, errors, and network requests from your app, in the same way as any other web-app. From 7d38fa909d8f02f2f46d7d22b939f81d8057b6f7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 16:14:43 +0000 Subject: [PATCH 056/215] Rebuild name in ArbitraryTransaction.preProcess() --- .../org/qortal/transaction/ArbitraryTransaction.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index 50d8ccad..3330a84c 100644 --- a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java +++ b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java @@ -9,6 +9,7 @@ import org.qortal.account.Account; import org.qortal.block.BlockChain; import org.qortal.controller.arbitrary.ArbitraryDataManager; import org.qortal.controller.arbitrary.ArbitraryDataStorageManager; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.crypto.Crypto; import org.qortal.crypto.MemoryPoW; import org.qortal.data.PaymentData; @@ -241,7 +242,13 @@ public class ArbitraryTransaction extends Transaction { @Override public void preProcess() throws DataException { - // Nothing to do + ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) transactionData; + + // Rebuild this name in the Names table from the transaction history + // This is necessary because in some rare cases names can be missing from the Names table after registration + // but we have been unable to reproduce the issue and track down the root cause + NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); + namesDatabaseIntegrityCheck.rebuildName(arbitraryTransactionData.getName(), this.repository); } @Override From cf0681d7df6620acdc1a47b1f9d100670591177e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 17:10:14 +0000 Subject: [PATCH 057/215] Only rebuild if transaction has a name. --- src/main/java/org/qortal/transaction/ArbitraryTransaction.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index 3330a84c..7e7d4040 100644 --- a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java +++ b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java @@ -243,6 +243,8 @@ public class ArbitraryTransaction extends Transaction { @Override public void preProcess() throws DataException { ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) transactionData; + if (arbitraryTransactionData.getName() == null) + return; // Rebuild this name in the Names table from the transaction history // This is necessary because in some rare cases names can be missing from the Names table after registration From 3318093a4fa808e0f61fb1e2465e2493baf91281 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 17:33:15 +0000 Subject: [PATCH 058/215] Fixed preview functionality for resources other than websites/apps. --- .../java/org/qortal/api/restricted/resource/RenderResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java index 53c56f7b..2d3a0e49 100644 --- a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java @@ -110,7 +110,7 @@ public class RenderResource { if (!Settings.getInstance().isQDNAuthBypassEnabled()) Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); - return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, null, "/", secret58, "/render/hash", true, false, theme); + return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, "/", secret58, "/render/hash", true, false, theme); } @GET From c40d0cc67b0e9518c49656e6e9145d1b4eacc66c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 17:47:14 +0000 Subject: [PATCH 059/215] Same fix again but for multi file resources too. --- .../java/org/qortal/api/restricted/resource/RenderResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java index 2d3a0e49..7a772f9f 100644 --- a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java @@ -122,7 +122,7 @@ public class RenderResource { if (!Settings.getInstance().isQDNAuthBypassEnabled()) Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); - return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, null, inPath, secret58, "/render/hash", true, false, theme); + return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, inPath, secret58, "/render/hash", true, false, theme); } @GET From 4b7844dc069039e98d674b750e7313b7d34b6074 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 17:55:46 +0000 Subject: [PATCH 060/215] Pass the UI's theme to Q-Apps themselves, so they have the option of adapting to the user's theme. Variable name is _qdnTheme, and possible values are "dark" or "light" --- src/main/java/org/qortal/api/HTMLParser.java | 7 +++++-- .../java/org/qortal/arbitrary/ArbitraryDataRenderer.java | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 3339ffd3..dbc75243 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -18,9 +18,10 @@ public class HTMLParser { private Service service; private String identifier; private String path; + private String theme; public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data, - String qdnContext, Service service, String identifier) { + String qdnContext, Service service, String identifier, String theme) { String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : ""; this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; this.data = data; @@ -29,6 +30,7 @@ public class HTMLParser { this.service = service; this.identifier = identifier; this.path = inPath; + this.theme = theme; } public void addAdditionalHeaderTags() { @@ -46,7 +48,8 @@ public class HTMLParser { String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"") : ""; String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : ""; String path = this.path != null ? this.path.replace("\"","\\\"") : ""; - String qdnContextVar = String.format("", this.qdnContext, service, name, identifier, path); + String theme = this.theme != null ? this.theme.replace("\"","\\\"") : ""; + String qdnContextVar = String.format("", this.qdnContext, theme, service, name, identifier, path); head.get(0).prepend(qdnContextVar); // Add base href tag diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 9ad021c1..584dd12a 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -129,7 +129,7 @@ public class ArbitraryDataRenderer { if (HTMLParser.isHtmlFile(filename)) { // HTML file - needs to be parsed byte[] data = Files.readAllBytes(Paths.get(filePath)); // TODO: limit file size that can be read into memory - HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext, service, identifier); + HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext, service, identifier, theme); htmlParser.addAdditionalHeaderTags(); response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob:; img-src 'self' data: blob:;"); response.setContentType(context.getMimeType(filename)); From 94f14a39e38500ff623673ee2420cfafe348c50c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 18:16:35 +0000 Subject: [PATCH 061/215] Ensure theme is transferred when visiting a linked resource. --- src/main/resources/q-apps/q-apps.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 8315c6c4..40c8716c 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -55,6 +55,8 @@ function buildResourceUrl(service, name, identifier, path) { url = "/" + name; if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); } + url = url.concat((url.includes("?") ? "" : "?") + "&theme=" + _qdnTheme); + return url; } From ac60ef30a3a40d503c2c746b1ce2285319a5c6b0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 5 Mar 2023 10:51:26 +0000 Subject: [PATCH 062/215] Added JSON service, with a maximum size of 25KB, and a requirement that the data must be valid JSON. --- .../org/qortal/arbitrary/misc/Service.java | 34 ++++++++++++- .../test/arbitrary/ArbitraryServiceTests.java | 49 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 5ea1b7aa..3a549180 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -8,10 +8,13 @@ import org.qortal.utils.FilesystemUtils; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; +import com.fasterxml.jackson.databind.ObjectMapper; + import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; @@ -94,6 +97,31 @@ public enum Service { PLAYLIST(910, true, null, null), APP(1000, false, null, null), METADATA(1100, false, null, null), + JSON(1110, true, 25*1024L, null) { + @Override + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + + File[] files = path.toFile().listFiles(); + + // Require a single file + if (files != null || !path.toFile().isFile()) { + return ValidationResult.INVALID_FILE_COUNT; + } + + // Require valid JSON + String json = Files.readString(path); + try { + objectMapper.readTree(json); + return ValidationResult.OK; + } catch (IOException e) { + return ValidationResult.INVALID_CONTENT; + } + } + }, GIF_REPOSITORY(1200, true, 25*1024*1024L, null) { @Override public ValidationResult validate(Path path) throws IOException { @@ -139,6 +167,9 @@ public enum Service { private static final Map map = stream(Service.values()) .collect(toMap(service -> service.value, service -> service)); + // For JSON validation + private static final ObjectMapper objectMapper = new ObjectMapper(); + Service(int value, boolean requiresValidation, Long maxSize, List requiredKeys) { this.value = value; this.requiresValidation = requiresValidation; @@ -199,7 +230,8 @@ public enum Service { DIRECTORIES_NOT_ALLOWED(5), INVALID_FILE_EXTENSION(6), MISSING_DATA(7), - INVALID_FILE_COUNT(8); + INVALID_FILE_COUNT(8), + INVALID_CONTENT(9); public final int value; diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index 96843876..8978a3df 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -22,6 +22,8 @@ import org.qortal.test.common.transaction.TestTransaction; import org.qortal.transaction.RegisterNameTransaction; import org.qortal.utils.Base58; +import java.io.BufferedWriter; +import java.io.FileWriter; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -389,4 +391,51 @@ public class ArbitraryServiceTests extends Common { } } + @Test + public void testValidateValidJson() throws IOException { + String invalidJsonString = "{\"test\": true, \"test2\": \"valid\"}"; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateValidJson"); + Path filePath = Paths.get(path.toString(), "test.json"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(invalidJsonString); + writer.close(); + + Service service = Service.JSON; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + @Test + public void testValidateInvalidJson() throws IOException { + String invalidJsonString = "{\"test\": true, \"test2\": invalid}"; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateInvalidJson"); + Path filePath = Paths.get(path.toString(), "test.json"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(invalidJsonString); + writer.close(); + + Service service = Service.JSON; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.INVALID_CONTENT, service.validate(filePath)); + } + + @Test + public void testValidateEmptyJson() throws IOException { + Path path = Files.createTempDirectory("testValidateEmptyJson"); + + Service service = Service.JSON; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); + } + } \ No newline at end of file From d6ab9eb06615139270b078e190dea0d62b48ab83 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 5 Mar 2023 11:39:53 +0000 Subject: [PATCH 063/215] Rework of service validation, to allow a service to be specified as a single file resource. This removes some complexity and duplication from custom validation functions. Q-Chat QDN functionality will need a re-test. --- .../org/qortal/arbitrary/misc/Service.java | 87 ++++++++----------- .../org/qortal/utils/FilesystemUtils.java | 4 +- .../test/arbitrary/ArbitraryServiceTests.java | 6 +- 3 files changed, 42 insertions(+), 55 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 3a549180..8ca62433 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -19,9 +19,9 @@ import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; public enum Service { - AUTO_UPDATE(1, false, null, null), - ARBITRARY_DATA(100, false, null, null), - QCHAT_ATTACHMENT(120, true, 1024*1024L, null) { + AUTO_UPDATE(1, false, null, false, null), + ARBITRARY_DATA(100, false, null, false, null), + QCHAT_ATTACHMENT(120, true, 1024*1024L, true, null) { @Override public ValidationResult validate(Path path) throws IOException { ValidationResult superclassResult = super.validate(path); @@ -29,37 +29,24 @@ public enum Service { return superclassResult; } - // Custom validation function to require a single file, with a whitelisted extension - int fileCount = 0; File[] files = path.toFile().listFiles(); // If already a single file, replace the list with one that contains that file only if (files == null && path.toFile().isFile()) { files = new File[] { path.toFile() }; } - if (files != null) { - for (File file : files) { - if (file.getName().equals(".qortal")) { - continue; - } - if (file.isDirectory()) { - return ValidationResult.DIRECTORIES_NOT_ALLOWED; - } - final String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(); - // We must allow blank file extensions because these are used by data published from a plaintext or base64-encoded string - final List allowedExtensions = Arrays.asList("zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx", ""); - if (extension == null || !allowedExtensions.contains(extension)) { - return ValidationResult.INVALID_FILE_EXTENSION; - } - fileCount++; + // Now validate the file's extension + if (files != null && files[0] != null) { + final String extension = FilenameUtils.getExtension(files[0].getName()).toLowerCase(); + // We must allow blank file extensions because these are used by data published from a plaintext or base64-encoded string + final List allowedExtensions = Arrays.asList("zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx", ""); + if (extension == null || !allowedExtensions.contains(extension)) { + return ValidationResult.INVALID_FILE_EXTENSION; } } - if (fileCount != 1) { - return ValidationResult.INVALID_FILE_COUNT; - } return ValidationResult.OK; } }, - WEBSITE(200, true, null, null) { + WEBSITE(200, true, null, false, null) { @Override public ValidationResult validate(Path path) throws IOException { ValidationResult superclassResult = super.validate(path); @@ -81,23 +68,23 @@ public enum Service { return ValidationResult.MISSING_INDEX_FILE; } }, - GIT_REPOSITORY(300, false, null, null), - IMAGE(400, true, 10*1024*1024L, null), - THUMBNAIL(410, true, 500*1024L, null), - QCHAT_IMAGE(420, true, 500*1024L, null), - VIDEO(500, false, null, null), - AUDIO(600, false, null, null), - QCHAT_AUDIO(610, true, 10*1024*1024L, null), - QCHAT_VOICE(620, true, 10*1024*1024L, null), - BLOG(700, false, null, null), - BLOG_POST(777, false, null, null), - BLOG_COMMENT(778, false, null, null), - DOCUMENT(800, false, null, null), - LIST(900, true, null, null), - PLAYLIST(910, true, null, null), - APP(1000, false, null, null), - METADATA(1100, false, null, null), - JSON(1110, true, 25*1024L, null) { + GIT_REPOSITORY(300, false, null, false, null), + IMAGE(400, true, 10*1024*1024L, true, null), + THUMBNAIL(410, true, 500*1024L, true, null), + QCHAT_IMAGE(420, true, 500*1024L, true, null), + VIDEO(500, false, null, true, null), + AUDIO(600, false, null, true, null), + QCHAT_AUDIO(610, true, 10*1024*1024L, true, null), + QCHAT_VOICE(620, true, 10*1024*1024L, true, null), + BLOG(700, false, null, false, null), + BLOG_POST(777, false, null, true, null), + BLOG_COMMENT(778, false, null, true, null), + DOCUMENT(800, false, null, true, null), + LIST(900, true, null, true, null), + PLAYLIST(910, true, null, true, null), + APP(1000, false, null, false, null), + METADATA(1100, false, null, true, null), + JSON(1110, true, 25*1024L, true, null) { @Override public ValidationResult validate(Path path) throws IOException { ValidationResult superclassResult = super.validate(path); @@ -105,13 +92,6 @@ public enum Service { return superclassResult; } - File[] files = path.toFile().listFiles(); - - // Require a single file - if (files != null || !path.toFile().isFile()) { - return ValidationResult.INVALID_FILE_COUNT; - } - // Require valid JSON String json = Files.readString(path); try { @@ -122,7 +102,7 @@ public enum Service { } } }, - GIF_REPOSITORY(1200, true, 25*1024*1024L, null) { + GIF_REPOSITORY(1200, true, 25*1024*1024L, false, null) { @Override public ValidationResult validate(Path path) throws IOException { ValidationResult superclassResult = super.validate(path); @@ -162,6 +142,7 @@ public enum Service { public final int value; private final boolean requiresValidation; private final Long maxSize; + private final boolean single; private final List requiredKeys; private static final Map map = stream(Service.values()) @@ -170,10 +151,11 @@ public enum Service { // For JSON validation private static final ObjectMapper objectMapper = new ObjectMapper(); - Service(int value, boolean requiresValidation, Long maxSize, List requiredKeys) { + Service(int value, boolean requiresValidation, Long maxSize, boolean single, List requiredKeys) { this.value = value; this.requiresValidation = requiresValidation; this.maxSize = maxSize; + this.single = single; this.requiredKeys = requiredKeys; } @@ -192,6 +174,11 @@ public enum Service { } } + // Validate file count if needed + if (this.single && data == null) { + return ValidationResult.INVALID_FILE_COUNT; + } + // Validate required keys if needed if (this.requiredKeys != null) { if (data == null) { diff --git a/src/main/java/org/qortal/utils/FilesystemUtils.java b/src/main/java/org/qortal/utils/FilesystemUtils.java index 1b3de544..64148f5e 100644 --- a/src/main/java/org/qortal/utils/FilesystemUtils.java +++ b/src/main/java/org/qortal/utils/FilesystemUtils.java @@ -241,7 +241,9 @@ public class FilesystemUtils { String[] files = ArrayUtils.removeElement(path.toFile().list(), ".qortal"); if (files.length == 1) { Path filePath = Paths.get(path.toString(), files[0]); - data = Files.readAllBytes(filePath); + if (filePath.toFile().isFile()) { + data = Files.readAllBytes(filePath); + } } } diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index 8978a3df..940b33a9 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -319,17 +319,15 @@ public class ArbitraryServiceTests extends Common { // Write the data to several files in a temp path Path path = Files.createTempDirectory("testValidateMultiLayerQChatAttachment"); path.toFile().deleteOnExit(); - Files.write(Paths.get(path.toString(), "file1.txt"), data, StandardOpenOption.CREATE); Path subdirectory = Paths.get(path.toString(), "subdirectory"); Files.createDirectories(subdirectory); - Files.write(Paths.get(subdirectory.toString(), "file2.txt"), data, StandardOpenOption.CREATE); - Files.write(Paths.get(subdirectory.toString(), "file3.txt"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(subdirectory.toString(), "file.txt"), data, StandardOpenOption.CREATE); Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); } @Test From 83b0ce53e6888c6f1fd9bb153ee2e5989749eb32 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 5 Mar 2023 13:16:08 +0000 Subject: [PATCH 064/215] Fixed bug in JSON validation. --- src/main/java/org/qortal/arbitrary/misc/Service.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 8ca62433..a52571f2 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -8,6 +8,7 @@ import org.qortal.utils.FilesystemUtils; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -93,7 +94,8 @@ public enum Service { } // Require valid JSON - String json = Files.readString(path); + byte[] data = FilesystemUtils.getSingleFileContents(path); + String json = new String(data, StandardCharsets.UTF_8); try { objectMapper.readTree(json); return ValidationResult.OK; From 44aa0a6026d5ca3988dd5a435dabfa2c44512b1d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 10:00:30 +0000 Subject: [PATCH 065/215] Catch ArithmeticException in block minter, so that it retries instead of giving up completely. --- src/main/java/org/qortal/controller/BlockMinter.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 185dd7cd..bc879f23 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -432,6 +432,10 @@ public class BlockMinter extends Thread { // Unable to process block - report and discard LOGGER.error("Unable to process newly minted block?", e); newBlocks.clear(); + } catch (ArithmeticException e) { + // Unable to process block - report and discard + LOGGER.error("Unable to process newly minted block?", e); + newBlocks.clear(); } } finally { blockchainLock.unlock(); From 82c66c0555479df84eabcf12c8f7c9f5da95c5a8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 14:28:13 +0000 Subject: [PATCH 066/215] Added testnet files to testnet/ directory. This will be maintained with future feature triggers etc. --- .gitignore | 1 - TestNets.md => testnet/README.md | 11 +- testnet/settings-test.json | 18 + testnet/testchain.json | 2661 ++++++++++++++++++++++++++++++ 4 files changed, 2687 insertions(+), 4 deletions(-) rename TestNets.md => testnet/README.md (91%) create mode 100755 testnet/settings-test.json create mode 100644 testnet/testchain.json diff --git a/.gitignore b/.gitignore index fcc42db9..218e8043 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ /.mvn.classpath /notes* /settings.json -/testnet* /settings*.json /testchain*.json /run-testnet*.sh diff --git a/TestNets.md b/testnet/README.md similarity index 91% rename from TestNets.md rename to testnet/README.md index dd84e1a1..3f7ea9f6 100644 --- a/TestNets.md +++ b/testnet/README.md @@ -2,9 +2,10 @@ ## Create testnet blockchain config -- You can begin by copying the mainnet blockchain config `src/main/resources/blockchain.json` +- The simplest option is to use the testchain.json included in this folder. +- Alternatively, you can create one by copying the mainnet blockchain config `src/main/resources/blockchain.json` - Insert `"isTestChain": true,` after the opening `{` -- Modify testnet genesis block +- Modify testnet genesis block, feature triggers etc ### Testnet genesis block @@ -97,6 +98,10 @@ Your options are: { "isTestNet": true, "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", + "dogecoinNet": "TEST3", + "digibyteNet": "TEST3", + "ravencoinNet": "TEST3", "repositoryPath": "db-testnet", "blockchainConfig": "testchain.json", "minBlockchainPeers": 1, @@ -113,7 +118,7 @@ Your options are: ## Quick start Here are some steps to quickly get a single node testnet up and running with a generic minting account: -1. Start with template `settings-test.json`, and create a `testchain.json` based on mainnet's blockchain.json (or obtain one from Qortal developers). These should be in the same directory as the jar. +1. Start with template `settings-test.json`, and `testchain.json` which can be found in this folder. Copy/move them to the same directory as the jar. 2. Make sure feature triggers and other timestamp/height activations are correctly set. Generally these would be `0` so that they are enabled from the start. 3. Set a recent genesis `timestamp` in testchain.json, and add this reward share entry: `{ "type": "REWARD_SHARE", "minterPublicKey": "DwcUnhxjamqppgfXCLgbYRx8H9XFPUc2qYRy3CEvQWEw", "recipient": "QbTDMss7NtRxxQaSqBZtSLSNdSYgvGaqFf", "rewardSharePublicKey": "CRvQXxFfUMfr4q3o1PcUZPA4aPCiubBsXkk47GzRo754", "sharePercent": 0 },` diff --git a/testnet/settings-test.json b/testnet/settings-test.json new file mode 100755 index 00000000..e49368f8 --- /dev/null +++ b/testnet/settings-test.json @@ -0,0 +1,18 @@ +{ + "isTestNet": true, + "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", + "dogecoinNet": "TEST3", + "digibyteNet": "TEST3", + "ravencoinNet": "TEST3", + "repositoryPath": "db-testnet", + "blockchainConfig": "testchain.json", + "minBlockchainPeers": 1, + "apiDocumentationEnabled": true, + "apiRestricted": false, + "bootstrap": false, + "maxPeerConnectionTime": 999999999, + "localAuthBypassEnabled": true, + "singleNodeTestnet": false, + "recoveryModeTimeout": 0 +} diff --git a/testnet/testchain.json b/testnet/testchain.json new file mode 100644 index 00000000..31b691ec --- /dev/null +++ b/testnet/testchain.json @@ -0,0 +1,2661 @@ +{ + "isTestChain": true, + "blockTimestampMargin": 2000, + "transactionExpiryPeriod": 86400000, + "maxBlockSize": 2097152, + "maxBytesPerUnitFee": 1024, + "unitFee": "0.001", + "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "1.25" } + ], + "useBrokenMD160ForAddresses": false, + "requireGroupForApproval": false, + "defaultGroupId": 0, + "oneNamePerAccount": true, + "minAccountLevelToMint": 1, + "minAccountLevelForBlockSubmissions": 1, + "minAccountLevelToRewardShare": 2, + "maxRewardSharesPerFounderMintingAccount": 10, + "maxRewardSharesByTimestamp": [ + { "timestamp": 0, "maxShares": 10 } + ], + "founderEffectiveMintingLevel": 10, + "onlineAccountSignaturesMinLifetime": 43200000, + "onlineAccountSignaturesMaxLifetime": 86400000, + "onlineAccountsModulusV2Timestamp": 0, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "rewardsByHeight": [ + { "height": 1, "reward": 5.00 }, + { "height": 259201, "reward": 4.75 }, + { "height": 518401, "reward": 4.50 }, + { "height": 777601, "reward": 4.25 }, + { "height": 1036801, "reward": 4.00 }, + { "height": 1296001, "reward": 3.75 }, + { "height": 1555201, "reward": 3.50 }, + { "height": 1814401, "reward": 3.25 }, + { "height": 2073601, "reward": 3.00 }, + { "height": 2332801, "reward": 2.75 }, + { "height": 2592001, "reward": 2.50 }, + { "height": 2851201, "reward": 2.25 }, + { "height": 3110401, "reward": 2.00 } + ], + "sharesByLevelV1": [ + { "id": 1, "levels": [ 1, 2 ], "share": 0.05 }, + { "id": 2, "levels": [ 3, 4 ], "share": 0.10 }, + { "id": 3, "levels": [ 5, 6 ], "share": 0.15 }, + { "id": 4, "levels": [ 7, 8 ], "share": 0.20 }, + { "id": 5, "levels": [ 9, 10 ], "share": 0.25 } + ], + "sharesByLevelV2": [ + { "id": 1, "levels": [ 1, 2 ], "share": 0.06 }, + { "id": 2, "levels": [ 3, 4 ], "share": 0.13 }, + { "id": 3, "levels": [ 5, 6 ], "share": 0.19 }, + { "id": 4, "levels": [ 7, 8 ], "share": 0.26 }, + { "id": 5, "levels": [ 9, 10 ], "share": 0.32 } + ], + "qoraHoldersShareByHeight": [ + { "height": 1, "share": 0.20 }, + { "height": 1010000, "share": 0.01 } + ], + "qoraPerQortReward": 250, + "minAccountsToActivateShareBin": 30, + "shareBinActivationMinLevel": 7, + "blocksNeededByLevel": [ 50, 64800, 129600, 172800, 244000, 345600, 518400, 691200, 864000, 1036800 ], + "blockTimingsByHeight": [ + { "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 } + ], + "ciyamAtSettings": { + "feePerStep": "0.00000001", + "maxStepsPerRound": 500, + "stepsPerFunctionCall": 10, + "minutesPerBlock": 1 + }, + "featureTriggers": { + "atFindNextTransactionFix": 0, + "newBlockSigHeight": 0, + "shareBinFix": 0, + "sharesByLevelV2Height": 0, + "rewardShareLimitTimestamp": 0, + "calcChainWeightTimestamp": 0, + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 9999999999999, + "disableReferenceTimestamp": 0, + "aggregateSignatureTimestamp": 0, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 9999999, + "feeValidationFixTimestamp": 0, + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 9999999999999 + }, + "genesisInfo": { + "version": 4, + "timestamp": "1677572542000", + "transactions": [ + { "type": "ISSUE_ASSET", "assetName": "QORT", "description": "QORTAL coin", "quantity": 0, "isDivisible": true, "data": "{}" }, + { "type": "ISSUE_ASSET", "assetName": "Legacy-QORA", "description": "Representative legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + { "type": "ISSUE_ASSET", "assetName": "QORT-from-QORA", "description": "QORT gained from holding legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + + { "type": "REWARD_SHARE", "minterPublicKey": "HFDmuc4HAAoVs9Siea3MugjBHasbotgVz2gsRDuLAAcB", "recipient": "QY82MasqEH6ChwXaETH4piMtE8Pk4NBWD3", "rewardSharePublicKey": "F35TbQXmgzz32cALj29jxzpdYSUKQvssqThLsZSabSXx", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "HmViWJ2SMRVTYNuMvNYFBX7DitXcEB2gBZasAN3uheJL", "recipient": "Qi3N6fNRrs15EHmkxYyWHyh4z3Dp2rVU2i", "rewardSharePublicKey": "8dsLkxj2C19iK2wob9YNDdQ2mdzyV9X6aQzfHdG1sWrp", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "79THiqG9Cftu7RFEA3SvW9G4YUim7qojhbyepb68trH4", "recipient": "QS7K9EsSDeCdb7T8kzZaFNGLomNmmET2Dr", "rewardSharePublicKey": "BuKWPsnu1sxxsFT2wNGCgcicm48ch4hhvQq9585P2pth", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "KBStPrMw84Fr84YJG5UQEZkeEzbCfRhKtvhq1kmhNJU", "recipient": "QP9y1CmZRyw3oKNSTTm7Q74FxWp2mEHc26", "rewardSharePublicKey": "6eW63qGsiz6JGfH4ga8wZStsYpU2H3w7qijHXr2JADFv", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "C9iuYc8GB9cVNNPr28v7pjY1macmsroFYX65CTVPjXLn", "recipient": "QNxfUZ9xgMYs3Gw6jG9Hqsg957yBJz2YEt", "rewardSharePublicKey": "4LvsURDbDhkR3f9zvnZun53GEtwERPsXLZas5CA4mBPH", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "DwcUnhxjamqppgfXCLgbYRx8H9XFPUc2qYRy3CEvQWEw", "recipient": "QbTDMss7NtRxxQaSqBZtSLSNdSYgvGaqFf", "rewardSharePublicKey": "CRvQXxFfUMfr4q3o1PcUZPA4aPCiubBsXkk47GzRo754", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "8ZHT347rPzCY8Jmk9R2MTEm1c2t6zLGjSU8nKQh4JgBt", "recipient": "QiTygNmENd8bjyiaK1WwgeX9EF1H8Kapfq", "rewardSharePublicKey": "BSatVDRBBzeSMwXfDU7ngjVLhUFfS3CTpdmBWb2wCSU", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "BqWV8eMDUxAJ7FEcjQZzCsNKi6TggwYd7yQHWtmYJLWd", "recipient": "QScfgds57u1zEPWyjL3GyKsZQ6PRbuVv2G", "rewardSharePublicKey": "AZBGQ6pVcH8KHBRuqNyBZSkFRedida8GdjoPJvDbgXtn", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "ELt8dgskQ9zfwF9dwVYwjq2zXFExstRJoPD4gCC4991d", "recipient": "QZm6GbBJuLJnTbftJAaJtw2ZJqXiDTu2pD", "rewardSharePublicKey": "C6aVBbUHy8nAS3wYQo6jdWFTBagmqrh3JhRo8VH5k1Bx", "sharePercent": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "Btqz7ug1XEMMun8hXZHVZWctRZxMKYeExsax7ohgzGNE", "recipient": "Qb5rprTsoSoKaMcdwLFL7jP3eyhK3LGqNm", "rewardSharePublicKey": "CdVq4RwirHMjaRkM38PAtMvLNkokqYCiu2srQ3qf7znq", "sharePercent": 0 }, + + { "type": "ACCOUNT_FLAGS", "target": "QY82MasqEH6ChwXaETH4piMtE8Pk4NBWD3", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qi3N6fNRrs15EHmkxYyWHyh4z3Dp2rVU2i", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QPsjHoKhugEADrtSQP5xjFgsaQPn9WmE3Y", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbmBdAbvmXqCya8bia8WaD5izumKkC9BrY", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QhPXuFFRa9s91Q2qYKpSN5LVCUTqYkgRLz", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QPFM4xX2826MuBEhMtdReW1QR3vRYrQff3", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QSJ5cDLWivGcn9ym21azufBfiqeuGH1maq", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTx98gU8ErigXkViWkvRH5JfaMpk8b3bHe", "andMask": -1, "orMask": 0, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QY82MasqEH6ChwXaETH4piMtE8Pk4NBWD3", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcPro2T97Q8cAfcVM4Pn4fv71Za4T6oeFD", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbJqEntoBFps7XECQkTDFzXNCdz9R2qmkB", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qc5sZS1Vb1ujj8qvL5uXV5y5yQPq6pw2GC", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QceNmCiZxxLdvL85huifVcnk64udcJ47Jr", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qd453ewoyESrEgUab6dTFe2pufWkD94Tsm", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QfjoMGib4trpZHzxUSMdmtiRnsrLNf74zp", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qgfh143pRJyxpS92JoazjXNMH1uZueQBZ2", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qi3N6fNRrs15EHmkxYyWHyh4z3Dp2rVU2i", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QicRwDhfk8M2CGNvpMEmYzQEjESvF7WrFY", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QLwMaXmDDUvh7aN5MdpY28rqTKE8U1Cepc", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QP3J3GHgjqP69neTAprpYe4co33eKQiQpS", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QPsjHoKhugEADrtSQP5xjFgsaQPn9WmE3Y", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRt11DVBnLaSDxr2KHvx92LdPrjhbhJtkj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRTygRGv8XxTeP34cgQqwfCeYBGu3bMCz1", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QSbHwxaBh5P7wXDurk2KCb8d1sCVN4JpMf", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTE6b4xF8ecQTdphXn2BrptPVgRWCkzMQC", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTKKxJXRWWqNNTgaMmvw22Jb3F5ttriSah", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QUxh6PNsKhwJ12qGaM3AC1xZjwxy4hk1RG", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QS7K9EsSDeCdb7T8kzZaFNGLomNmmET2Dr", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QLxHu4ZFEQek3eZ3ucWRwT6MHQnr1RTqV3", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qe3DW43uTQfeTbo4knfW5aUCwvFnyGzdVe", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNxfUZ9xgMYs3Gw6jG9Hqsg957yBJz2YEt", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQXSKG4qSYSdPqP4rFV7V3oA9ihzEgj4Wt", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QMH5Sm2yr3y81VKZuLDtP5UbmoxUtNW5p1", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRKAjXDQDv3dVFihag8DZhqffh3W3VPQvo", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXQYR1oJVR7oK5wzbXFHWgMjY6pDy2wAhB", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNyhH8dutdNhUaZqnkRu5mmR7ivmjhX118", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qj1bLXBtZP3NVcVcD1dpwvgbVD3i1x2TkU", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjNN6JLqzPGUuhw6GVpivLXaeGJEWB1VZV", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbgesZq44ZgkEfVWbCo3jiMfdy4qytdKwU", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QgyvE9afaS3P8ssqFhqJwuR1sjsxvazdw5", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRt2PKGpBDF8ZiUgELhBphn5YhwEwpqWME", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRZYD67yxnaTuFMdREjiSh3SkQPrFFdodS", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QieDZVeiPAyoUYyhGZUS8VPBF3cFiFDEPw", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QV3cEwL4NQ3ioc2Jzduu9B8tzJjCwPkzaj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNfkC17dPezMhDch7dEMhTgeBJQ1ckgXk8", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcdpBcZisrDzXK7FekRwphpjAvZaXzcAZr", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbTDMss7NtRxxQaSqBZtSLSNdSYgvGaqFf", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qaj7VFnofTx7mFWo4Yfo1nzRtX2k32USJq", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRchdiiPr3eyhurpwmVWnZecBBRp79pGJU", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QemRYQ3NzNNVJddKQGn3frfab79ZBw15rS", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QW7qQMDQwpT498YZVJE8o4QxHCsLzxrA5S", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QM2cyKX6gZqWhtVaVy4MKMD9SyjzzZ4h5w", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qfa8ioviZnN5K8dosMGuxp3SuV7QJyH23t", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QS9wFXVtBC4ad9cnenjMaXom6HAZRdb5bJ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QSRpUMfK1tcF6ySGCsjeTtYk16B9PrqpuH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qez3PAwBEjLDoer8V7b6JFd1CQZiVgqaBu", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QP5bhm92HCEeLwEV3T3ySSdkpTz1ERkSUL", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZDQGCCHgcSkRfgUqfG2LsPSLDLZ888THh", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QN3gqz7wfqaEsqz5bv4eVgw9vKGth1EjG3", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QeskJAik9pSeV3Ka4L58V7YWHJd1dBe455", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXm93Bs7hyciXxZMuCU9maMiY6371MCu1x", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWTZiST8EuP2ix9MgX19ZziKAhRK8C96pd", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcNpKq2SY7BqDXthSeRV7vikEEedpbPkgg", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QhX25kdPgTg5c2UrPNsbPryuj7bL8YF3hC", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qcx8Za7HK42vRP9b8woAo9escmcxZsqgfe", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjgsYfuqRzWjXFEagqAmaPSVxcXr5A4DmQ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXca8P4Z6cHF1YwNcmPToWWx363Dv9okqj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjQcgaPLxU7qBW6DP7UyhJhJbLoSFvGM2H", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjaJVb8V8Surt8G2Wu4yrKfjvoBXQGyDHX", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QgioyTpZKGADu6TBUYxsPVepxTG7VThXEK", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcmyM7fzGjM3X7VpHybbp4UzVVEcMVdLkR", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiqfL6z7yeFEJuDgbX4EbkLbCv7aZXafsp", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QM3amnq8GaXUXfDJWrzsHhAzSmioTP5HX4", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWu1vLngtTUMcPoRx5u16QXCSdsRqwRfuH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qi2taKC6qdm9NBSAaBAshiia8TXRWhxWyR", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZko7f8rnuUEp8zv7nrJyQfkeYaWfYMffH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcJfVM3dmpBMvDbsKVFsx32ahZ6MFH58Mq", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QVfdY59hk6gKUtYoqjCdG7MfnQFSw2WvnE", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qhkp6r56t9GL3bNgxvyKfMnfZo6eQqERBQ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjZ9v7AcchaJpNqJv5b7dC5Wjsi2JLSJeV", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWnd9iPWkCTh7UnWPDYhD9h8PXThW5RZgJ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QdKJo8SPLqtrvc1UgRok4fV9b1CrSgJiY7", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcHHkSKpnCmZydkDNxcFJL1aDQXPkniGNb", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QjaDRfCXWByCrxS9QkynuxDL2tvDiC6x74", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QS4tnqqR9aU7iCNmc2wYa5YMNbHvh8wmZR", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiwE9h1CCighEpR8Epzv6fxpjXtahTN6sn", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRub4MuhmYAmU8bSkSWSRVcYwwmcNwRLsy", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QLitmzEnWVexkwcXbUTaovJrRoDvRMzW32", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QUnKiReHwhg1CeQd2PdpXvU2FdtR9XDkZ4", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcSJuQNcGMrDhS6Jb2tRQEWLmUbvt5d7Gc", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQQFM1XuM8nSQSJKAq5t6KWdDPb6uPgiki", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWnoDUJwt6DRWygNQQSNciHFbN6uehuZhB", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZppLAZ4JJ3FgU1GXPdrbGDgXEajSk86bh", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNHocuE5hr64z1RHbfXUQKpHwUv3DG4on4", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QS5SMHzAyjicAkMdK7hnBkiGVmwwBey1kQ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QhauobwGUVNT8UkK41k2aJVcfMdkpDBwVb", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qh31pAfL5dk7jDcUKCpAurkZTTu27D9dGp", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QM1CCBbcTG2S6H1dBVJXTUHxhfasfTR6XF", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQ5zUwBwfGBru68FsaiawC5vjzigKYzwDs", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWmFjyqsHkXfXwUvixzXfFh8AX5mwhvD7b", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTJ8pBwaXUZ1C7rX4Mb9NWbprh88LeUsju", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QMLDPdpscAoTevAHpe3BQLuJdBggsawGLC", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QaboRcMGnxJgfZDkEpqUe8HXsxFY6JdnFw", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QVUTAqofenqSuGC9Mjw9tnEVzxVLfaF6PH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QVCDS2qjjKSytiSS2S6ZxLcNTnpBB9qEvS", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QfEtw43SfViaC2BEU7xRyR4cJqPdFuc547", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qf9EA2o8gMxbMH59JmYPm8buVasBCTrEco", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QddoeVG1N97ui2s9LhMpMCvScvPjf2DmhR", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QajjSZXwp33Zybm9zQ62DdMiYLCic4FHWH", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZVs7y4Ysb62NHetDEwH7nVvhSqbzF3TsF", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QP6eci8SRs7C6i1CTEBsc7BkLiMdJ7jrvL", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QgUkTPpwsdyes7KxgYzXXWJ1TnjUFViy9R", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QVVUs58P3UimAjoLG3pga2UtbnVhPHqzop", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QYVhnvxEQM3sNbkN5VDkRBuTY3ZEjGP2Y6", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qgfcck7VX4ki9m7Haer3WSt9a6sEW7DwKm", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qdwd54nUp5moiKVTQ7ESuzdLnwQ9L7oT37", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiPTyt2VgN7sJyK2rCfy24PQhoL1VwvAUs", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXNABfSfAFRDF2ZCca4tf1PyA3ARyLUEUK", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZJjUVgjoacvHmdjfqUDq3Dh6q3eTyNh2y", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QWHzcbXSrEg7AiVDLBhsR1zUBnWUneSkUp", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QLgjnrRRCkQt7g7pWQGAXg99ZxAC8abLGk", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QPmFGR56aQ586ot61Yt1LX79gdgBYGNeUN", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQb493uqAUrWe2YoNR8MmhhxjNYgcf3XS6", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QV3UDtxFyXCsKdmnVWstWQc1ZMSAPp1WNE", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QV527xbvZNT1529LsDBKn22cNP9YJ6i3HF", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QQAbKyRGv8RUytDyr1D6QzELzMvNmGnuhZ", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QP2xZTDDu6oVvAaRjTNW7fBEm9fcjmyjAF", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRH9E99H893PS8hFmzPGinAQgbMmoYxRKj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QRtqR9AqsaE4TKdH4tJPCwUgJtKXkrzumk", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QaEyGRLnR7o85PCRoCq2x4kmsj1ZuVM3eo", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QUZSHjxYNfa6nF8MSyiCm5JKbiRnBy6LZd", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QXUozAco8vrZgc3LZDok4ziQdUb1F2WNiv", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZF252FDKhrjdXUiXf16Kjju3q23aNfXWk", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qj1odhqTstQweB9NosXVzY6Lvzis24AQXP", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QTaiJKCnV9bfbEbfbuKnxzNU8QEnYgv4Xu", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QYLdKUKoKvBAFigiX2H7j1VcL8QaPny1XX", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QaEfP6nFkNrDuzUbcHWj9casn9ekRJCtrg", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcbQcC2BZP9AipqSDFThm3KWfycn9jweVj", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QfGLmDwWUHhpHFebwCfFibdXFcMZhZWepX", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QMkUwfBU1HKUius1HrEiphapMjDBsFrJEd", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qab7N4CYsATCmy8T3VTSnG8oK3Uw3GSe6x", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QdJirbcRUTZ4M6fBAmKGgsvC7DVpEqQLrt", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QSPVSpKZueM1V9xc8HD9Qfte5gFrFJ61Xv", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QcuAciBq8QjDS2EMDAMGi9asP8oaob7UFs", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QNwDgR34mYsw1t9hzyumm5j7siy8AMDjST", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qf5RGjWtSn8NSpYeLxKbamogxGST3iX3QY", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QYrytjgXZmWsGarsC3qAAVYdth8qpEjjni", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbHqojw2kSmcsdcVaRUAcWF2svr9VPh1Lf", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiTygNmENd8bjyiaK1WwgeX9EF1H8Kapfq", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QScfgds57u1zEPWyjL3GyKsZQ6PRbuVv2G", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QZm6GbBJuLJnTbftJAaJtw2ZJqXiDTu2pD", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "Qb5rprTsoSoKaMcdwLFL7jP3eyhK3LGqNm", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QbpZL12Lh7K2y6xPZure4pix5jH6ViVrF2", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QUZbH7hV8sdLSum74bdqyrLgvN5YTPHUW5", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QiNKXRfnX9mTodSed1yRQexhL1HA42RHHo", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QT4zHex8JEULmBhYmKd5UhpiNA46T5wUko", "andMask": -1, "orMask": 1, "xorMask": 0 }, + + { "type": "ACCOUNT_LEVEL", "target": "QXsrAcNz93naQsBcyGTECMiB3heKmbZZNT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QN4NnUvf4UwCKz9U66NUEs6cQJtZiHzpsB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRXzd5xi7nPdqZg5ugkoNnttAMEMAS7Zgp", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZmFAL7D719HQkV72MnvP2CEsnBUyktYEX", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QT7uWcs2dacGGfLzVDRXAWAY5nbgGjczSq", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYhu1Yvx4wEcMZPF7UhRNNfcHFqWKU9y8U", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QeEY7UgPBDeyQnnir53weJYtTvDZvfEPM4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQszFsHkwEf1cxmZkq2Sjd7MmkpKvud9Rc", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi8AKfUEZb6tFiua3D7NMPLGEd8ouyAp99", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYMortQDHVwAa44bfZhtoz8NALW3iE9bqm", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMptfhifsYG7LzV9woEmPKvaALLkFQdND4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QR48czk5GXWj8nUkhzHr1MmV9Xvn7xsyMJ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRmrBWDmcRz1c5q63oYKPsJvW5uVvXUrkt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QR24APnqsTaPCS5WFVEEZevk7oE1TZdTXy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPUgbXEj1TfgLQng6yHDMnV4RE4fkzxneP", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhZH9dcBwJXRHTMUeMnnaFBtzyNEmeEu95", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QeALW9oLFARexJSA5VEPAZR1hkUGRoYCpJ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgxx7Xr4Ta9RBkkc5BHqr6Yqvb38dsfUrT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QcqiXKsCnUst4qZdpooe4AuFZp6qLJbH1E", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQLd58skeFGRzW9JBYfeRNXBEF6BbxuRcL", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfBvQKMgWjix4oXPZrmU9zJDv8iCT4bAuv", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QamJduVxVwqkUugkeyVwcEqHSSmPNiNt4G", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QeYPPuzXey13V2nRZAS1zhBvsxD9Jww8br", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QiKu8wuB5rZ4ZvUGkdP4jTQWBdMZWQb4Ev", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhhhQhVeJ1GL3oMyG2ssTx7XLNhPSDhSTs", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPfi9t9CAPVHu3FGxRGvUb723vYFUYQEv6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWH9V5WBEvVkJnJPMXkULX9UaDwHGVoMi6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYWoBSTXCRmYQq1yJ3HHjYrxC4KUdVLpmw", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QftjmqLYfjS4jwBukVGbiDLxNE5Hv5SFkA", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMAJ2jt377iFtALB3UvuXgg21vx9i3ASe9", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QaP9FzoAQAXrvSYpiR9nQU6NewagTBZDuB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZpWpi8Lp7zPm63GxU9z2Xiwh6QmD4qfy2", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPNtFMjoMWwDngH94PAsizhhn3sPFhzDm6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTkdeWxc34v5w47SDJYC9QFz9t4DRZwBEy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSSpbcy65aoSpC3q5XwEjSKg15LG868eUe", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhcfCJ6nW4A6PztJ5NXQW2cUo67k2t4HHB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYqv8RVp57C9gaH8o1Fez3ofSW24RAfuju", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVgLvwFNNjHAUwE8h2PcfKRns1EebHDX4B", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSv5ZY5mW7aGbYA7gqkj4xyPq4AECd7EL8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QgyQ9HX5JRbdKxFTXgsoq2cnZD89NwxinT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTEpaAMni8SpKY8fd8AF7qXEtTode1LoaW", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QeKBjbwctfydGS6mLvDSm8dULcvLUaorwX", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhfG4EVSd8iZ8H1piRvdRC8MDJ3Jz1WcN9", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjXYs5HWfda3mgTBqveKatTWHnahv2oX22", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh1iJg1BEdoK4q4hjXcSkNE4qv9oYsHoF4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPBcSVqzpB3QhiwMkiq9rMHe7Mx5NynXnD", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUgVsyMPFxjiS2o5y81FoXoiWHiAwfbq94", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNH3ebZTv6GeWwjwhjhGg7doia6ZJjqQXG", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QXXeoduLPuhfURibgkfEfSSQ2Rom9SELtL", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QcKnXTjEaTBr91PQY7AkCxvChNpkqU6r1t", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhWNbSmPAoAg8bXirPeNyGVuoSk84rfnHu", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QdivX7dtJKosr83EmLTViz7PkFC4FQqeH4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUB6fPHDTrpYyU6wJmAqV6TUBZiWLrTPuz", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QiG69VVGp13oCiryF4vpDu3a2kEEHi7HDm", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QS4dJJhwCheoMB3Z8Mk8wNZFfSu4FkW9Vv", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb6peGujesgEH9aHd19NfKvR5vTmsb2oHM", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh5tovSQykjFNJGV1P7tGtfmfnJXQQNLr7", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QS1vPBzGLu8ZskZtapcYzUCr8pEjVxtFgu", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUJmfReuva7PmyzFBr7M35QuYZcAoeWPyT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QLfYVnUtR4RVcthhzYc7U76vmK6LkyUky4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfeNQecGDhdHSdoTDAKAaAdpmgGBfJjQw6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QiPHkz2YVDhsJPdkD7qxizFFEu7m3g3zA7", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSmmCGNkGbqwGGvdeBtkHBPa4pXXEG2vkf", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNjN51iZaZb3ZnfNiLdm1xtUZ4DKLj9X7e", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjFocrHNieQ8rDYifrZTWtYgejjih6mmS1", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSNDFgL3bfX7Pe9FaD7p1G1rtJe5v9aYsV", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWf8uFUXCahEXLV2cjJjunimCJdnvsN3JM", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSzVqpvkjfFAC6sJcyefyouP1zYZycvwpm", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QX3zQTmhnm89PrW1nfs6YJDfiAkegzpD1S", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg6kovZCzF2GKNyMoeJSaUArvzKJJH56L1", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhP2ND6q5Sptsy5pQUo18AuTgKMBfF4aPr", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QT3Cu76gET1ezemDVCojoP3SLMY4xNDH7k", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QdsqubwFQ1hChYwzpHvKAiLF9JMWWEwXhp", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QXE9M12CjPHBSFTS8DFUWjab4Z7F1JeRw1", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa5e8Pz4sM7RSAbwvM2N9m5NyYAgm2Fo3J", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjoNujTmVCDVoR5M99NMBrGwuJCVZUSWJ2", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMmVSM2dmfhRjGMCZaLeBGU7kXGGPeiRZn", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYh1Ht5c278CPs56khy4iH2YxXZrtdMGXo", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb3m52qr4jcsidw6DTPJUC62b51rM61VFj", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTC76DrGsCJuT4ybDiDTFaTxjXTPUJcpUi", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRqBqahzem4MpJarmGYh1jyaFHYxufssY3", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZpoY1W7MJvu5uJwdJRbKwWBhVhYPRAgag", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPfvtXRAWazxK8CrSRvDoCtRG6Hy3ujCx4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QgJ8Ud1qJHfdC6wyaUNcigUHJ65Udd2jYh", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRQtHawUKGY7g68yabnneKo88BFv35ddMD", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYFKaYFjRe8iYDbwUBTWjmPGosjcgBtC3Y", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc3HUdiKbHaaFK83p44WVicewmZip1TnAj", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYwkYWDsoJAWHPN1dHttMZ8QPABbriRMov", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVNwVTRnJNL7HYpHZ7wppApTv8H3FxvPXU", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QaxHEi7urRTZbGmcpyCcJr6zQZbDAnbfJt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPqG2UHH3ueqsjm2HMUuQj6GQW99VVXJry", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc5LxN2SQCQfJLVatuSMtmJtAihjapL3Qg", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QgMgsYiwyRiUYMHKCdB5tLJxuCroEbJnq8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QS5NyYUUPuPvkkvazYyYjTT9ef7eZU8of8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfNd6YADJq1M4SbwBxLKQ3AD7GEpTpAJi7", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZfGRwx8K1AyYwPUXHa9Tn16KP2h54iwfr", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNB1kaRHYBrmDRHepqxad5DYxQPbjVG4As", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTpjvRCrvWjXoBzSG379ZsEwW2F5xoLSiP", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZhnNK5FfX3FjTwwYwbewUQGE64Vts7qXP", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNFmGsWLr7Y4qngz1maq4ptzhcUAJdjDU1", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QR3hH2cxYz9MgDBq3vthEbdnFVMJvprzyV", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjjDaHSiAaPP8p3CRM3STeBc4VD9SCY4TP", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRppWy5shqf6TPZfh6CAfjPB25aLWPiNub", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgaszp8eniCvsFiVHaBNNDToaVVYjLdLeB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QbdHAJur3Vg9MYCPcgsz4dNW9gDGp1f727", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTxt6nMZmyZCJVLcsxZmwt4sUv1bFkLLRi", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVbpnTE83PfopgvXY9TD92aYWQrTgvGN3Q", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPdfyB2zwWt77X5iHeAKr8MTEHFMHE3Ww3", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRUgU6YptQd85VWiSvLUDRoyxnTBPGRHdx", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRLDb39eQWwiqttkoYxDB5f5Bu8Bt6tu8P", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUB67e2qPecWexgCB98gr3oHqMN2ZVay9j", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhK3xN3Ut6W1B5pg9MJdTLyHLAGLjcP7ma", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qai9X8cd9FdZufFH5rcKYodp6s4AQqH2XF", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QX2YaXwfrEDNzUAFWRc3D17hDaLAXw42NQ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMUMUzgWeXhUJsWxa7DWVaXDzJFrtpuPCn", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg8hVCdNiRy7Tqs2EHqLWtydqp1wzYc7Ny", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QXG8YWRehGa3aLTnnMupmBrXeXS93YuwmE", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qadt3251BYugMm2MjkmzCjrzGp2MfkJicH", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QaiosSXjrXXca8vLpNwKh8qijdh1rd23L3", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QP5tVLY8CQqQgzMuTPrxz2XpP2KDL9neNV", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjeebj5TZqG3y8yGwWT7oamPxEncaf5fC4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMM6TbkySGcRkxdpjnmeRcYgL1oC5JKR7X", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQAFHDRg2PyR6UMR87T2DkQfizMR5VhStM", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQcXsQRpHtPjECVp55Weu4ohoJK6pK81vu", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSrT7WTzjs6jnwZpDmcD6NvD2V3i4H5tq5", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWAagG61SiQvfSbWS4vQnvmJbyCJ7GSXiy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQP64bevncP8kZ9bxVP5Brp8moK1rsPsBk", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUuGEuWwQyjMgtxzAhcvmsQhE8VzsA3vjt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa7iysVRdxo3KzYSi6JAqAYf4NFfFDjWLj", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QcQdJHRGgvL3AoR9LSRSjVNdczukw7PKQe", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QedHYrn1QkrRZBkRu5kkajgqh5bcD8xZkt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUChBcWdxZX1VFHGwrUjRJbqbXjRdPNyki", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNW6zHWRyzaMPbb6JbKciobqbxtuQSZgw4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPFA5p13WYzzhpvHCGDoHtiA2oKAxPeKhU", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QS2ekPtGMR2obKdFKqFAcJQ3rbZmrzBSRz", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSpVwNfiKEh3NiBXduS8TnJXwgyHYmfFqH", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYm6g3WqAKnhotVwSLjqzorpVhzn2LgctL", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSfifb4e9W8C1K2uaAcwvjzqN33fmMcVwR", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYrLivTTHat8xFeJKkzrJXSyHeWkuBhWVA", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTxcZ3kMi7msQCkViFwWLdhkShhNNVa5Wv", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QNS4HmJen6qDVqSAYszeHKfaf1j1662tj6", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPBri2D8WYjxVZYd2oKgwvXg94FKweytBQ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QbeujwVbYFLx5uQBmkYs1a6cZRAopeB4cD", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWQUSSqBRQBiNnDu3ZGNGTXJyAfbLf5MxK", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPt5bnE51SzA6VES5kpdvpNHiFeHHMKWc8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdu3N57EXxaZ8TXRfHbEa8QuqbYW2sot1t", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QU29ppZiJ9Vzw4tQBrXdPJZToWhpu9Dp9Q", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZbrkBLFcmRUA21u5QsrPBpzrDH2wXpK7V", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QP544WzvAVh72cCVGr2WKFMzpicaH1wqAY", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QgKUwvnhj8tHWbNb59s9nkHQdapgWNcgAy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSCminTT9z7qmx3zEvGZ221B5rVNvjBsK4", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QaSrZL9TyKNUMfge6YiDatURrT2QHxNX1R", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYg2fLR5jXjStMhzUSq7QJ5uEbTrvRXRYt", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUWZa7s85qeLC6uWKTsMXnJ4BQbMiBddZB", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QbUSdFauEMKMHq2kAfX7BaLknVME6FpJhj", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QRXMXw7CT1NahXwj19t8wHHAuUFAMYm6NK", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QWr5TR1trHvVh1JzQbRARKqjJaMiywYzgr", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSjVVpSLeaaFcV1XacFJUXpBoBB3paFVPY", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQaDSZPWWFcFPGj38g63aP2gngvcgJnmsa", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QcKk4AGz4FwYA56C7wAZW9Ep5Fimf4c1Mo", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQJnvY3h86m56EGfWKzaVZnFthNDAUdYFo", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QSY6Ps3vxs1XEyFugvAWnv8a7sd1WuZkA8", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QjGbYagnZyc38Sm2M7gbg7wNX4Tfp6kTSs", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QikfKyFmSWN12cMHVzEurCrfS4KEywessZ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QZRnbiNgLsGjd4pCrWntwSaGU3Ex4sZfLE", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QTyUpPTd4n3Qk9k6k6ifKnB79XHueE4M4X", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QftHwRjwREQ3goEzehhF59rZUtrqrBGH7P", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhy15ZCfvjcDiQt97YcipgwK3paNQWfSAT", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfhGGYFr8ANfCg32VcvULCqcofUybRbHYJ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QMZqtWiJjH1JUqy7roNi95ByGvzFThxDXy", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QR17PHMYpHsfhQ8NXPVSVzXG3puMn99YfU", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVJKdAoPLfnShJFk1cxcu8h7z1SvPTaVyg", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QPByWaTGBToyDNhGMMBgGGRtLPD9V4h5Vv", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QVNWqbd7ERjn9dcqBGwmUcseoiwQCehey7", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QUQiDpv4PzjHLz8bYk8FJBnzrmjKYc6bsr", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QXtNoe9v7bsfW6w8uJweXpo4JESHoxWium", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QfPfxADaYrQUrKySf6tJBtMHA8cNG7VtNe", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QQjtX9bro4bRkS1B3FyfAihyk3vZkQm8hZ", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "Qidvt5WQVMqgcchxwGdCd2jp4cCdGioA4H", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QhfLqEaDKmbynhKYK95BQJtseH3cqEEURD", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QYyyAFBUXB9F91KwHCQNuFDGuw7L38fi4x", "level": 1 }, + { "type": "ACCOUNT_LEVEL", "target": "QbMdmYjG4d71FUjk6L7pEoszoC9EQH1zUN", "level": 1 }, + + { "type": "ACCOUNT_LEVEL", "target": "QWxFeuRWE5GZXNfZ2tYqW3GmAC3FAz5Qrc", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZbG62vQnBrtYJ2VwuJSzfA8NXMj36FYbb", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QLoV9cxAUkPn2DaQKnqDVJq6jMN3k21JAM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QcsBjck2WTR7J3PmQ9RXHxsPewPkbxzCtp", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj29EdPyW7MhZ15XDgvGZwXrmsP84KM5ff", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUTg3JNn6JGtHy25XTgdNu5APzp5cAg79v", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QU1C597JwXXBbR2ysX4fKGr9DTqbn1bPxE", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgboEdXscGVZ3pFyUq7x9ufaRmDseeb4dC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNWtJX7SDYBQxsEqmjsLbhVQoAYV3QkynD", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QY1KfMNNtBe1q6JxGzGimxM3vpCoqzQCNX", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYp7DknXc9PbdF52vTozrh1ZEfM7wZBhFG", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfshJREL1rFXcBDYTZQcj8mGLpQh3ZWC6t", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNLBihtJXLo3HVjzLGgdbgbHacTgMt3USC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZkKsgF78HsiDef87g7dGLGKsoTSH2ekWT", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWv4gyJ4N1WxCLvAmWKLtx5mmBYAqXHTXD", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSp5oQ65SWNbfampnxzgBuEymJLVkarPBV", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QLddFbuRfbkrMQnpHA3gvBtYERfqwRdJsC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMHWrDejEvBVuzQyUhnVqnSaKKMHyCosyF", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVwFkDM51dcvCfmvYUBjjQg87JteNis7f6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTvu4zok2UGnB45s1Luj6v3AzMRUEP1zmd", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYh6BPhuScCt9ENbnAcp16mCZLsYnukMWY", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYH3WNEknRKSFViWuZzmN43q8wkAGpzKXu", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZwweWZAURCtoLM8K1ouA7McNyHNjyDcBi", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ5RBZkiqGhvCnQvCPPZar8RqhtwDonDBi", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWpt9ZPYks3PE8nHLyKkoLogD3doMumrK6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdds3wCmA2P4kkMXHJCi1JuQVMLJayskQu", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYkkDoVQCHZQM3KJQC1J8qFVZmXi3T7JZe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QM44Ks8EALor7MNhQGHpUpqu484VeUYRAL", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeBeZzP6xxSk1hem3tRzchzAAMgRKb3fkg", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QThNX1VbEGbAE31sjZKZYBBg4CNX5JkbRr", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQiz1NcVPECxicoDXQ1p6h5yU6KozLYFhj", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QP5h7AugR5sY2U9YLHjmTTkuoZFWoomar1", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QY69G6HF2SCnqEPJwwHrnBrXn6UwccfSGa", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QhPFgRDmwGdjexK1nEA2r4caPXG4SRVXCD", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQrjQEssGwc6ixp9N76b42By1sFbEKDTDZ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgnzw5Drcj1LvipRbcCPS9rG1PSyXF91nn", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeLgbgD74BgbBpoPE2jJuNQN5GqyBYNRev", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVPE6V1xpZpVz2Zhu3SNkKf7TgWPAqRo4x", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QY7NTMeAq2Wt9BZYf4BCwj3eJG5aMYADRu", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVj9GHBnU1T9yseTTR3j4PST8aaLGNPpm9", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVVvANL8ML3RaMbF34aoxL3z1bSoznTSC5", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfxrJXCBbnvGqCSztwDzrNzDaBYQK8Lejr", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZKQViyTqY2D9zQN1k6pwmKaKE1ooaf4UZ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSv8WNg5HwfU68NcGyMEJ3G9pQLGVpHwFk", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QR6QPuyzBtPFB66SheLhiUp8sqgyvrXoVs", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNPSKjSd8BdKV9y8w3CuU8str4t6AtS2aA", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QT2GRgCRBJTBCWVsoxax2kNFi4eGq8DZ7Q", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfNphDAZBZPDmtakni5PThJxdbi3xufDr2", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZoLPzXLePhs7VcLMGRZ9qJxCb9rzqCJmK", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiDq33pUvSHi2pEZ3cGEPVtiw1i6FzV9ai", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUNGLbMWTQELBUQN4XUkNtZQehvyZaDmAC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZtiZnjjATzg8dEoAikrbQfdjhgGcCTxsT", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgAt6xyNojsoDpJvcsUPkdmpz5TDp7gZh3", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYwUGeqXJZDrtMh6QyUT4SubuPqm3nXkYe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYdqgA8uYhcec88NxVr7wg3WReUQqGVHzn", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QhpbiBSUcTUu2Ex5pTyTS3SodSyfKmtzyx", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiTpmEJEstonzSsvuCvkmBQpf7jaNuAuq8", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSTUkD8xB9rkYNzAhZFdSAxan5Y5KqirtW", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QS5q7B665QkuJtJzNnSnPuHTeDxqAPFJzk", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfzpWw6tMMWgX76cMZvorPRLPnpxmr1j2X", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWQdAWwYPMFCRCAc2bDqjJoRx4crZVn1Vh", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QScrEuDdqGHfixHcjyHFkbg5LdeyGexbkS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSfDW3KC9P5KQxBRYf4gjJUXSf1DZQwufm", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZLFJLReUT98wGdaieoA8iLSY6e9pDtkuh", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRgC1RbtDyvka7UH6RTqSNvJD8vTNkdsNv", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfCfuAxSbNeHbF8Y2GNuFmJfmexqVH131K", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQDfTzNLz8NwmPJ1PTiL7zAtWdz7o3LQX7", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QdmfM8nzDfi6U22ze6kaEceED2sb2yYW4x", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYMpwvQHyny3zKM68SKFUPssSkoNwC5vZt", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QPbsQYN1rpJwV1GbPNJBUkCyx2YWPuLJZd", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSofobEjrtD2KntRYg5PLdFDdGuf3mdAyc", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QbShL174ecJPLU8nRSjtMwbrudCjzPRqFe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgMCRwAr5JoZvth1ESUo5n3Z9ycrfhCofo", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMyhFtK7iNHUe98nzEXdkN6toAa2RttST5", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh7LXNX79eJoFSUtdppQtAt7Si1R1wbaJX", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWvVuQKy9165r1osQM98eUnAhfe2HiFEmN", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QbUzPNzbB1McDTWBDJdhpFsVUQhi1hP9NQ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QggkvYpWRuqPjcMLLGG1R9ZXJAoE83xj2U", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QXS1p29dEQV1JtHj1Mv55SEWfDuHe47AX6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUfTY7fh8we4nYPVAXL2jsXSm3hRLGL3uc", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc6HfpXNWjeWQ1JsXRZScit9neymb3tsBV", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe3LcdQpecf8jMiYdMcs9pG7yQiaL4v3dK", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiJC1E8sA1RVTuRXBFgqzY2zmfJ2eXMgtv", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeFinpTR23Ryh8Xh2qeX9kHnezQniEx2JT", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QevoCTEHo3PWAKMKgwjv2ziYdeWDJLXsUk", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNsPF3iZ6RExncd7NCWHzAuofRD56nhP1J", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQuFcmg7wsdHpEZjTXpbJAEmCxJaZpScfi", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QcgryWoPDdmNbJ7XXnFbhmXVpNopio26VQ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QjuVB3x6CcH8k6aoQfckdTHP2thnEkeeLM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe11ExJhNtsH55zAwEuE7RuHBdWhKNHVX6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb9d7XrcJEB94Lthk1mzTfm7gMt7XjVCPr", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfA2r9SJogxx5h4Do1rMSEQuJCkeMhL37n", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUVbFEYn4SUz5eAdum1NHL9i3CvBkvdcpM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRDKcbobLECBD7yKCfzcaBAHM3DScRpccL", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSVTLdnvnJiF9r5P9aEYFobjj9Urv48iyJ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhb9upVqQLzJfWGzALSVAZNwk7nnkGqctC", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QPMs22gnWYuCxeq133aQ8hezvfo2ukZjBV", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYfxnLX6sxv2nKaemdR3UG7AFMfwSpWktA", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeSb4PYhWYzrfvDF47EL8fEQ2tj89hszet", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiGDtjHbYSvCutyDP4FwB65AaMys26bgk6", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMefivZuDRcohdW6fKbMUYozLpG3Q5Q6LM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMGfUDRXUU2ZFaZDkFgRvCADqf572WvXEU", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTyeCwFefj24wSMwipWdcDNZonbCmUEExb", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSfYaHUJcrSFy6DUF4TTdhdxw48A9mRE6m", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTXbWbc63NFBBU6uT3f95htmVE5tamM5GN", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QW5yn7VbKkRLm5Aaowv3aKCja8VqqiGyCX", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWPigqprwrci7LCZjuoXWkVnd195gQyBYS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa6pGoGsm6zEYLaBjV85Nhd6p7aMbCUy9A", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNNrN2VtgfdGKSQJq3Z8AXuA9iMPddif3H", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QW9HHiZhURbqJVpjuwraujZPoDCsMPdjYS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRob3sEQHX7PNW9tJEd2iaXc2LuT8MFPhe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgUCStZkxC1b8AbTSDcEMTNj2txDKedN9z", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRJceDy9e5NEGKPZ3aEsKfQfpP5e97hvkE", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZc9DBWrEADDnrnTV2DzGJvMJydgteH2YS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QbgQ7mZH6JqNXno8rL89LqMoTsE7N3QKQa", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ1TnT3hF7MHhSCWLSJ8TeZXFMDZD4FY7b", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYCyEsBMT6o53RTuFtmPUTJYDFsCEQbxAZ", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeYkt8Kc9zXS5s1FHGkW8iqZowABUJhgEd", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSXhmKQBB33AoZvw3K8bzQeomcpDTSV8be", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVDMMeYvQxmHeTe8Nw2of4Z6AUm86Eyn3X", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMrMeYPa1FPzQbH7F4hpsAxXGMi1cqhVwU", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QP3Vfwt5qAUW4JxBtCRbyY3qAYraLrJFcN", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc1pJ7rYLbUhTZXvdSvnD6JiKXrHHMSGa7", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNU2RHKDRs5MVueLfZ5DyZQz2V89v197Cw", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QT5rNcBcKR6uHxXkwntscbxHuUpSqAkJ2Q", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTrJVPcMcisEfBBPqEiwy4UXHdQWWG59yo", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMqVRK51WYgwCAXXHsVBw7zWom8LngFt5w", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfge3zy6Q1FeqKQfBB1ALqFqZfgZyWJ2Mz", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTirUpjh93fmAjZa4Ax8PxwuTxAj5uWgug", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QSwkodPybgHBaerTABByNBRnBeWT7oxUgD", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QaLM4cLhjYtex3JUXPzevefKhruWhL2AFU", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUSryCrEDXRwv5iKZPDdhufa9WSP7NRr2J", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QbzqqkeRZtFDp1UCtsXByvkpWTVtShP8nn", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfcpK6LtUNCdTjxkh3b2JLU5HWGim9utF3", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiWFSfVYCBdTJLDNDnZSHwqKf7Wrymw4y1", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfgvJceEk3UMkQeFc5h7n2V2zhNuanGqC3", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc2gx4tsFiJSea3jYUfrGyQJWkpZfZ3FfX", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QjogZjZwQHrXeDsguP1AMW8o6ehcYNX1h1", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ8i9kKbWni9L1ZQf37vjfL9wdRqQYMjt4", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRyMhM2WPk2yg8GDRCHzGZzgqK6a3QXUtA", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QRLvMoLehvw9gK7w4HW6nUn7EGk1F83Ekv", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVqvXtRsofKyXjwXieiqEpwRrN3cykue2z", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjr86JYPa2ge6eRxvCbuorhQ7Qvf3T7fve", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QfdrQRjpYvMo5FgctABoBZA1accY9GpnGo", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMJnn9QY3ZwuGesxrwjQu5CdoirQ634HmM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd5aSucZtsGUpkk1A4nk6VHKHLN7SQ6bsM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNmLqbetUxdMgzvMBr5fFgVuxrMuKvdRca", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QTNphesPV41FeTqzBpR7qQgz1k6WjVLkfq", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMkq9TSQbn6Tbf1dyUMmuZE7Dgk9EKi638", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeBSM5kEQdcVfA5xB2wyWX7sJiHhm1eQxj", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUUFvC2LtMGMoDoQmBjG1fVGhfauFQcg3x", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QWmctuu3wzZ1ySvPANMRHtcR2WqzGDiuLM", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QMVtRvd3r3hRLSy1xsj8q53kE1PfqyJqJ8", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVeC4LsXkUvd67okfGXdYXHsaq91TEMzda", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQXPGZnC3BPZ5ApnQvfTZfXYaXsZZNzzxV", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa9SdiUgkGU8xxCLYF9W6D4XtWpagRk2p4", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QVsJzxKLR3StSb55GQEBKRLDUhWQvdu4mW", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QgoqYLgYjAg9Sovw3UrwNZr1uYLbdZBKjo", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QN8LhGeJiDjidBNUwrRjyXrZW282RDin9J", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QhR3ygFfHKr4MyUj2b5bBkowgCND8RqMJ9", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QiQwmW6cybhHYSrDfM2DYyJeCQJMJ7dzG9", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg3sfP7bu2StVvDxELCZyEFMcCZ19pwSnp", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QXzGnAFwuwN3uqztJ1ARPk8AkSCRKWddrY", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QeLPwH4xD5CRx5wMJ3zU52P1yPw35GL95v", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QdgbvSACGz5uWTjMBcC5MBMRi6gAU4xBg7", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QQgyrjSUxb1gGoG6qiteuuqfRTPVQxHw4q", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbc1f6SL5AdKmg2xxTcuswEe7FP8Kv241g", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZMqoWSLEtTz3rDAiuPigkgpdwbGqFeA4Q", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QXkrrTfHCdqhHodX5ZYmR4pZ99bykeFqKe", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QjFKeSTEdusbqF2S4xFQURKsHMy6m6QjbK", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QY9U7czTSvqgi77fRhuuwmVrWBZYxCqzQ2", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QXAyhHovPqEDmdUgRtjnrC6UZMVWE9P9qS", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QUJxJyYzxgukWBNc4Aghs67DaWoN5UFFn5", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QdpYgDsun4TwoNjz1ZDsyed7GEGXchNw8f", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZeYrnArdbNUW8bgLxaJuWRyXRmrueor9a", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYYocaRuwxoZzv1JWr8egZkGZVgNAkd7o5", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QNT3JwAF2cQ3CUCfX52x4WFGgksH4731wA", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QLsdr3KVacCYGuufGkyNerzHgCyNS9EBiw", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj3mCSgWqMECNaSWUDnXbz3a6sQ5SRdXb9", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QYjxtRJXiDHRaP3urEd8MX5nUV9fbgb8Gq", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QjgNuzEGvBEotvHo7xynD3h31mntp7PnSs", "level": 2 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ6Ur9DWGZVzzppkWcZupGAbU6jND8mN2A", "level": 2 }, + + { "type": "ACCOUNT_LEVEL", "target": "QZsDt43LLsYoif7KSHmyUXcUxhWgQfz51E", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qezvnvta62kW8ZNdiio3h3Eded7sDG89ao", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QcBuTqxNmsg3QotEnW8ZCf1EyWHwqBc3w5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QT94EE7rzSgazh15xpzhjhuqKFE88cHHgY", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QcBqZ4ozxs6JPcvCT3beYzki5Na8pwiEPt", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QZo9xY1NqYwr8XxoiNBVHicHsQDRPDvanM", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QU6DVbLkztW8oS1Q17j8QEcxisSbxnTZzf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QgqdeTtYTKnLAoCH5x3mh8EL4bRixSAoB5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhyRtUUohmkbDzSjZw422cLeXBUBK1Rygw", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QecZdcfkyFbKqTXGn8i5s1iG7Rfz6mAtAS", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QeVSYP9juB5gfwL9QMz3NgYgNj1FLJ9u2x", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QXVoRnk8DKFU6AjqPAcx3RwDnzDnknxwf5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qcc3iyh3ektfySjbxgJbQ2g457k7KdF2hH", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbLxPwNiMmdaRPYywjuMeu98RDAYaZPXQp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi2DAZBWbia4KeE52Qt1PVvzuSEAHQAmyh", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QgFyAZ2mUp1879ZNpKb8zHFCsYDnHhVCmR", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QawWJdQGTNHk9VQUwF617GRCBpk2zL3Q7m", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiYaG6TMPjtqQwFz1KeWp2ZX86JCJtaDcp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNvmjT2ZpBSL66SqSEUPPmPK7pddcxauub", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMJshnWZZsr7NRTuJuwHY24UKMHkGorRqU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgu8a7dGNaMLudiF7LAKGA33BSzEa3Jdwm", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbrtqmUoEDdLiwnCWtvNwXaccaSpCKo8uS", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQPwgGCF3Bp28VBiDWFku42wDYpf1sMxQe", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd2iY8utUL8wcshE5MCfBR9SVBmKbyHU4F", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QamEfYzNmdo1BEzSbfQSqqSrHbA9AJCaeW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QcQ6gNFN3b8uHEhCuG9sSgk9LeXjaHKF8f", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNtqkq5KtkKKF1jYQ3GaNFHALANh1gZ1Qt", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaWzzJ5XGtKefyCvZ4wCMW56JnJpL8XWYs", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfeFfrbAL1pxC5jZSUum1BYnbToo4u5EhW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaRd3tTjcroAPYXvYR8zmojcXPHL9DZxd5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qeq1BV4i6gN69DmQ9AgkaPmizo17YuGKA6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhcc4R9wJ6mbxB8jCgA7gxsonqGaex7hq1", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfSa6ivpmWjcTZKw5Mz7sLKX4S6NgPFrFU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe3LntnKWfJLkkVcJRqkRqSzqjcJZLrCoa", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QS5P7zuFKFineYRY4Wej2USv2A38GVDbZv", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbB1tCEKriy5wRnEVetWZmByjYLUyFkg4g", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QXBXg6c1jNYZ9PeAKGLsBiuMY9MVyYVNgz", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfp2xKR2hiWS29oy8GYJgRANCQyHsSzXMf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj8yVKdxvUxBe4E9TvvKcjZ2UxUpa68ZP1", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QZTGHpZ5cyqGBBpiHMTPSGngmqgmh5LB2b", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QR5SGcLtFAxk6mAQZiAMMRUyZLovDaQnQf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QcTPM3qZFXsArex2Tcjq8KzJmZeTL6LG6A", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi5o9RHLN8menSyT9ATAv3A8ge3vu94KGM", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNNRCNBotwc4Z4dyYTwhdCz28EBPHUqgng", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ5xduv5rwt4f54jicU5KB6TkNZQJZDgRp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQrvmCEHwQR5dzvLTxy8edXBzHJ9Uwde1W", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbi3FU8dMLEZHJT7DdZWu5rpXnWT2GTGF9", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi5PGoa9H5zBmfva62SgbyJ5bo2qYo2uKG", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUpM5bugMgiZ4AqDiT4aiy6mLJQ7Y9GeRU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQJJE79CuahqQHSJ4xVVcxANHfE1YHMUoi", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaddFd123JhgyyZo4SqDzRxkD4v7wyfDxu", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbuBHgF86E1WHKtiGswiGpWZxtFRg7L7z4", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QdDqQ6rBJLrW1DhPuQnw2Nh2pLbHoXB38k", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNtb3aiA92Gd9egNvhK7a7uwZY1tDHVSCy", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPV8fxpCPPqv972Pn77hR735rQ1h6dzAue", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb2ampydMe4iTvTfh7jtuUbcAuH1xJUpHm", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWEGkXDJvwyjHppad4JVvCa6jvttn7aPJN", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPHTv62t8XcdkRjnzU6qmN3yqi95o4F4An", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QY5NwDSwvBFNhu7M2WxUDvyvDPmExQXryz", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiA6aEE1mq9PPkNTAU55crqkuHycdS1Kf3", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QeabFZdH5srqgfjN9rACGbqkSLdnPHc5Ym", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhKHv48KUL4spnjx8JppAdraah368VHa3D", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QRC7iB3Ce2vwSfFexT2gipP5VfFBkzYG3K", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaHF7gJzo1i4yqFqp85QxoQ7WGRuzhm9kL", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNxE3CV5AMfqgpKUrLWPYkVjWeJj8FGvZL", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbBvBr2gheZkKiR1nJNfzhA17rnFpPeiXr", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUe1jYckxbSTYddnQDqa93xJh1Q13pbgwi", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaKWS4aJHWPee1mGLK4NKfYsHoLym4qcT3", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWU1HEMTbvMKMgjVmRN91ooaAi2TX45XzQ", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQxVZ98CxWA79KWer8tBtgbbZ5vbdRfTuu", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNDHBHKpVz4Lr3EBDkSJ4ZoiSxG34VjTMH", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QY4E9pEXcEFH3Eh8KL4vuXZZEQMsCRjJLw", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QSdyWsbqYkFupwWdxt9AbiQoP4cq9ymPgX", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhR7nJGFMV9bhj34Ldb9SYiTLMiJWnA2N2", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVCFSYhWMLTCmPj6mDnLq8JQ9fDTaPDCDY", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVp2aMAFAjcFQe7Mev2XrxsTCYUcbGfsZx", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTred1oVKR9QSeuzZ6BudnkK4EUsojwHsb", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QS51FHhmDJHrJ5jxDVTPXbxe27hoU3aJ7k", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QerB77uXd93h64KuMXT1TGuDinYGyBz7Vp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUBx7ioCLLbFuMdYCtxmxLpiG4EoBCtxDF", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QYexbcwSivr8tvr8K7P5vkWV6wU2Up211G", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVY6jD33ykTCVLjwaL3bnuUupzSVKnLVyc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTDSpCk1BwrfUhFrnJb5jo4u5ce9mYrQtq", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWKFaTrMDsBrWB2fbD2GZ5j2y8mt9ofmqN", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj4Y13T7YnRRnZoDEQcSvPHgDz6dHPFzUH", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMr2cySkP9ACj9T3pzhSZkPsCeasiLTuuF", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPocRpr4MzdpHRfjXDdp1PAbjDVBKMCEax", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QP48VJk4UK3XSafgV6b3dLmsJfnDvNX5pD", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QS8SYFNeDzyiNRL4tJBLQauGMBXkATgFHE", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QdfAyJ2fGxnzmyXR3J5ekG1LbjD2nhUJZf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qah57SitxbUZDeAiCFj26k4hvNFjX5cQSJ", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfRZsM88kdbi8a26SmrZdusR4pVTCLCHmd", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QgtS5U8K89Ax2mmc2JKCWBHQNVZ7tLwCJn", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVgD467m4gCe8y25X14xsMchFvzbeNMay3", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWScc4dvcbgPmAQSAUZsfpqCRPE3nivGsU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUBhMg3yy4FtiW6h5136CfQqqHDxr3SUtg", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj54QTsNtZ2HtTt7tPaKMVZdSQtfdNbrGL", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVX7DzY5oCJydHWdmEuZMpuAFLpVYwmHzK", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QLi4GUiww4bKQH6ouEFFEmyHMXgPDvtko1", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiEUfeoo8eAKUgFad1qsMziJWw6ZenUxMd", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMGeYbe4aXs6CTnstGib3zZd7k6UvTvZsr", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWeYhaXNW94WAY1YPm83pXaZfak46AWaKe", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QZMUskZQiycMLrcCmRAE1xDDLCyTCAVZrf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfVRtTXq5ft8L8CA6XpUKYK7v1Zea8WJvi", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QeWSpRvWQ5fW4Deac9fhy2KogSYJzrFyKf", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTnFhyTywaTZcxQsHuKYXoT4x5DMJ6zM7u", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QepQfSL7yZQAKFxsbpnqxiWc12FnrC7jtv", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNSGkxLdJwqztSbHRP1a9FV1o48YkYAgGy", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgnt93E7MQRUmisXR8anK81D9SdmCxBVob", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbfLajkHMLZxNTcK2p5B5AKJhVbWSYohog", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qce4cfhZcTbV6FyAfGfzwpP58qpeDF1Cci", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ8wsBG98Q7HxCwvCUUVfdXo9CX3PcEpTX", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa6dNeXGkMdooTd8SxFicZYxbxPGCwLx8s", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMU5izcUpRNk8CRzy7VL6CuP1DS4XYnNeP", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QibWhLgP23xahRe4cDQ8JmdSavEA2RAbH9", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWn5gL9aBWNArVF4e4MRgP8YkUKee39W2y", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfgiHy7s2jPJFe6zvHQTcZWwV8ojLyKvrs", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQvXhppxwTDQrhs58Gb51BM3aUrFevPH5j", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbNB37Qtoh2i8Pj6MtzGANVUZerzG3Zb2N", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QabS4XAyJpXzPHZyiuUhurnpuHZpACNuny", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMkuFWrxs2Rhwt9KuhVghX3CAhcSXTmN7W", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QSrPzymXJwqDbDpmEi14pRjtrrdehZpGyc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QexNnLLCdxJjci43j1FytfzoaDD5RmvoXE", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QMf8KFSxAsyTdGrNQnFdXQkE2fcQrrVWQ2", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVfMdKX45x5FdnRTdKAURPCkymYJRyJgRX", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdo67QsSrDhf4oL8D5jC2efGgUknAKrEWK", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qcb5niZq3fapBq6YHcSFmpPdK7zKAyVqMo", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QSwnihvcSfvePUZJUKZPuTr2WQb5BiyW1D", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QgjEEYEAPWSwS4jEVZcYdJSvLfwoywCyvZ", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbyC9ue1BEDbSafF4u9EuhuBvpMvm8rTAq", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUKzxTDD9AzthoukedkqSYDEHRTFjRFnfm", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhTn6DXvAatHbqcz32NJ6Am8nyNDc2ZMQM", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTZHY3vX6aLVGWe8A9QjW3uHMGhd1pMAPa", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUmfeSj9Ae9NsESzHKsgyF5i7sw3riWbCJ", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QTc149rVuzoJ2kLLDi6TLQ5QQfU45B4VFk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfX1sfG3Z1ix5mm2mdVDkEr7fTnq5HYRCW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfMeaYEra3ZP4576eWgBYwyHX9gbRcHE8x", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg65672zycKy7Tb5SZYxXPNBvh3vPwdKdy", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVF8uodXcVdnvU7DbRg4FJBR6dfYNK1vSa", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiA92XRA1Sf28iAsrQvZNsYTDJpUAsyZCc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfEuD1CtcefSu7jMYpwDhZHupBwmhaCTsz", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QacH68BmZyMkB8dufjzTjGWMYkvUHUwFut", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb1KRviQaL1j93c7CWb36KS6pvfQdUSLzk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbUuHMMxwbbnRZZCNRTcSK4gZ5fSha55Ed", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QdGQppMtF7LKj5uNBCQU5LQzvwiwXeP9Uy", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVt2cpiE8HLsofz1iEyFcrJg9g7MbGQZTk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPc8yZZztKDmF8SCKBKHcMVEXqyWypmaoU", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbjPhMwdXdkFY1sPrE7jMWeWBcSRTZweN6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgc3Q1ZRWc1LKX51GqPtaYzXyLn6SyoobB", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QS3AQ7DD1RZ6M81XcGdrKibhNgvKFnNwjY", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QdGfF78c8kwmGFD7DWhtZMGvxG35nT7tZW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QVdh3tLLkAZdYKPAMz4CGaSqp7RvRmE1wc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QRqjcSqiGWADnD8Z6cF2949PYWwRAsdWd5", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiVfxu5mgUkfiUjdwxvnxBJEsZuUaL2nM6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQR6WaNVF8y72Extb6Ndb6bqEDabCUiXs3", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhim4KT9VkcxbE6a61ECZ4nq5dHUtb9okx", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh5GLNHyNt9Zx4umjoBkbsaPViJ8xKiVDi", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QV1FEkzd4nsDZPddG3sWBdxWCELMkZ6HFk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfrwyMsGvF9Vo6SMyPyKSuveEFikx5fgvc", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ2rRvcqCr6nj5kkxwBDT9ZTfN2akMGv1z", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QepQTxuer8cnS69aYf7EDAoWQw6GMPLibW", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbdB5TN8P2mDRrBi7kXWu6U8vNkMyh7RJ6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QjEsV1pxjcHPNPV8m3oCC163w6t9PZZF6p", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qc7yMoMRrA2fXmQ77JuxSfVdXyfPdcnNwp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QUbAAYiv8P1oACxGDp4jGWD66t7siiqTtp", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQsYuc5XBWaUsoR2QAVs4AVKBmY9FCSrQ4", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QXo7EkKEDCE1SeReojKyqVUFVQ1sriN1WH", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QaAo87qyhKXU26y1YR1FTvrLHv2uav8KsE", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QNJT34EthEvwgonu2vUVHNmosGRRxZhSHh", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QhTQDuBcjfnhqHu8mfRJAYn6VyFj7YjrHP", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QQpoQWccsb6UVWnstdZzyMZZjBuWLxSgaV", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QWQ6MUMNQKiJFnF2iKsFakeVvH4TBogxki", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QRXZA3wdXpu8phg8KJFe7RNQhs8D2P3DLq", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd5WLbqdcUcbi5ZyY1rsDTBpBG7X6YAS9r", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QYAk7XFu2bG5cKTVApjey95YRtie4Ed13N", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QbPqWcpXcNGuGhYZ2hvLNQ6XhyfudvCbi6", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "Qajx9YjYHukNF2fxq2UbGniGdpQL6jzy5t", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QiPKz6cwzu6HiWU7ayBjPhW9i63f93K2Gk", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QPmCwzYeToHypmcosysxkSu2hnzEPkZ3Kq", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QfFs5G1TPzDzsa4UUB5PmypRnEFTyS3674", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QT5Te5Ya15tV3vSmdy2pPpZdrnztAAZeUL", "level": 3 }, + { "type": "ACCOUNT_LEVEL", "target": "QeiMYN7pcPJY5GUvZo2tYMHvDvRYx1cNak", "level": 3 }, + + { "type": "ACCOUNT_LEVEL", "target": "QY4NuorvFU9AUhonC5owihgNdRork8oo1E", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSDWB7bKAoH5sHRVsUNmTPe9xDkvX2phom", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNBQQ29UH2SA97MLDdnTy7ExxZxLLpfZwU", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QhP12VMSCpC4PcV55Fx4aFfT2c6RSsMs42", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgS64v2deiY1Z1AiLkrRxQKzJSMCNXVgrD", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ2vjwzV4Y5JCGzkwJPDgWysNMB6rFVgrK", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWmWNDLYdKkDwy5kRbyRe654wksS8r2nUX", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcwaxY5RRmoa3fSntzJXZLrwLmfrjqtFNu", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QaoJPWKxmGRq4rWNfo4232yVX5WPBuoKqC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSJHRs8N3dbPwYbhbj1L8jFWBzrq7L3duY", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWRzfuzym3kfZzuoA5ASpnEvmgeHE18hF6", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQUUAxnYkmMPs9WWQcgjwUMVPGpKnQPeYc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYqZN2qwT4zfE8XkAfTnvpQV4ws3JbCMxU", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSx29CwBhQsJbQ9hVQoAFEXQR2VYz7KjMK", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRnJfCXxGrdEUDVdHDCw9DDV3gKgRu5vVQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgWKwKe9mZLgTu2NeyeDsfuPVE9Ku4Zt2s", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qcj8jG5E9KtEYK12hVmWdo6cdUKven9z7f", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUMJtgeL4xEBWBT4NZdjqvMWGyfdagQ2pB", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdPxohH7LJTdUSXXnTb99qhuMSqJFCxc3s", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbW5AkBDfr1cLZHtMFANoMKB9ta86CAYD1", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPgKTyPyj8DMv2nLZumJYYYwSD7iF3Lw3U", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QLzWPFyLvezHjzdwnNR5n1jUHpHjdjQ3R7", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgcf647FFAFZ1JP7bEv4sa5rw4qr54uTQW", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjuQLSjRyDTVhDgMxzUjLJFbnYdUeXyH23", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbwcDH8PDbr5Kyr5jwBZ9Ys7hzg1A5QpMA", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQfhecXaev1FYq2UgMhpzZa4oayc9k1nnQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfpiA1rowLYMDVPf6oe7E9R7WNGQwAKfir", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQCcSHJspqeYhfxbK8UH1UhjHeGzmnQEHQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPUtaNb6ANbWHLJCGMs1o74yeb6pYmHNcG", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYrLYW646AtMjd6Nn3e4qzeKkMCzhtBkG9", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNfyujwtGFucVnjkaDEhdRprnixYfV2wz8", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSFxD72vCMra8P9ohh895NuuPHvof9b7qc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbVAUHJsqRY9JNn9aBV8VEowQQ2BbP9uyv", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXtM9SWWqqJGS36qDq6S4MnMt3dnUb4kNp", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQCr9Aj6XtEVQXbz4D9fHSDDwn8ANbQd7B", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSqyNKEktA4iXb7cWWTSUMkkc58vCiCJCH", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qeek2544Smo4zkMHvbQ2tVJhKv1gDAp1if", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQUm5WQs1jzN19X7Ls9NY5q9G1BmtbKK3U", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QenuGzurgCPaeh9xDxRwoPRjNivgX6h68s", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QihN1use3mN5BshhSrSS3hF1iMmwPFcdog", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbu1Si8WLZeXpwiHXzPsSkdBMDV1BFLkEU", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRkubxXBe8ABtsWFpdB498EhBy16FNiPMo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVa9VbF2aGbXNh3LfNxnFJ9p8cqSmFnymi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYqQHwpJMPR8aa4PWKEXxmc8uB2AybcRt5", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPHyxSLBx92izt17oifcsBqh2WYDTWcgpo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg3QXvRPXayBGqsGzfvS1a1Eh1WDHowBLM", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjJu5DiC3xVkFca2wznFCei4HBbvCRPoJS", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbcDt7uDJok9ka4FaVtXaT7LYR1sQMENyL", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPR3NwdDuZuZGXW1UZjoZhHKWreLw7iZVi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QaoWao3UJjZpwbwj6YgrdWgS1dDvR2vEFK", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVX7YLBES6rJGtTeLespEspCxi9oDYxGQ4", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRpsUCr13shNTDo78B3r4UthkXa3E5FgFr", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QREUmhn4Pty6mjnJxJH7RxnrwN1RvbD7fe", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPdzGiWdyHjbBhtCMvpd6QacfozzMPpfNa", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMriaRPNU6RZJmipSuZcRi1WVj63wb6GrL", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QiW6Kd22LCJjCpp5EBotFDeCKjCC7t8vSY", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QN5GSskzBjKQ7ZnwMMqgko1M3KWKCVqwh8", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWwra8uA9M4pvabK41561mgFd2o79thQdT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcQSX8HPRpNDGgQH41U1QV5FJ1TgK9q5Fr", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcTrX5Qzbe2djro29T3wKDq9MA8m86HqUH", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWpLWkuZ2pMiiPXRM4jupQe3vBp7GiRtvA", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdLHqfBmfKG7mnXCUALfgQvKW4S5igrDSV", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTBiyoSuy3ZF4yLJzjqUY2imVPsUULFbG1", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTEFHfLoXohdbT1FFdHVJeEL22qmMypTJ7", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdRwRvbTfT43sHjyG7q4f38PaGvwiDyrWj", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QijECkb9URmgXD1oAtvYEe59dPmU4A4fHP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qeh73yn3ngvB5yX3beKJArFuCJks5i5r7B", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNWzHrRGoZtYhEUznjdxCmMi22LqGa1ndN", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgNjGNiAKViM1Mzd9pGHieNJk6CRSFrZKt", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVrGMZ8NBLEvEcWXfKkCWXDvUCWF4z4yXC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe3camytysJJ2BnfqGmw7BUegZXJTvkeeJ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfVUkFvVNPxQCKgRFWzFQVk6oFCbuyzyRW", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QP86kJ98hxBi6rzAJFkoCuwkQXh3DvAGcw", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXxMKghXxEKx8RopT2rdiCrBFvoyN1mZMS", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdtUw5FRmaKAfJ1Ttu4bUagfX13cTHCpFw", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXuHgMUN9FWFVFjbquFqkDw5NKRToVd2t8", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjSZxZZJ2MRB3118i1VmSuzamBJNCnUFaR", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUWURY2qbSM29to4uuZ1CQXh2VgWp5AJsT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXbg7o4ufCFjeA5uSWDSMB28vAc9XeRSH3", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdmf9fZDUFWxjXZU2hrhTZmKiiMuy6AEyC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWJgQDygXYBXuweFXPTzte1eDMg3CnRUxS", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgZJ17ZXdrJEqcAPM4Bnj3NJimpCupDs2x", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QiZRd7wFASi2jaQfiWSMFS8Qcfrp3MCDxo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZuxbXpcWbPNHoU4yEps383E4rTKkkTdBH", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUigfC2QABH3RMuStStggx9YiZ49VdtWTw", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVQepwPCzSosioi85mzfCxVMPR3f8mGBjP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWBTMtHSCRrHTabkzf38tqhe5xSB8wtTN4", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRY5mhkq1fV9MZ8rtrR1j3MnCidqfstKCX", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTnnKbBSqDGHKiD1Qo7yb8ry33mzxZDs4E", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTtyKRb2fSeuhH44cvenzahXSKWiGfV5K5", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSbg4JJCm9oZkESD9obePZpGK49gWbmGsc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRtARSe5ppL7WpNaMaWeboWbVcL4Ua3nxo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QaQs8c2ccbjPGhpAYdaJRLGyBTWTkDKfmh", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRVotWtKboC5APg8YxhjdJuV9JioWFydiC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjXB8Mac8ityiVMWHkXPbi7qgKMuCjKdbW", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXLtbT3ru6WfPjTMZ35q2f29kuNh5v3X8s", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QisDJUYKUnv4sEW3RfVhNywxVWcHFg21Rq", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVpbg3oMF4MAUD8QVQbSfK49YfUYAijEPf", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVYPrrnPsn3D3AbiXsCk6wb3EERhTQbauT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdjJKpJt95jGgyQK3HR4qTYQuwAYdYTM5X", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcUths9zcKzhmWxpQjdoPkf7ZCrvPqqHum", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfErd2q9pvzPGuoH1NRSUgXxZtz2oyWX6C", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg8zWrXTiAk2r1gFLrh8e2vSen7DdbYU6X", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNMF4gGKyQxKBHxC9weivsiGwJ8JFAswgi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWNik2tj86KQh5zGCoskz4Rhcd9K1Qv2gL", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdxqwnjHC2qy1j11YMZP9KF9dm8AbyGMby", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSVYtX9qPuCxejLZtUxabTJ4urKFMcbwsb", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj6ykh2hXy5jiYRgrmt7D4H2KvMX5oPWac", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNyT6h7qK1zS6GeqXfoMJUf15pyush94yg", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdnRL1yDRGhoE2695SCLpPdCzzp5xZLMDc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QX8FkJYYLTjXSwdJBAwHtTvHiZWCmAVmCY", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXqHgue5R4qJNvPEsxZvbYMpsCRmD7YmRf", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTUArhyERXNN76q33wdcxJzVxZoo4YUQk8", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZHKbDYdSjS6FF3Mz41xowpjHF3fh6BvFb", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf73yQjcdH2hFndu5f7xcb6NDt19TP9DoB", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMxU2uvzikxgzj53sE7cTe6iri94z4FuXv", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QX2gQw3y5xuKD3shthn4cQ2mZ8b6XLysjk", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNtoFtG7gBXeocAGwDVvC2JX81qs6gSPHU", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUHZiok3byKWVjdp1U1LcVhsqcF5ARHT1q", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMZ7Vnq9P9TcB6LK4WLZstuf7ozSUBJSTQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPy3imHu9bFEkNPF28vidDQTZtLGdgpWqC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWMoMkXwPv6f5s8PxRfiy6u3nYfVMpyGve", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRYAgyuRWrLVg7VaB87vBVWm7kUyYFJ71w", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QX6PwuE3VRToyWd1Y5jiUsByFvppDeha2U", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfoPk8G5fiBXJ2S4Yk8PpcktDj8AZnShvT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdihq99B7ZnAtqru71PAGQhjhjtAJdAX5k", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTGMkcxHVmxv1JkDw8DSWtdB19hTJLG7zd", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXgeaEWieLL93jvT6QigYr9JGcJdnFXByP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSSKJUY33kdbz1vkiEooiY45VKiZkD2Dka", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg7TbPrg1q2ydVdNLqJoQtC3RLBUo3t2uD", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgcVr77TfcVmb9iSgsSRPeQqejqfKAvgQy", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYs3NV1EsGc2GaBYC1jPAPBsZRGfYfwopn", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjDgJmv1gnz3VSJHziY3quBHE51qEAj9b2", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMoUYGYfYXdVUxAGvxkisHfgzwvf1psMLB", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNTdKEnSyyUFfp9SPnbUSgbbnHi73LZ4py", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf4NBSfFKmjQBuuL3ti4xUFLt9cutKrHDw", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfjrwPMPQTdkc7rdu1qyUGGmy8uyXB5BH3", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QPxbzCsTxCsafRUWp1oBHfbqRva6sHyxTk", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSKnXn18fM83HS4J96BvSHmYi5CfvZYgWn", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QN2yfuEHpZqZDZREXUUTp7JDzzAnzD26S5", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QjCL939qc9yuNuP7KvEnpX3Ykj6vWtU5Xi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMkmZBSS6CqiUZrL6HkgL6NeEAbz4VYgKt", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVRxJWg5jsbNcuMFSzEbrQ6ZCWL9qqiQBc", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSKeBHV5ndQNEwZf7BMT8YMY63wJPXHUDg", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QM6nHqVPpe9eXvpeshH3fzKS7vok9ykN2c", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ5HVfecMcnxnUbxhNPk1HV2GMUTqUF5uB", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QNDN31DNB3cu6E6hKT7YQRv5P5wzbvm8gR", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QaDVaEtetDZk2SQUcEwrv7srTKVi5nKMXk", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcnYqcsiJ5bJnyKGMHRQA3LjB8EP6kbRxs", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVg5c7fQ7AjQx3Vtf1esfbNeMjuJ7HSxnS", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QMG7Q4CQfS8uWRFVNZCkMq9EMeKQMyo8hA", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWeVdu8Q6UtPCA7oxcw1vN8V4BYJ2UTLuT", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QZNRfr4Q2M3GCgUiCrffn4rr1fcNLMLuDo", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QXhvKsfnptbBhkbyThihqZyU9QESPfATbP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qeho5fUhWEb58qFoZdMB9LggSnbaQh9vRs", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe8DdBX1a6dzMyX6kA7BXHmJz3hPWB1y7X", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSAejnaEvm4pSS8oXEh3b9XYqmKuHhqLVb", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhidz4HLjm1kVrLTd8EPyJEELnoFVqnATQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QRtmedUHNdfwaNwBWX8tAK88mwTWa2z8Fe", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQa4sXFp5jq7ntE25pvz7xVU3rUWs4eiiQ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QSz9ksffuikfBjBwFRQJW51wQ5CbSc8HUV", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QVHXvLjCrNXgcRu5nw2KFNsaZ4SUkcod64", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWnXShDuAWiCmmKsLtRUWrbovhoXafU5sP", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj3PMPgJbk5nYi9wZxpyoNwNPaPAjBfKa5", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QbG8iHnYMCEt5Gv4gbXT2sTiafwnwy6SWh", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QTkzizg6HmDCNjA2XoUSJK79dgYTAFdNEg", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QfmqayQUFTY8YTqrw8odoQ8P3RwyWo777F", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWNkvkThhZXJQ23AidJ26bUQXiNCNfeNMv", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWmJ1XpdQSmZLG4vDS8EoB5w6UrwYzUFNC", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QQT6fRGwyxAS1uuayVJvetBHBhdKpBvgFt", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QWbqaN8TxsvxDihfkUUBRobujVbxsbzoTA", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QdtRTY7sfe2xQKR4jFRWMpFyyP4EoPnvDJ", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QgyC28vh1ri8U1UCkjwQuCinjJS6xmLE11", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QLnMwxfVJf82MovsG4i5GPvkny5JNBTQup", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QYF6MLarj9k1VPKyog2YHDBboeFngmUnTK", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QeXgozDEv5NhxmNzbV1HEugcceLoye2b2y", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QLrGfcLXyTWmA8CPUZkPM3WywzTAHVuz7x", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QcfiV9f1vUbBLrTRPLJsyhVgKzKT7uuHKi", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "Qgxgpn2J8c5LzC2aUkPqixnVkRmd4fjBUm", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUmr9efCkGUt1qMNer2vt1xtcy8S9wTtAL", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QienqdWpCiDvk5q99F8pt1JYTZsSn6qLrD", "level": 4 }, + { "type": "ACCOUNT_LEVEL", "target": "QUMip9ykZ66AP3Gbg8pGP1ewoZwoTZBtba", "level": 4 }, + + { "type": "ACCOUNT_LEVEL", "target": "QgYWsGqKjL7MrJdQmsHXMhtKxJqW6vWyTw", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQMiJaCGrw57PsF4hWmqtnbmyWVLPkq1s7", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPSfVkCtF3NJYyhPNN8yNAQY8pgRbFirgW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNrY4iAmR4TQtB77hMrM3u2XXYX2st3wxD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgCQoTy5Y5RNrBjeycXthX7t5HX7oEzz19", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiczLg5bJZsut7zqwka8E7y9Hi6qPh4Jqv", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQW5QPRbWBFQpdPa9x9x8AxejhgSTUGTwJ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTEDEPGWU1pED5VMo6dPYrN9a7CQe1zWtS", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZDN32a1tDV1mZ2jMZekaHiQq8QTfoaJ6a", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRBJVtRZGb99SosM9y5YJ7ogsMdVxXdPu9", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QifaDahrcETU3Jc5HEQJVUQdSXVvRYXUTi", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaXsvTkVCcfXYBod3LLnT4yBbVyxAcSK6V", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJq4Q8ie25z7QdfzeXoSJYqkG4pYQDU6J", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdUDcf7Ey61TxGtfdW8BLTZjBJ7zKGgk9s", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgwhdGRUuSKm4xqpT61xB5iiP29wKFkTXr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSftyUsD8B3F5nkW2YjEikmcUvLoGHjUL1", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QP7NckFrHLgGKbM8aNYwbGCk4YjsmgeKT2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYwvyiFoaoK74dw54N2xt7UmWH7hwUeCzb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdssSgnCVg8M66bacZqFaYCDXRGCpb3ze9", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQTrDiZhETEoAimcJFfFT63rzqBy6RNA34", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfi42mfbpxRE2KnqH4TGQzX5dEuSGaTABT", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRYgWAEXBJ21AN8ncvWYN1NhQm4iQV1n6m", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYu45h5kp5TAx5R53mMk7XUE1YgkEym2H8", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVX3JsK9vcyLBLjoWY4WwDLF3MoL3tSDMk", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QMHcurZGnyzPAmdNurcacm1GNCUHRRZ8jf", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVkpbCGEwfdkEjkkXPjZJGGqPG4F5YxoD7", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj3Fdad5SPJuMFFAmndBRe9AGumWxvJmZr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRJjhxgFEepMD1Mb3Bzgmd1t2WuSRKxrge", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QifSzfbmba5KHi2HyUwdm5C9evXqXENgZk", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZbs49mBzkHtoKouBUAD9atYUz6RBH9pQ2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcBREkh5tkffuFLT78SLPJZCzVWXhnweoR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbPcRUYCLY1WssipaRygKeEBm1LHBPZnuR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QfypvmTWiTHo2GgpBA9CrGvr9ke5Pi1dvG", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUEviSVKeHCwfBm2cpVHzx5aV4uETjARNh", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdhGpQJJDuVbZrVdNUS2ec3gNq2D4Tu9pF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZEKMgog8epbcSKGaH3stvFX6mc6EH611J", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qiq3xFZgSv8hiTmMs2inxf5T5tDfarPU4x", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZSS1LoXwHpPNzgW6schoQgUNoKoCA3iVF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXaaXiBDAiL5nwVsPhGwabjoEaV11q3DzG", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSUBoFU5hhcHduACBiz7kD4UAf8jo8zsTb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qani4X4UGeXamvzHc8X4RXzA8jWSmH8cAW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSTzv5G8YEhtHpGoUDNFVW9LMNke77kLye", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNneGgUVdTAMkQ9hoY1XbezGZ4joa3Thnp", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcNxuwspRac1sGRjotUTZrsNAX5rYr9fM1", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTuZoN8Rcm4pLUNP6HXR1t3tU9Z4Jwiu2T", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdvieSs2Lnr8j76TMaZVwiN26kTzsF7mFD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdSppgA8ZA4ojEPdNNj9akBbgDPvnTQH8A", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgSLWX4QEgujL8vB1btx2feZa7Nyueyv8k", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSoV8SFqxoEweZ1rJSsWtM5wJJnrbT2LGH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPpr5vJcjoJY8f7Wv4wrQMAyfPx4eB9Kk6", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QjHyEgbcJaYmmABWCMTcDiQAHsmYZ2ZkMQ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QadBi4yLoKjC6XHKGmrVJsVZReR7PzHgmo", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcAkNCK6bF9sjZYsroSAwRRygVrq5Lqjbg", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb2vVR5Jq6AmCDXhZutKdLU6fKi8weGPzT", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcgtjvYfx4BnV1mmA5FXWtXavXWkqJaYXo", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTK2wbbs3LzideTS2UpXLwKduVaFg3aZ6h", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhRPSTf9z5nELnH5otVyyRN2iHLJGM5gxH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPqdmoZnmPKCwugnbtURKgVMv4LCN51Mra", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhz2Enzj62kVerEnscLa1oCmJXYRk1b9rY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QY37v4nnj2JdnwxvZRyQKok89PkXNy2DRG", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXZrPLnA2yjCrbFkk1TJ4rGVunXDTcUCiH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWae72VAzeus4aVbUYJmtqgPhAYvEWrrAD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRzCnx48eJtqm6gUKhdSEZPVE4PoD9cezH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYFZoo4jcSDgrPxQ7rc8FhPb8fcNgBvrhu", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZbh5t9UiQGeR12cTMaEreo8pBQCEUodwm", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPiMqnsBRRwGQFJgzNK51siFqUGppfH1cY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QURar6EGNcaXr6TZf2X3gHCMkGBhGQLBZN", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgHdmfvGfiGx5kSn3GdRn72pWafGey6Jia", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbRktBVQovHF9Cc59M98VedTAFwgqg3jHJ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QU8mm9JDdtgHpwWEA6Snou1qvBgwVqQjio", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRPVT5h6VuNWyXWhtL1nMMTi7bGmw3yMDX", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVbmnrrGqe9RjwX4EHU7w17AY2mUrJ9y37", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgBLAB1HtEU8nuuSEso6413ir8bv7y9NNY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QV7LARZvy2Psz5kLfsD52uEeQwHuM4VYtn", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbEJfhEUV4nqBeGsDUYiiJyHW2a7LHzX1q", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QLeJ8R9FKeyhVivaLuvTt3vcszKDxEBWXk", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWazYjG28fWUvGCoxvVCwhz47hty7VgHdt", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QP15D6KGREk1eGZ8Pjb3LP8jw9oaywHRCH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSVQDZsQSvsd3BFiAS45WjA6gquH7mKp3s", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qee9GheZLwpyYPEbGci65Cu9ywmfUpiUtA", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVYGSLnspzWNtGCDBxtF26JMY1PRR9E8Mr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QjQCea9aLKuXQNH6iqADfC7yngwVTdA5A2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXZJY8MgKXviC4xeuMoZ6zaYSm7dJqZYaA", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfv2jWMD3EC6sAGxKx8hRBSQVAt4YmtTvX", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSVzQVQCzL3AC8bAHGkbTCiy3xgeWGfsfR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd31D4nhiCMnPFHoKdjeszqbNP914JZ8ro", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QfgfpEia92nL94vxwRiSJp7ee5ZophKhJ9", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQdJscTibkMvWkbZitYzrWLtnTxhgt7K7U", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QR5WMxJWgaBPUiDhyYvbgYfiNXMjvqg6UA", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJKN4HYcMBw1BCxEPB79peKqyBE2o8pfz", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQBRiZmS55ZEJNPj1VQBCQC7FVvafVdRBF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVyrsn9hn3evAQFm8ECjhRYeAqhDZgwiz7", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaYtyv8etaQF7gQP2YKzeLqKyzrp8jrJpv", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QStvmZNCzqNeyfzzeKrq5xQh83P1F6ERpt", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe6XX4Eghqm3psn3jSzwcsJ8N9yaaE6qXJ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qhy3Zg5D95QWrprgRyWL1Hta6JmMarP2TN", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVG8Qdgn2yBsNRQDV7oW54r1whSEwvUk7M", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVsBXTUPszNJrdaT11rDSRewSdUjMQd5cs", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPmFVFFkB72o8Th9D2wJxgZaz6unt9HwW7", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRA1jykbch853CAsXXt9sEGBjsp835v3P2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSXfJsEHYTcwmcsJ9yoekCD4HULQpxeCBd", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZhb63HLT4RFyczAXhvviLHvkQUi9q6mTX", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUHuwaoxNusHj7ZUyTYjRFP9EETt2hixkV", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRyvShLAW3tZEaydKZxLAA7R2GmErJFdn5", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf4FAqg7uo9sDZVwSyRctiGgHNfyr77HGD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcxYc7fFZjMCtE3GAdr6YduTcPzWXux7kV", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRwYWNYRYpP42uucjSMiSmrpteCyJuaatT", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUw5tEoXvnGKK7bUKye9zGLyuumhJxE8ZY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYw5c1Ufeu3Xs6X4wDtEW3rY6mvJdyiV69", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZfcnG7M1KLuNVHFoAz75Q9axCPeZGvmnK", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSWBNaUoWQtkDioGsRQVMevrNjMBNhFA9m", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfmyojs61pnVshq3AMb3SueZQJmZWGS6P5", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbakZPnxpoFvUUA2ikFXEMfupnzL2g1Hpe", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgYwhNYcrYXEVL5vu6xgtB5egYpspHu8Qr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QLtzQ3BiNMDJr6UtibNGKNY5q1Lek22mbw", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTxsroYE6NqWJEfCYRTN2kXFpvc1L6dywo", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QauYtokfSq8oZC7MQJkkRUCHwV9RCJXfop", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiLH8NVybYU3gfwXqmApeeLoebMmdxsvy2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QboQVDYaeYEWoxxuq638FGFwFZwVbv3wZd", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTTMNosEkAGiFXLUVMczcmKA12Uj77Dm3G", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbo2JRDzV3DFruTjznyhzF2arhrKuNuz8C", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdbbfH115EuQzAcEPfV4adEVKEhq2rQE7P", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUQYqyWTkyXvnUxM3caZuEtDEDSbfJYpwd", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdwmWF5FRsXNwn9aDh4SKWfhEuamnAXokH", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNTqdLMtm1k6Q4iYpnvc5BcH9NBxanMkYC", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qi7J6Wzb6pSgaeyGXYmbNo6a3J7csQncYW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QMaJeJ5MmK4UG3Zto9JgLhJ26JaPKSp6ha", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QeCmhsUEgUe7ddLqCMHcX2be72BPFkQWd8", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWTdjKcTE8DpSopLJsy1H6CsqupZSU3ZGB", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QeDrfr1wmu7okNMRZmbJ2EwZMFesEv9zMV", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QizC1Qtg7UUDu4bDKibiTKEMhmGqP7C1Qb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZPQfSTVKjRYJ3r5P6orJsYaz6ZcG87ktd", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjt3WY2Je4xSBFA97ptGQBZfSpJb4jgxR4", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTPUBwrW6aRQLHQpPrw4e2bDXR3gRWq3kF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZLRzY7RZy3h6pE5CAk57RUvR48HH2joWi", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNAN8iRgrqKwPqrCojJQjBpEiEov5tirL2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiygUw8uXTLzDFJ2E7HBKHMHqiuFWCa6GB", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaZpSiB9Nj8WdbL3MHvUzZBXeFSqEMiD7T", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgaJRHFpD7WQEpPKVkUSNzxae4hSUyeJvh", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QeN8j74amkd8GFyoRcxBaVrkHns2WxKjmS", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QLmQGf4imhLzZVAbX97MR9JFZik8JQ48B8", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QY81xXYuMaewGHHcrYNdxJzhi6dNqu2JRY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiVm5E2rvGb5VVfWTAMA1VUAd34k7Gqr1q", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhGHn89LXfEj7y4CjSLtadvn8ezL6cAScQ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSCP8xGYg88n963nN9ejiDJeiNggwLRLpE", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdzyigNad6fXJxykm46yWRH5tN8uDq4beF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QQJQfXRwypwuD6oRHjcdRCKeUhdrCsDs8k", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QPCYAstwUstQDESpFbPBB1U28LEoRcPq1J", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QU4xvi1HzuxYEqQrxhKYnQ2D8hDmRimE14", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVgLeZ8poUf7CJJ2vUGUEwUiKJ1sgszTzE", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcU5kKi3mX1VJzne44LZLpr3htebQtn7xy", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdkxvWwbkLDnovvksuNDwyDHP3634CnSCU", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf9XqVqSSKDBG5F6AP7nUv1LpUMU6bmRnK", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNfqTKfSU3E8RWoJtJgnKxwJDkzfXb18cW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSpEUGYR2rveDE4ryRPVjizHtRKnMRh5fN", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd1GMBYjor3X8AL1WzHFi7egejb45XtLzY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbbzcfy5WjRejf5tHJLG14P3uvK23ywozr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWUSHC2Bpdr9PUNB8Hj9S7HF5iagWPWNEa", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcftPPiBPU8Y1XuCzdAH6vduXHR5V7YFhv", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QR6rz7SHGgDC7QnNzWBCo6idbcxcXiLfBz", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhgTuLEhWnxFCqCCADVP7Fh2oXXG9j3Dj9", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTzaaunU9vbdibBHKqg5ZpVH1jcu4rCCm6", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNCas5mqJLgeJHB5jQKXuCVBhqPfRHdYMF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhqWZL5643Y5C3RwvBSJpv3W6FA8GbFWJC", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaVBebFHbsbhBcmrX9ocbnwEMJgzkHvMZQ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qdbj1tS7ZGrNdKXqEKfnt9EnwbCLLzyoCY", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjn22uhkomiP9H95G7XAbEVZFjKiGPfezV", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qj3QnnhKZeUbjUtxGLJ8jQVb7XohfDy1Eq", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QLqNGEMTT6GGi3dChtp56ocae6XxkakTkf", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZHLChVNfNNp5rsZQQKRRxkPGriUAyhK5s", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjrgs36ajjTFgrKUMvsDSW8xiNmDG1L2be", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qbriz3o7KWJZCafQCt4ftJAAEh8Pvg8o8v", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QgPQSW3FVHbEKf4UBZh1WwVhLo4eTSXa44", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSchsxiHAmhvA59HBy4M9y2JobH7nwi5Xf", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYEbJEiXbPjqKFpUWkbXBcFFUU1PbppZnw", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QNG95Qwbb5P4DGedLHv2kmYvMjG3GxbCEP", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSFj8r42yFpaPQNwKWwSVQKVdozTniCk8G", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QM4JVyX65WbLUYTyptAMea2MHsefGvUcR5", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QRey3hPPGc2ewP1Ztw4SFG1fyU1xiqLjCP", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ8cte5J5R21uypoaoCvAALzBkYSePZHDF", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcCRjkP1XeD1dvwU4umQ9cFWv8d3hJqjK6", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QUH61i6hsZehnXNJF5VefvLQcRCsNg3NPr", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QagyUZdmnKJA9LyEqcxJFFf2ehmcqVZsKb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QcCYuXos5xBXXHbRg1RTfSdxiZEkGa3N2P", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg3TKLhPvn7bKVrT9x37wJiJ7YZ4jBuqQW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QiQUssukhoo1ft4G9Mxa8JpViqFW4PdBjJ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QTVLRwoopfg7qSG9CMfCPJz3UydnT3jDxD", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QV2HChYd7opM1r6oYaX7KA5VUoKdiUuagg", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QV4f2XGsEFNgzabewhSPn1Gv3ZHNNps9fa", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVhboSLD1VmX2YvAnfAXkbzvsmXkDJZTNR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QVxGkDgXt4nHj4MAd1afV9AxT1XCUVLGja", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXqkdj36XNHviKSyvmYgcKX1rb7HyXCxPZ", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXZPc1JxDRWq2ruxuhVzirLqeb16rjpCc3", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QatMo1UEJwRoLMsV5PdYYXLV9RPGCiBMvU", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QbYEcWjKDLTA9tRVkkNbvVT5924rBjwVSm", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QMyn4hgtgCqpBTvXfhs4CWGmeKr8TzWkHA", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QS4HpYpif8xWe2EVcK6rexii2bK8NqsiDs", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf5SRxnRThmY6eUoCVJhfK7vdsWP4fqEBR", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaKT7xuHznaWqN1RdiU2WhNJCWtuDX7Brq", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd5NuuHK5tqTk8jNUF9mkR4zaLRbh2vUGj", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QWW9iuy79tcFbHChCsZ28NDoxMAqMpPXhW", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QP9y1CmZRyw3oKNSTTm7Q74FxWp2mEHc26", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe2ZksqXbXgJQ5kSErszCunbAEp7q5BBEB", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QhQLJ1w8ChxuqpdCMqB5cB9YUfsnMGKQrC", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QSUkFbuPdM11iN6LHTSyoc2XR3vir6CHSb", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QdWhwJckVbZCjCMDhLwsFuzAvwJv7y4r34", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QMEL3mfTcS4NwMsR2hx9tx1HaqmjsFiji4", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QXWCbFQ4fDdUVtcivCDySZmhbqSUw5CWKj", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QYvML3UWBSwpx9SSQprJo12R2ZjLKXHY5e", "level": 5 }, + + { "type": "ACCOUNT_LEVEL", "target": "Qbzgu32EF5nPsanMMXXsMNz1rQ4hcmnGZA", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSCYGst9SJb4gz2H21Vq3DXquxzY73VWm2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh9Mx3kaTcWfoYJDgeQuDJ487K8EEwTtDo", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRQ7875Nwp9osH7GScPREnfLPX5RczZZ46", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjBn4bLZEeau7hx3Wae6ZWVtc7yCC79xEU", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMG32pXpVopiMA6HLoawMi9x4WmwZBpotK", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa8uuqfKV9yZekwTNU2JWnj8rnZc2RRmco", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhRiACrq2Xgw491jZovDL5UqvmVDGXQfdG", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZXGVbvKEp2G4c3dBHbTxtZKmu1x3k4Urx", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QX1oGefhKHCchNSUqycfzPjZp5NnwBoAGK", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSw5SHLYZLe5NKT2ebMLAr6BbYJNQ6rWrd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQsUPNpkB2iERBFqVHJomgPvBEookzGgFP", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSqw9dxfGhQJTstNgkxJmig9YTxVFaTo3i", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjFpwBMrZKsGDmi8tGgXp1m9P7xxcr758T", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa64xQ1Qqmc13H3W8KB6Z5rRPsoRztKZuM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRpCadQWjcJHeieoUnXTiSpqydRbYcA6qh", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiG86w5cMT9iv7pekuRohJmFXUwkvjvMXm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgAUczBPFQz7UpVeukv8tEPGEtu4TkMAgv", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRjAxvFYSsXSuwTgDQFAxFo1Vy8ntmij9w", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMc2ogsdyB9HUS6gka1XvJst6iWV6XDd1y", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qjf73NRcLF18taDgZvrDXUNysViHiP81j8", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QLsnsdVELRJDkr355QCJXzzR29whaxbP3m", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgocHNWrSPTrUGp6oiSg9gwsvAHD8pYVHi", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbmbTWgUEH57JXHzdgUAy8H9HZD1Bzu2T5", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNXG4iaaPiqd2RLA28FJwCk6csUU7Mh6rZ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhsWKPi65qKKLVkn7DgopCn4h3f2W1FMvd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qe2TzQdF5MGsfFbytqEosFkmWA24i4YaQM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QULHofsgHS3B3whoFNXPHrNMJZDv4YUc96", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTwx66zP3H7PxNJjtX41BZZB6nEEpXGTV8", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgjfghHZfQtNjjetUUNC5cHza4JebSAeyB", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QitLt9FeT84swMehxuWnLqKrtfhaaW8nzm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdLxR3bofPLP2ZkwHKuhW1KzetRNyFeW35", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSkzC9kNFxZnKFiMaiGsdVoAGKjfBBZwcz", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ4z5mEXUDQMqBXGQg5Cp2SvGMDTEZaXLD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJfNxUJvvnX9zRCxAFrMFz1YB1cYShNLv", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQnyuF6J5AN7MvxZdxLL4r6qjYFmBpf6qd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJ5tQZCqh8KJmhbWKsx2uwUYXYbjyzUxz", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb6k1h4VvfoRsQeEnDSvsBe9PfJnsaRcax", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNjTDHtHXdVtfcRf8qTqSZgXLDiFBADBqX", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMnrK51UipWbtiA3ogm24mhe7WRAJ17BmM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNdQuxwAjHcojdxYfkxnnkMNMDZ2Ym6sH2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QN7HMxm2qCxHNTei5wmBfxFMr4cbb6xBAF", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qfb3KNCYWsEzj7npPJxiNnQKw97Ly3BpEW", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qg36i5Z5f12EYBR5PUZaf59Ub8KeEkYovh", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNy6RzB1xWykv3Yb6uUDQ9VgLTRGoFPLKT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qf8cCCvv57r14pN4oJFVbLym4WWMERkA8H", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QV4VZbbdmuYtZKE1LXjQsojbb4nTJSw3wR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QReEeUBRRnsXUd4iMtgHAt6pfHZfVYD66H", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QThtnUYKiXtx9ga7LtT9qftafdiVDZs2tQ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbADUiFmkLpvyTZ6ug8kkE9j8aDcDm8W7o", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYYpvHzcUP4s9jSZzaNn1mqZDM4u26yAfT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQKCYjczFAKAgjYhRL1jjGcA6khh35Fu9F", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSa3xN2kdAwc6PBQw8UmFpK245cK34Aa8Q", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYuLzGnHLSvLtrDndk9GGPQnGU5MW2MtYE", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QN7zXUcHfBhn28qopFZ1R7pej4i8ndPibm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QedfstymDM3KpQPuNwARywnTniwFekBD56", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTJMjw4LikMSf9LJ2Sfp6QrDZFVhtRauEj", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRE5pZcGwZ7bSEWh7oXAS9Pb8wxcBLwQdJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QaeYRDhR9UagFPGQuhjmahtmmEqj8Fscf3", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUyubWVyz5PLPcvCTxe9YgVfRsPhU5PKwH", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdxGiYrxV4Hr4P3hNT988cCo8CqjyNNtN2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QY3jWQe5QbqQSMyLwf8JiMAbY2HRdAUQQ7", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbTS7CNqoqhQW79MwDMZRDKoA4U3XuQedT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QY8AQrKf1KE7MCKCWG1Lvh7q2mEWWqjnCh", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZtRisdwd1o2raPA7KhCnF88msVJoyc3uZ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdRio67LD8QCPmzXiwimvnNgSuXhdHy6hM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qd6RrfCKZX3nx8wRCtJ6jJA9VJ4o7quuEe", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiUbpcT8Uibzua79RRzqbLqA3MUkWG1Q3W", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QXKBimE8Vbat755M9zmcKiiV4gkSLc6vfB", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUC5EMMVav2Qt4TDe9Af39reYoFnamxUkn", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjEaoBWyAP4Ff29dGUZtGsYdRvHKf8HKb2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QS11w9zba8LPhicybuvxkTZCmTLMCt3HZ1", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QW2LGY6cQwmGycv5ELE23z38WqXFzsuTFx", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QcL5p8mwKk9g6xpwCXTiHpJWwRUKqUkgKc", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QPHLQxDwymECG1deHhhxNkEM8jTH3rfmvA", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTR34yKDT59X1YJR4Y4HAnHJXXjwVHi1BM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QR9EUCjXzD7hQETjnZrKTsQ9XQAWjZtN3d", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQQUNMU47F6LbMjC7wVhaPgw34ytetgbLT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUuugD6cTY5p7RFGnMrV78dfmEBrAAYx1N", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQktyzttrEtkN1iHQNAR3TfS1T5Xse9REv", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QPaWULDvmSr1cwhNiYzU59fnZkQmLQafWe", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSvTyX62mGPTGLKA8TvmYcuct878LaLrsp", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMKunzBtoEQ2Ab8emix8KCXQR9dcfN7gca", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZezsFhUeN3ayGjJ2QnPJpG8tHqfutnKxq", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNBe19N5gNvgK1R4PvaYEinsAGTcbaQiNR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVkLgL3tx7aizRF64PAWnLn6VKTY4jGGXC", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRsi8YiWAQKrBVNHyEAdcKy9P82NRK66pu", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiktanAj2ACcLhcCLWSAf3oboZdrkvWkcu", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QXKXL8hen7hM8W1fFHUAfKbPxZqqiimTnD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QP2NiMK9iATLo2bNRER3yuEk38VP4SC5jL", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNkZHXusJXpreoxyo5ULyvPXZjkxA3UvEw", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qca4vfeoNVbtbHzJa5F3v8sWqZh51Fz5mR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiNYuBjPpwDo3b1iETYPZnZwtfQnpQGouR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QV3sZ8AtdRr8YTEPnmxE9tMt7wxX4ruG8U", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYkPYG6CEwpkxq5s3Sy6PHnn3SDXqvPSvb", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhGkgG7EtqwuRYQ599DNn1jMfyzkNeZhKk", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdHk2qSAMeiPqYRACaNyy1jpAVYzTLrdyv", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QTrtqtdN1Kxiu8YDumczZd4QvyRwAzk7FS", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWxcJFLecFjrmejcCToGVRJpXueAZJgiEu", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgU2udWFXJpDatHhmXWqFLxYCkYGSDUGLD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhT1YEHJpbuFrTqkRCyeAn5QdeJsNzjX6p", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjuZxHoptE264839GsNhjcWgrCAzcbDQQL", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QP9m28hRAd3Qz96CvBnwipR8J319b2sjzY", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QawVhDFeqEd6aGfgRxHsLN7SnrhGqweSHC", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVwpXi2jMj5aMVjZmXbfDiQwhKY3FJyiPk", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZBRV9a8UBbdKaz63JqTnY3w2R62W1phiN", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhQDb2NkMXo5TALywQwJm4jp5CxydPsrqf", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVjGMUHTBkimNLGuDvctX8VPq1NkMAWJfc", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYCd9YsdNpFeabaQVzoYUYAbUkXkERZNSZ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbcacPWfXdQe4HpDr5ddbFBuuURLaaS1Dr", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbSToBjt9g75sCM2SUEgJNb6uskeTyzPbb", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeToG5yenFa8TfHJUE17898D2RVZ76tYiT", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNBHmU7jgD3HyfD7qFzKAMcgdw7Pr3FTBm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeFMFEzN6nEtC42MdffLBB2RbyUKMq9BPf", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQmVTLRTBBQ8c2syo379Koydj1RNCAhUw5", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWWKVymgzeYECUwomWkaxioMAmpmotUh62", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QM8xDvrXzLRo41SjkNeMTYoP3tKsaLcQze", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QRkhzEqETQczL8xHV8P98rwu84755SDbWP", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QLiSBYE3QzNsWijsF8BTNzziLfyVB6nV4q", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QcS9jgiG6AptzioTUfUJr5oXJYQES275xU", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QfkPYKpTfotYzN5BhKXENDgt1f3vo8LTSp", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQ9NUxhdgtvvxTSZqYY6k9qxHziqSQ5jcK", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ94CoMHUyNGxtef6QHMUNRd8D3NNUgM5V", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qce4PQd34icYvN463Smi9ahVGxznoax9Wi", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QR3MXpsu8ig4PJHJEfKsnDuxrSDLrDzLd8", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QXHQEBm9CtVTY9RNdCDxju7yr61DW3K8JL", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhFG2as3oZVYSubieisTPHco58pgw2nr5E", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QY3dGvuVkQADmQYndkKv7sLBG6JeduhVbz", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QMSx3vQagdw6QD4D9SiiDRMhDrNFGjhUpd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVBktFMtw31ye88qjR2LTkfFGgoRkXyf7w", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QPr17q2iYVQ5kMEtmUmEBN3WpMF6DjzR2x", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZituXHq3AzDdi9PDhtA5jySAC4VBUk6UJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYaVmr36tGTH4g5iTCeB5tZu3u81yp6M1G", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa51wH3pbN1bDpDWwpDDRrccwVmZo4CdXc", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbQB5hSA7P2ssdYXzxWcbDePL6SDDBUpgQ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeKHm4Rg7ANF6RBfphgS9gkYhLEboJUP8v", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSpjpN64bYczYNsKsgmNmNDAFiKUg9orJA", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdmwboMpKVpnvdYZgiaEXpuEnygDRxyywc", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QLnRWFKRGRtQAmX2aGM1F5vXvEb7naUUBG", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qaqb6saKN4YuHVKJ2HEDgKWAzGhJQ43sic", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QadBYsejVVWyFneDMpCffjbBpxgF9AEatD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qh6K3QdnKBkb4u5Z3wD73C4sTjvcBTgiFC", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa7td7KrALcVXpMcv5GzvrtGMAPKHUUECb", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QaqNZLJBCJTas5Frp43jzxEYvEoWdgYeXJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUJoDzJ8WDG62MSpMfzxUDH1pwJ6aRWZL4", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeVt9GFpDSdg73XQbVdCU4LHgMp9eysYa1", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QgzD9PSp1P5WVkyifGxCcoV7TXzLWL4GgN", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWDtjA76XhCfXw6gvYfo3MFcbKCX2ZEyLJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QM9wXFKoAYkmDwCkz1Vdsn9vyMeRRKRCzy", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWyp8eCTuCnT32vYQEj5rywCXWxYm36dov", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSGJrJSGub71GrjGSXJSZMFUtHEn7C5TUW", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNhPdmMHBUPJL6yvghnTFnRajMBmdqddZd", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZaZctoQRrR2g1bhAfzb5Z5ZMANGVkBG5u", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUhct2oBCmaU6kYguDNcbU6HQss9QELpLJ", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QSwQUg1aWMQ7kQcR6WMWa5SHxarGdG3DgW", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QdQmZTpA8a2YnrZAykVhNpGhk4kVmjnwRL", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiVTD43EpJ4iFXKJwnofocwcopw1iYo1TP", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QeXsnu3X1FsmLMRPYPJcsfJBVrTtwW4qrR", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QVTgyvvRGrd56BrFLvQoaF3DAYBXaobwef", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QczCL1E9G6fpifK2pFgDQiV2N5M7X54vAV", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QYQs34RxFv7rtYAx9mErUabnJDvCfBe8gY", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QanakqWSmEB6oQkrWVDRArG4wTHPs3zw4T", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNiGHSk13xXy54KuCqQ5PQZBQa13DhPb84", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QemRZy1gnzY1j5czckXAoBqW2Ae32onBPn", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QNzqkJgXKy4Gi22hGgyMMThFeG6KSYUwEb", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZ3cnhqAJVyCwYBZmgjnvDz76bKyJCXa1d", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qa4ZKZEgKNRDNADY97aB95VYQMa2CUYV7y", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QWceBxyxTA9AUocwwennBg3eLb97W5K7E4", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QaX4UkVvH27H3RkMtKebMoBvJCcEbDiUjq", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QjJnJQfaPYJdcRsKHABNKL9VYKbQBJ4Jkk", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QhqaWQkLXTzotnRoUnT8T6sQneiwnR4nkM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QQwemW9rxyyZRv428hr374p92KLhk3qjKP", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QaeT4E1ihqYKa5jTxByN9n33v5aP6f8s9C", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUaiuJWKnNr9ZZBzGWd2jSKoS2W6nTFGuM", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiVQroJR4kUYmvexsCZGxUD3noQ3JSStS4", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QPcDkEHxDKmJBnXoVE5rPmkgm5jX2wBX3Z", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QfSgCJLRfEWixHQ2nF5Nqz2T7rnNsy7uWS", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QU7EUWDZz7qJVPih3wL9RKTHRfPFy4ASHC", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiTygNmENd8bjyiaK1WwgeX9EF1H8Kapfq", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QScfgds57u1zEPWyjL3GyKsZQ6PRbuVv2G", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QZm6GbBJuLJnTbftJAaJtw2ZJqXiDTu2pD", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "Qb5rprTsoSoKaMcdwLFL7jP3eyhK3LGqNm", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QbpZL12Lh7K2y6xPZure4pix5jH6ViVrF2", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QUZbH7hV8sdLSum74bdqyrLgvN5YTPHUW5", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QiNKXRfnX9mTodSed1yRQexhL1HA42RHHo", "level": 6 }, + { "type": "ACCOUNT_LEVEL", "target": "QT4zHex8JEULmBhYmKd5UhpiNA46T5wUko", "level": 6 }, + + { "type": "CREATE_GROUP", "creatorPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "owner": "QbmBdAbvmXqCya8bia8WaD5izumKkC9BrY", "groupName": "dev-group", "description": "developer group", "isOpen": true, "approvalThreshold": "PCT40", "minimumBlockDelay": 10, "maximumBlockDelay": 1440 }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "2qRDBEKrarZGvePqWM8djfAsa8LMw3WCcG7UmGni42Rk", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QhQLJ1w8ChxuqpdCMqB5cB9YUfsnMGKQrC" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "A5RNKWchwQisV89MXBsD36mXEYJYUoCqtMenhHRaWNt7", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QSUkFbuPdM11iN6LHTSyoc2XR3vir6CHSb" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "B4Yvir9qMK1SHoqffiyTj96ke9ZAKzvpybwURjy4LxsR", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QdWhwJckVbZCjCMDhLwsFuzAvwJv7y4r34" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "4MqhFijJJPjrLQVaUaAMPBpRhQH7uPKNDkgVMXdZSbVh", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QMEL3mfTcS4NwMsR2hx9tx1HaqmjsFiji4" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "FmSzBdj3kj8Uyin3pUzBNDHTfZ3dMKYFEJJkjeP2sDxq", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QXWCbFQ4fDdUVtcivCDySZmhbqSUw5CWKj" }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "2qRDBEKrarZGvePqWM8djfAsa8LMw3WCcG7UmGni42Rk", "groupId": 1 }, + { "type": "ADD_GROUP_ADMIN", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "member": "QYvML3UWBSwpx9SSQprJo12R2ZjLKXHY5e" }, + + { "type": "UPDATE_GROUP", "ownerPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "groupId": 1, "newOwner": "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG", "newDescription": "developer group", "newIsOpen": false, "newApprovalThreshold": "ONE", "newMinimumBlockDelay": 10, "newMaximumBlockDelay": 1440 }, + + { "type": "JOIN_GROUP", "joinerPublicKey": "8q7oSa8YQqTSvPP7aC3P9TrSpXbqp7zdYxbiGCHzv5Wb", "groupId": 1 }, + { "type": "GROUP_INVITE", "adminPublicKey": "CVancqfgb2vWLXHjqZF8LtoQyB7Y5HtZUrFKvtwrTkNW", "invitee": "Qe2ZksqXbXgJQ5kSErszCunbAEp7q5BBEB", "groupId": 1 }, + + { "type": "GENESIS", "recipient": "Qe2ZksqXbXgJQ5kSErszCunbAEp7q5BBEB", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QhQLJ1w8ChxuqpdCMqB5cB9YUfsnMGKQrC", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QSUkFbuPdM11iN6LHTSyoc2XR3vir6CHSb", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QdWhwJckVbZCjCMDhLwsFuzAvwJv7y4r34", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QMEL3mfTcS4NwMsR2hx9tx1HaqmjsFiji4", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QXWCbFQ4fDdUVtcivCDySZmhbqSUw5CWKj", "amount": 10000000000 }, + { "type": "GENESIS", "recipient": "QYvML3UWBSwpx9SSQprJo12R2ZjLKXHY5e", "amount": 10000000000 }, + + { "type": "GENESIS", "recipient": "QY82MasqEH6ChwXaETH4piMtE8Pk4NBWD3", "amount": 1000 }, + { "type": "GENESIS", "recipient": "Qi3N6fNRrs15EHmkxYyWHyh4z3Dp2rVU2i", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QPFM4xX2826MuBEhMtdReW1QR3vRYrQff3", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QbmBdAbvmXqCya8bia8WaD5izumKkC9BrY", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QPsjHoKhugEADrtSQP5xjFgsaQPn9WmE3Y", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QdV7La52WsJz1Fr7N8wuRyKz6NbZGEQvhX", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QSJ5cDLWivGcn9ym21azufBfiqeuGH1maq", "amount": 1000 }, + { "type": "GENESIS", "recipient": "Qfbyw8g4uMnwqinozQsbrXF1WisFt1NmbZ", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QbbbBLembrrYy8kA1GEnSUTRRX74nKFVVv", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QdddDvehhYdd67vRyTznA8McMYriNVJV9J", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QS7K9EsSDeCdb7T8kzZaFNGLomNmmET2Dr", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QWW9iuy79tcFbHChCsZ28NDoxMAqMpPXhW", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QiTygNmENd8bjyiaK1WwgeX9EF1H8Kapfq", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QScfgds57u1zEPWyjL3GyKsZQ6PRbuVv2G", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QZm6GbBJuLJnTbftJAaJtw2ZJqXiDTu2pD", "amount": 1000 }, + { "type": "GENESIS", "recipient": "Qb5rprTsoSoKaMcdwLFL7jP3eyhK3LGqNm", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QbpZL12Lh7K2y6xPZure4pix5jH6ViVrF2", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QUZbH7hV8sdLSum74bdqyrLgvN5YTPHUW5", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QiNKXRfnX9mTodSed1yRQexhL1HA42RHHo", "amount": 1000 }, + { "type": "GENESIS", "recipient": "QT4zHex8JEULmBhYmKd5UhpiNA46T5wUko", "amount": 1000 }, + + { "type": "GENESIS", "recipient": "QLxHu4ZFEQek3eZ3ucWRwT6MHQnr1RTqV3", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe3DW43uTQfeTbo4knfW5aUCwvFnyGzdVe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNxfUZ9xgMYs3Gw6jG9Hqsg957yBJz2YEt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQXSKG4qSYSdPqP4rFV7V3oA9ihzEgj4Wt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMH5Sm2yr3y81VKZuLDtP5UbmoxUtNW5p1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRKAjXDQDv3dVFihag8DZhqffh3W3VPQvo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXQYR1oJVR7oK5wzbXFHWgMjY6pDy2wAhB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNyhH8dutdNhUaZqnkRu5mmR7ivmjhX118", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj1bLXBtZP3NVcVcD1dpwvgbVD3i1x2TkU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjNN6JLqzPGUuhw6GVpivLXaeGJEWB1VZV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbgesZq44ZgkEfVWbCo3jiMfdy4qytdKwU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgyvE9afaS3P8ssqFhqJwuR1sjsxvazdw5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRt2PKGpBDF8ZiUgELhBphn5YhwEwpqWME", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRZYD67yxnaTuFMdREjiSh3SkQPrFFdodS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QieDZVeiPAyoUYyhGZUS8VPBF3cFiFDEPw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV3cEwL4NQ3ioc2Jzduu9B8tzJjCwPkzaj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNfkC17dPezMhDch7dEMhTgeBJQ1ckgXk8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcdpBcZisrDzXK7FekRwphpjAvZaXzcAZr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbTDMss7NtRxxQaSqBZtSLSNdSYgvGaqFf", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qaj7VFnofTx7mFWo4Yfo1nzRtX2k32USJq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRchdiiPr3eyhurpwmVWnZecBBRp79pGJU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QemRYQ3NzNNVJddKQGn3frfab79ZBw15rS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QW7qQMDQwpT498YZVJE8o4QxHCsLzxrA5S", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM2cyKX6gZqWhtVaVy4MKMD9SyjzzZ4h5w", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfa8ioviZnN5K8dosMGuxp3SuV7QJyH23t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS9wFXVtBC4ad9cnenjMaXom6HAZRdb5bJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSRpUMfK1tcF6ySGCsjeTtYk16B9PrqpuH", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qez3PAwBEjLDoer8V7b6JFd1CQZiVgqaBu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP5bhm92HCEeLwEV3T3ySSdkpTz1ERkSUL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZDQGCCHgcSkRfgUqfG2LsPSLDLZ888THh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN3gqz7wfqaEsqz5bv4eVgw9vKGth1EjG3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeskJAik9pSeV3Ka4L58V7YWHJd1dBe455", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXm93Bs7hyciXxZMuCU9maMiY6371MCu1x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWTZiST8EuP2ix9MgX19ZziKAhRK8C96pd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcNpKq2SY7BqDXthSeRV7vikEEedpbPkgg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhX25kdPgTg5c2UrPNsbPryuj7bL8YF3hC", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qcx8Za7HK42vRP9b8woAo9escmcxZsqgfe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjgsYfuqRzWjXFEagqAmaPSVxcXr5A4DmQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXca8P4Z6cHF1YwNcmPToWWx363Dv9okqj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjQcgaPLxU7qBW6DP7UyhJhJbLoSFvGM2H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjaJVb8V8Surt8G2Wu4yrKfjvoBXQGyDHX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgioyTpZKGADu6TBUYxsPVepxTG7VThXEK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcmyM7fzGjM3X7VpHybbp4UzVVEcMVdLkR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiqfL6z7yeFEJuDgbX4EbkLbCv7aZXafsp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM3amnq8GaXUXfDJWrzsHhAzSmioTP5HX4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWu1vLngtTUMcPoRx5u16QXCSdsRqwRfuH", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi2taKC6qdm9NBSAaBAshiia8TXRWhxWyR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZko7f8rnuUEp8zv7nrJyQfkeYaWfYMffH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcJfVM3dmpBMvDbsKVFsx32ahZ6MFH58Mq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVfdY59hk6gKUtYoqjCdG7MfnQFSw2WvnE", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhkp6r56t9GL3bNgxvyKfMnfZo6eQqERBQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjZ9v7AcchaJpNqJv5b7dC5Wjsi2JLSJeV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWnd9iPWkCTh7UnWPDYhD9h8PXThW5RZgJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdKJo8SPLqtrvc1UgRok4fV9b1CrSgJiY7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcHHkSKpnCmZydkDNxcFJL1aDQXPkniGNb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjaDRfCXWByCrxS9QkynuxDL2tvDiC6x74", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS4tnqqR9aU7iCNmc2wYa5YMNbHvh8wmZR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiwE9h1CCighEpR8Epzv6fxpjXtahTN6sn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRub4MuhmYAmU8bSkSWSRVcYwwmcNwRLsy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLitmzEnWVexkwcXbUTaovJrRoDvRMzW32", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUnKiReHwhg1CeQd2PdpXvU2FdtR9XDkZ4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcSJuQNcGMrDhS6Jb2tRQEWLmUbvt5d7Gc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQQFM1XuM8nSQSJKAq5t6KWdDPb6uPgiki", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWnoDUJwt6DRWygNQQSNciHFbN6uehuZhB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZppLAZ4JJ3FgU1GXPdrbGDgXEajSk86bh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNHocuE5hr64z1RHbfXUQKpHwUv3DG4on4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS5SMHzAyjicAkMdK7hnBkiGVmwwBey1kQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhauobwGUVNT8UkK41k2aJVcfMdkpDBwVb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh31pAfL5dk7jDcUKCpAurkZTTu27D9dGp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM1CCBbcTG2S6H1dBVJXTUHxhfasfTR6XF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ5zUwBwfGBru68FsaiawC5vjzigKYzwDs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWmFjyqsHkXfXwUvixzXfFh8AX5mwhvD7b", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJ8pBwaXUZ1C7rX4Mb9NWbprh88LeUsju", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMLDPdpscAoTevAHpe3BQLuJdBggsawGLC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaboRcMGnxJgfZDkEpqUe8HXsxFY6JdnFw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVUTAqofenqSuGC9Mjw9tnEVzxVLfaF6PH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVCDS2qjjKSytiSS2S6ZxLcNTnpBB9qEvS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfEtw43SfViaC2BEU7xRyR4cJqPdFuc547", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf9EA2o8gMxbMH59JmYPm8buVasBCTrEco", "amount": 10 }, + { "type": "GENESIS", "recipient": "QddoeVG1N97ui2s9LhMpMCvScvPjf2DmhR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QajjSZXwp33Zybm9zQ62DdMiYLCic4FHWH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZVs7y4Ysb62NHetDEwH7nVvhSqbzF3TsF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP6eci8SRs7C6i1CTEBsc7BkLiMdJ7jrvL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgUkTPpwsdyes7KxgYzXXWJ1TnjUFViy9R", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVVUs58P3UimAjoLG3pga2UtbnVhPHqzop", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYVhnvxEQM3sNbkN5VDkRBuTY3ZEjGP2Y6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgfcck7VX4ki9m7Haer3WSt9a6sEW7DwKm", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdwd54nUp5moiKVTQ7ESuzdLnwQ9L7oT37", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiPTyt2VgN7sJyK2rCfy24PQhoL1VwvAUs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXNABfSfAFRDF2ZCca4tf1PyA3ARyLUEUK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZJjUVgjoacvHmdjfqUDq3Dh6q3eTyNh2y", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWHzcbXSrEg7AiVDLBhsR1zUBnWUneSkUp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLgjnrRRCkQt7g7pWQGAXg99ZxAC8abLGk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPmFGR56aQ586ot61Yt1LX79gdgBYGNeUN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQb493uqAUrWe2YoNR8MmhhxjNYgcf3XS6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV3UDtxFyXCsKdmnVWstWQc1ZMSAPp1WNE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV527xbvZNT1529LsDBKn22cNP9YJ6i3HF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQAbKyRGv8RUytDyr1D6QzELzMvNmGnuhZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP2xZTDDu6oVvAaRjTNW7fBEm9fcjmyjAF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRH9E99H893PS8hFmzPGinAQgbMmoYxRKj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRtqR9AqsaE4TKdH4tJPCwUgJtKXkrzumk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaEyGRLnR7o85PCRoCq2x4kmsj1ZuVM3eo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUZSHjxYNfa6nF8MSyiCm5JKbiRnBy6LZd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXUozAco8vrZgc3LZDok4ziQdUb1F2WNiv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZF252FDKhrjdXUiXf16Kjju3q23aNfXWk", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj1odhqTstQweB9NosXVzY6Lvzis24AQXP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTaiJKCnV9bfbEbfbuKnxzNU8QEnYgv4Xu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYLdKUKoKvBAFigiX2H7j1VcL8QaPny1XX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaEfP6nFkNrDuzUbcHWj9casn9ekRJCtrg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcbQcC2BZP9AipqSDFThm3KWfycn9jweVj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfGLmDwWUHhpHFebwCfFibdXFcMZhZWepX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMkUwfBU1HKUius1HrEiphapMjDBsFrJEd", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qab7N4CYsATCmy8T3VTSnG8oK3Uw3GSe6x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdJirbcRUTZ4M6fBAmKGgsvC7DVpEqQLrt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSPVSpKZueM1V9xc8HD9Qfte5gFrFJ61Xv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcuAciBq8QjDS2EMDAMGi9asP8oaob7UFs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNwDgR34mYsw1t9hzyumm5j7siy8AMDjST", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf5RGjWtSn8NSpYeLxKbamogxGST3iX3QY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYrytjgXZmWsGarsC3qAAVYdth8qpEjjni", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbHqojw2kSmcsdcVaRUAcWF2svr9VPh1Lf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXsrAcNz93naQsBcyGTECMiB3heKmbZZNT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN4NnUvf4UwCKz9U66NUEs6cQJtZiHzpsB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRXzd5xi7nPdqZg5ugkoNnttAMEMAS7Zgp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZmFAL7D719HQkV72MnvP2CEsnBUyktYEX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT7uWcs2dacGGfLzVDRXAWAY5nbgGjczSq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYhu1Yvx4wEcMZPF7UhRNNfcHFqWKU9y8U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeEY7UgPBDeyQnnir53weJYtTvDZvfEPM4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQszFsHkwEf1cxmZkq2Sjd7MmkpKvud9Rc", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi8AKfUEZb6tFiua3D7NMPLGEd8ouyAp99", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYMortQDHVwAa44bfZhtoz8NALW3iE9bqm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMptfhifsYG7LzV9woEmPKvaALLkFQdND4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR48czk5GXWj8nUkhzHr1MmV9Xvn7xsyMJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRmrBWDmcRz1c5q63oYKPsJvW5uVvXUrkt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR24APnqsTaPCS5WFVEEZevk7oE1TZdTXy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPUgbXEj1TfgLQng6yHDMnV4RE4fkzxneP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhZH9dcBwJXRHTMUeMnnaFBtzyNEmeEu95", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeALW9oLFARexJSA5VEPAZR1hkUGRoYCpJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgxx7Xr4Ta9RBkkc5BHqr6Yqvb38dsfUrT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcqiXKsCnUst4qZdpooe4AuFZp6qLJbH1E", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQLd58skeFGRzW9JBYfeRNXBEF6BbxuRcL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfBvQKMgWjix4oXPZrmU9zJDv8iCT4bAuv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QamJduVxVwqkUugkeyVwcEqHSSmPNiNt4G", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeYPPuzXey13V2nRZAS1zhBvsxD9Jww8br", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiKu8wuB5rZ4ZvUGkdP4jTQWBdMZWQb4Ev", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhhhQhVeJ1GL3oMyG2ssTx7XLNhPSDhSTs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPfi9t9CAPVHu3FGxRGvUb723vYFUYQEv6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWH9V5WBEvVkJnJPMXkULX9UaDwHGVoMi6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYWoBSTXCRmYQq1yJ3HHjYrxC4KUdVLpmw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QftjmqLYfjS4jwBukVGbiDLxNE5Hv5SFkA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMAJ2jt377iFtALB3UvuXgg21vx9i3ASe9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaP9FzoAQAXrvSYpiR9nQU6NewagTBZDuB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZpWpi8Lp7zPm63GxU9z2Xiwh6QmD4qfy2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPNtFMjoMWwDngH94PAsizhhn3sPFhzDm6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTkdeWxc34v5w47SDJYC9QFz9t4DRZwBEy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSSpbcy65aoSpC3q5XwEjSKg15LG868eUe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhcfCJ6nW4A6PztJ5NXQW2cUo67k2t4HHB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYqv8RVp57C9gaH8o1Fez3ofSW24RAfuju", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVgLvwFNNjHAUwE8h2PcfKRns1EebHDX4B", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSv5ZY5mW7aGbYA7gqkj4xyPq4AECd7EL8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgyQ9HX5JRbdKxFTXgsoq2cnZD89NwxinT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTEpaAMni8SpKY8fd8AF7qXEtTode1LoaW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeKBjbwctfydGS6mLvDSm8dULcvLUaorwX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhfG4EVSd8iZ8H1piRvdRC8MDJ3Jz1WcN9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjXYs5HWfda3mgTBqveKatTWHnahv2oX22", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh1iJg1BEdoK4q4hjXcSkNE4qv9oYsHoF4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPBcSVqzpB3QhiwMkiq9rMHe7Mx5NynXnD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUgVsyMPFxjiS2o5y81FoXoiWHiAwfbq94", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNH3ebZTv6GeWwjwhjhGg7doia6ZJjqQXG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXXeoduLPuhfURibgkfEfSSQ2Rom9SELtL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcKnXTjEaTBr91PQY7AkCxvChNpkqU6r1t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhWNbSmPAoAg8bXirPeNyGVuoSk84rfnHu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdivX7dtJKosr83EmLTViz7PkFC4FQqeH4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUB6fPHDTrpYyU6wJmAqV6TUBZiWLrTPuz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiG69VVGp13oCiryF4vpDu3a2kEEHi7HDm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS4dJJhwCheoMB3Z8Mk8wNZFfSu4FkW9Vv", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb6peGujesgEH9aHd19NfKvR5vTmsb2oHM", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh5tovSQykjFNJGV1P7tGtfmfnJXQQNLr7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS1vPBzGLu8ZskZtapcYzUCr8pEjVxtFgu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUJmfReuva7PmyzFBr7M35QuYZcAoeWPyT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLfYVnUtR4RVcthhzYc7U76vmK6LkyUky4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfeNQecGDhdHSdoTDAKAaAdpmgGBfJjQw6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiPHkz2YVDhsJPdkD7qxizFFEu7m3g3zA7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSmmCGNkGbqwGGvdeBtkHBPa4pXXEG2vkf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNjN51iZaZb3ZnfNiLdm1xtUZ4DKLj9X7e", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjFocrHNieQ8rDYifrZTWtYgejjih6mmS1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSNDFgL3bfX7Pe9FaD7p1G1rtJe5v9aYsV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWf8uFUXCahEXLV2cjJjunimCJdnvsN3JM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSzVqpvkjfFAC6sJcyefyouP1zYZycvwpm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX3zQTmhnm89PrW1nfs6YJDfiAkegzpD1S", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg6kovZCzF2GKNyMoeJSaUArvzKJJH56L1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhP2ND6q5Sptsy5pQUo18AuTgKMBfF4aPr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT3Cu76gET1ezemDVCojoP3SLMY4xNDH7k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdsqubwFQ1hChYwzpHvKAiLF9JMWWEwXhp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXE9M12CjPHBSFTS8DFUWjab4Z7F1JeRw1", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa5e8Pz4sM7RSAbwvM2N9m5NyYAgm2Fo3J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjoNujTmVCDVoR5M99NMBrGwuJCVZUSWJ2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMmVSM2dmfhRjGMCZaLeBGU7kXGGPeiRZn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYh1Ht5c278CPs56khy4iH2YxXZrtdMGXo", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb3m52qr4jcsidw6DTPJUC62b51rM61VFj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTC76DrGsCJuT4ybDiDTFaTxjXTPUJcpUi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRqBqahzem4MpJarmGYh1jyaFHYxufssY3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZpoY1W7MJvu5uJwdJRbKwWBhVhYPRAgag", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPfvtXRAWazxK8CrSRvDoCtRG6Hy3ujCx4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgJ8Ud1qJHfdC6wyaUNcigUHJ65Udd2jYh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRQtHawUKGY7g68yabnneKo88BFv35ddMD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYFKaYFjRe8iYDbwUBTWjmPGosjcgBtC3Y", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc3HUdiKbHaaFK83p44WVicewmZip1TnAj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYwkYWDsoJAWHPN1dHttMZ8QPABbriRMov", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVNwVTRnJNL7HYpHZ7wppApTv8H3FxvPXU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaxHEi7urRTZbGmcpyCcJr6zQZbDAnbfJt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPqG2UHH3ueqsjm2HMUuQj6GQW99VVXJry", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc5LxN2SQCQfJLVatuSMtmJtAihjapL3Qg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgMgsYiwyRiUYMHKCdB5tLJxuCroEbJnq8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS5NyYUUPuPvkkvazYyYjTT9ef7eZU8of8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfNd6YADJq1M4SbwBxLKQ3AD7GEpTpAJi7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZfGRwx8K1AyYwPUXHa9Tn16KP2h54iwfr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNB1kaRHYBrmDRHepqxad5DYxQPbjVG4As", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTpjvRCrvWjXoBzSG379ZsEwW2F5xoLSiP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZhnNK5FfX3FjTwwYwbewUQGE64Vts7qXP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNFmGsWLr7Y4qngz1maq4ptzhcUAJdjDU1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR3hH2cxYz9MgDBq3vthEbdnFVMJvprzyV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjjDaHSiAaPP8p3CRM3STeBc4VD9SCY4TP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRppWy5shqf6TPZfh6CAfjPB25aLWPiNub", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgaszp8eniCvsFiVHaBNNDToaVVYjLdLeB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbdHAJur3Vg9MYCPcgsz4dNW9gDGp1f727", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTxt6nMZmyZCJVLcsxZmwt4sUv1bFkLLRi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVbpnTE83PfopgvXY9TD92aYWQrTgvGN3Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPdfyB2zwWt77X5iHeAKr8MTEHFMHE3Ww3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRUgU6YptQd85VWiSvLUDRoyxnTBPGRHdx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRLDb39eQWwiqttkoYxDB5f5Bu8Bt6tu8P", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUB67e2qPecWexgCB98gr3oHqMN2ZVay9j", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhK3xN3Ut6W1B5pg9MJdTLyHLAGLjcP7ma", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qai9X8cd9FdZufFH5rcKYodp6s4AQqH2XF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX2YaXwfrEDNzUAFWRc3D17hDaLAXw42NQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMUMUzgWeXhUJsWxa7DWVaXDzJFrtpuPCn", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg8hVCdNiRy7Tqs2EHqLWtydqp1wzYc7Ny", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXG8YWRehGa3aLTnnMupmBrXeXS93YuwmE", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qadt3251BYugMm2MjkmzCjrzGp2MfkJicH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaiosSXjrXXca8vLpNwKh8qijdh1rd23L3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP5tVLY8CQqQgzMuTPrxz2XpP2KDL9neNV", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjeebj5TZqG3y8yGwWT7oamPxEncaf5fC4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMM6TbkySGcRkxdpjnmeRcYgL1oC5JKR7X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQAFHDRg2PyR6UMR87T2DkQfizMR5VhStM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQcXsQRpHtPjECVp55Weu4ohoJK6pK81vu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSrT7WTzjs6jnwZpDmcD6NvD2V3i4H5tq5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWAagG61SiQvfSbWS4vQnvmJbyCJ7GSXiy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQP64bevncP8kZ9bxVP5Brp8moK1rsPsBk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUuGEuWwQyjMgtxzAhcvmsQhE8VzsA3vjt", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa7iysVRdxo3KzYSi6JAqAYf4NFfFDjWLj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcQdJHRGgvL3AoR9LSRSjVNdczukw7PKQe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QedHYrn1QkrRZBkRu5kkajgqh5bcD8xZkt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUChBcWdxZX1VFHGwrUjRJbqbXjRdPNyki", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNW6zHWRyzaMPbb6JbKciobqbxtuQSZgw4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPFA5p13WYzzhpvHCGDoHtiA2oKAxPeKhU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS2ekPtGMR2obKdFKqFAcJQ3rbZmrzBSRz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSpVwNfiKEh3NiBXduS8TnJXwgyHYmfFqH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYm6g3WqAKnhotVwSLjqzorpVhzn2LgctL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSfifb4e9W8C1K2uaAcwvjzqN33fmMcVwR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYrLivTTHat8xFeJKkzrJXSyHeWkuBhWVA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTxcZ3kMi7msQCkViFwWLdhkShhNNVa5Wv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNS4HmJen6qDVqSAYszeHKfaf1j1662tj6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPBri2D8WYjxVZYd2oKgwvXg94FKweytBQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbeujwVbYFLx5uQBmkYs1a6cZRAopeB4cD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWQUSSqBRQBiNnDu3ZGNGTXJyAfbLf5MxK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPt5bnE51SzA6VES5kpdvpNHiFeHHMKWc8", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdu3N57EXxaZ8TXRfHbEa8QuqbYW2sot1t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU29ppZiJ9Vzw4tQBrXdPJZToWhpu9Dp9Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZbrkBLFcmRUA21u5QsrPBpzrDH2wXpK7V", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP544WzvAVh72cCVGr2WKFMzpicaH1wqAY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgKUwvnhj8tHWbNb59s9nkHQdapgWNcgAy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSCminTT9z7qmx3zEvGZ221B5rVNvjBsK4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaSrZL9TyKNUMfge6YiDatURrT2QHxNX1R", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYg2fLR5jXjStMhzUSq7QJ5uEbTrvRXRYt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUWZa7s85qeLC6uWKTsMXnJ4BQbMiBddZB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbUSdFauEMKMHq2kAfX7BaLknVME6FpJhj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRXMXw7CT1NahXwj19t8wHHAuUFAMYm6NK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWr5TR1trHvVh1JzQbRARKqjJaMiywYzgr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSjVVpSLeaaFcV1XacFJUXpBoBB3paFVPY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQaDSZPWWFcFPGj38g63aP2gngvcgJnmsa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcKk4AGz4FwYA56C7wAZW9Ep5Fimf4c1Mo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQJnvY3h86m56EGfWKzaVZnFthNDAUdYFo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSY6Ps3vxs1XEyFugvAWnv8a7sd1WuZkA8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjGbYagnZyc38Sm2M7gbg7wNX4Tfp6kTSs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QikfKyFmSWN12cMHVzEurCrfS4KEywessZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZRnbiNgLsGjd4pCrWntwSaGU3Ex4sZfLE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTyUpPTd4n3Qk9k6k6ifKnB79XHueE4M4X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QftHwRjwREQ3goEzehhF59rZUtrqrBGH7P", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhy15ZCfvjcDiQt97YcipgwK3paNQWfSAT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfhGGYFr8ANfCg32VcvULCqcofUybRbHYJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMZqtWiJjH1JUqy7roNi95ByGvzFThxDXy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR17PHMYpHsfhQ8NXPVSVzXG3puMn99YfU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVJKdAoPLfnShJFk1cxcu8h7z1SvPTaVyg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPByWaTGBToyDNhGMMBgGGRtLPD9V4h5Vv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVNWqbd7ERjn9dcqBGwmUcseoiwQCehey7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUQiDpv4PzjHLz8bYk8FJBnzrmjKYc6bsr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXtNoe9v7bsfW6w8uJweXpo4JESHoxWium", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfPfxADaYrQUrKySf6tJBtMHA8cNG7VtNe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQjtX9bro4bRkS1B3FyfAihyk3vZkQm8hZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qidvt5WQVMqgcchxwGdCd2jp4cCdGioA4H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhfLqEaDKmbynhKYK95BQJtseH3cqEEURD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYyyAFBUXB9F91KwHCQNuFDGuw7L38fi4x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbMdmYjG4d71FUjk6L7pEoszoC9EQH1zUN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWxFeuRWE5GZXNfZ2tYqW3GmAC3FAz5Qrc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZbG62vQnBrtYJ2VwuJSzfA8NXMj36FYbb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLoV9cxAUkPn2DaQKnqDVJq6jMN3k21JAM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcsBjck2WTR7J3PmQ9RXHxsPewPkbxzCtp", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj29EdPyW7MhZ15XDgvGZwXrmsP84KM5ff", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUTg3JNn6JGtHy25XTgdNu5APzp5cAg79v", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU1C597JwXXBbR2ysX4fKGr9DTqbn1bPxE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgboEdXscGVZ3pFyUq7x9ufaRmDseeb4dC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNWtJX7SDYBQxsEqmjsLbhVQoAYV3QkynD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY1KfMNNtBe1q6JxGzGimxM3vpCoqzQCNX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYp7DknXc9PbdF52vTozrh1ZEfM7wZBhFG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfshJREL1rFXcBDYTZQcj8mGLpQh3ZWC6t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNLBihtJXLo3HVjzLGgdbgbHacTgMt3USC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZkKsgF78HsiDef87g7dGLGKsoTSH2ekWT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWv4gyJ4N1WxCLvAmWKLtx5mmBYAqXHTXD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSp5oQ65SWNbfampnxzgBuEymJLVkarPBV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLddFbuRfbkrMQnpHA3gvBtYERfqwRdJsC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMHWrDejEvBVuzQyUhnVqnSaKKMHyCosyF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVwFkDM51dcvCfmvYUBjjQg87JteNis7f6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTvu4zok2UGnB45s1Luj6v3AzMRUEP1zmd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYh6BPhuScCt9ENbnAcp16mCZLsYnukMWY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYH3WNEknRKSFViWuZzmN43q8wkAGpzKXu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZwweWZAURCtoLM8K1ouA7McNyHNjyDcBi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ5RBZkiqGhvCnQvCPPZar8RqhtwDonDBi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWpt9ZPYks3PE8nHLyKkoLogD3doMumrK6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdds3wCmA2P4kkMXHJCi1JuQVMLJayskQu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYkkDoVQCHZQM3KJQC1J8qFVZmXi3T7JZe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM44Ks8EALor7MNhQGHpUpqu484VeUYRAL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeBeZzP6xxSk1hem3tRzchzAAMgRKb3fkg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QThNX1VbEGbAE31sjZKZYBBg4CNX5JkbRr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQiz1NcVPECxicoDXQ1p6h5yU6KozLYFhj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP5h7AugR5sY2U9YLHjmTTkuoZFWoomar1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY69G6HF2SCnqEPJwwHrnBrXn6UwccfSGa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhPFgRDmwGdjexK1nEA2r4caPXG4SRVXCD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQrjQEssGwc6ixp9N76b42By1sFbEKDTDZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgnzw5Drcj1LvipRbcCPS9rG1PSyXF91nn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeLgbgD74BgbBpoPE2jJuNQN5GqyBYNRev", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVPE6V1xpZpVz2Zhu3SNkKf7TgWPAqRo4x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY7NTMeAq2Wt9BZYf4BCwj3eJG5aMYADRu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVj9GHBnU1T9yseTTR3j4PST8aaLGNPpm9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVVvANL8ML3RaMbF34aoxL3z1bSoznTSC5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfxrJXCBbnvGqCSztwDzrNzDaBYQK8Lejr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZKQViyTqY2D9zQN1k6pwmKaKE1ooaf4UZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSv8WNg5HwfU68NcGyMEJ3G9pQLGVpHwFk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR6QPuyzBtPFB66SheLhiUp8sqgyvrXoVs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNPSKjSd8BdKV9y8w3CuU8str4t6AtS2aA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT2GRgCRBJTBCWVsoxax2kNFi4eGq8DZ7Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfNphDAZBZPDmtakni5PThJxdbi3xufDr2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZoLPzXLePhs7VcLMGRZ9qJxCb9rzqCJmK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiDq33pUvSHi2pEZ3cGEPVtiw1i6FzV9ai", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUNGLbMWTQELBUQN4XUkNtZQehvyZaDmAC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZtiZnjjATzg8dEoAikrbQfdjhgGcCTxsT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgAt6xyNojsoDpJvcsUPkdmpz5TDp7gZh3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYwUGeqXJZDrtMh6QyUT4SubuPqm3nXkYe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYdqgA8uYhcec88NxVr7wg3WReUQqGVHzn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhpbiBSUcTUu2Ex5pTyTS3SodSyfKmtzyx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiTpmEJEstonzSsvuCvkmBQpf7jaNuAuq8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSTUkD8xB9rkYNzAhZFdSAxan5Y5KqirtW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS5q7B665QkuJtJzNnSnPuHTeDxqAPFJzk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfzpWw6tMMWgX76cMZvorPRLPnpxmr1j2X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWQdAWwYPMFCRCAc2bDqjJoRx4crZVn1Vh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QScrEuDdqGHfixHcjyHFkbg5LdeyGexbkS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSfDW3KC9P5KQxBRYf4gjJUXSf1DZQwufm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZLFJLReUT98wGdaieoA8iLSY6e9pDtkuh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRgC1RbtDyvka7UH6RTqSNvJD8vTNkdsNv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfCfuAxSbNeHbF8Y2GNuFmJfmexqVH131K", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQDfTzNLz8NwmPJ1PTiL7zAtWdz7o3LQX7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdmfM8nzDfi6U22ze6kaEceED2sb2yYW4x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYMpwvQHyny3zKM68SKFUPssSkoNwC5vZt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPbsQYN1rpJwV1GbPNJBUkCyx2YWPuLJZd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSofobEjrtD2KntRYg5PLdFDdGuf3mdAyc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbShL174ecJPLU8nRSjtMwbrudCjzPRqFe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgMCRwAr5JoZvth1ESUo5n3Z9ycrfhCofo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMyhFtK7iNHUe98nzEXdkN6toAa2RttST5", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh7LXNX79eJoFSUtdppQtAt7Si1R1wbaJX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWvVuQKy9165r1osQM98eUnAhfe2HiFEmN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbUzPNzbB1McDTWBDJdhpFsVUQhi1hP9NQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QggkvYpWRuqPjcMLLGG1R9ZXJAoE83xj2U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXS1p29dEQV1JtHj1Mv55SEWfDuHe47AX6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUfTY7fh8we4nYPVAXL2jsXSm3hRLGL3uc", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc6HfpXNWjeWQ1JsXRZScit9neymb3tsBV", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe3LcdQpecf8jMiYdMcs9pG7yQiaL4v3dK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiJC1E8sA1RVTuRXBFgqzY2zmfJ2eXMgtv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeFinpTR23Ryh8Xh2qeX9kHnezQniEx2JT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QevoCTEHo3PWAKMKgwjv2ziYdeWDJLXsUk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNsPF3iZ6RExncd7NCWHzAuofRD56nhP1J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQuFcmg7wsdHpEZjTXpbJAEmCxJaZpScfi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcgryWoPDdmNbJ7XXnFbhmXVpNopio26VQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjuVB3x6CcH8k6aoQfckdTHP2thnEkeeLM", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe11ExJhNtsH55zAwEuE7RuHBdWhKNHVX6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb9d7XrcJEB94Lthk1mzTfm7gMt7XjVCPr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfA2r9SJogxx5h4Do1rMSEQuJCkeMhL37n", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUVbFEYn4SUz5eAdum1NHL9i3CvBkvdcpM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRDKcbobLECBD7yKCfzcaBAHM3DScRpccL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSVTLdnvnJiF9r5P9aEYFobjj9Urv48iyJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhb9upVqQLzJfWGzALSVAZNwk7nnkGqctC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPMs22gnWYuCxeq133aQ8hezvfo2ukZjBV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYfxnLX6sxv2nKaemdR3UG7AFMfwSpWktA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeSb4PYhWYzrfvDF47EL8fEQ2tj89hszet", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiGDtjHbYSvCutyDP4FwB65AaMys26bgk6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMefivZuDRcohdW6fKbMUYozLpG3Q5Q6LM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMGfUDRXUU2ZFaZDkFgRvCADqf572WvXEU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTyeCwFefj24wSMwipWdcDNZonbCmUEExb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSfYaHUJcrSFy6DUF4TTdhdxw48A9mRE6m", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTXbWbc63NFBBU6uT3f95htmVE5tamM5GN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QW5yn7VbKkRLm5Aaowv3aKCja8VqqiGyCX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWPigqprwrci7LCZjuoXWkVnd195gQyBYS", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa6pGoGsm6zEYLaBjV85Nhd6p7aMbCUy9A", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNNrN2VtgfdGKSQJq3Z8AXuA9iMPddif3H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QW9HHiZhURbqJVpjuwraujZPoDCsMPdjYS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRob3sEQHX7PNW9tJEd2iaXc2LuT8MFPhe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgUCStZkxC1b8AbTSDcEMTNj2txDKedN9z", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRJceDy9e5NEGKPZ3aEsKfQfpP5e97hvkE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZc9DBWrEADDnrnTV2DzGJvMJydgteH2YS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbgQ7mZH6JqNXno8rL89LqMoTsE7N3QKQa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ1TnT3hF7MHhSCWLSJ8TeZXFMDZD4FY7b", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYCyEsBMT6o53RTuFtmPUTJYDFsCEQbxAZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeYkt8Kc9zXS5s1FHGkW8iqZowABUJhgEd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSXhmKQBB33AoZvw3K8bzQeomcpDTSV8be", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVDMMeYvQxmHeTe8Nw2of4Z6AUm86Eyn3X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMrMeYPa1FPzQbH7F4hpsAxXGMi1cqhVwU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP3Vfwt5qAUW4JxBtCRbyY3qAYraLrJFcN", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc1pJ7rYLbUhTZXvdSvnD6JiKXrHHMSGa7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNU2RHKDRs5MVueLfZ5DyZQz2V89v197Cw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT5rNcBcKR6uHxXkwntscbxHuUpSqAkJ2Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTrJVPcMcisEfBBPqEiwy4UXHdQWWG59yo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMqVRK51WYgwCAXXHsVBw7zWom8LngFt5w", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfge3zy6Q1FeqKQfBB1ALqFqZfgZyWJ2Mz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTirUpjh93fmAjZa4Ax8PxwuTxAj5uWgug", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSwkodPybgHBaerTABByNBRnBeWT7oxUgD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaLM4cLhjYtex3JUXPzevefKhruWhL2AFU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUSryCrEDXRwv5iKZPDdhufa9WSP7NRr2J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbzqqkeRZtFDp1UCtsXByvkpWTVtShP8nn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfcpK6LtUNCdTjxkh3b2JLU5HWGim9utF3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiWFSfVYCBdTJLDNDnZSHwqKf7Wrymw4y1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfgvJceEk3UMkQeFc5h7n2V2zhNuanGqC3", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc2gx4tsFiJSea3jYUfrGyQJWkpZfZ3FfX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjogZjZwQHrXeDsguP1AMW8o6ehcYNX1h1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ8i9kKbWni9L1ZQf37vjfL9wdRqQYMjt4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRyMhM2WPk2yg8GDRCHzGZzgqK6a3QXUtA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRLvMoLehvw9gK7w4HW6nUn7EGk1F83Ekv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVqvXtRsofKyXjwXieiqEpwRrN3cykue2z", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjr86JYPa2ge6eRxvCbuorhQ7Qvf3T7fve", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfdrQRjpYvMo5FgctABoBZA1accY9GpnGo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMJnn9QY3ZwuGesxrwjQu5CdoirQ634HmM", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd5aSucZtsGUpkk1A4nk6VHKHLN7SQ6bsM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNmLqbetUxdMgzvMBr5fFgVuxrMuKvdRca", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTNphesPV41FeTqzBpR7qQgz1k6WjVLkfq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMkq9TSQbn6Tbf1dyUMmuZE7Dgk9EKi638", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeBSM5kEQdcVfA5xB2wyWX7sJiHhm1eQxj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUUFvC2LtMGMoDoQmBjG1fVGhfauFQcg3x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWmctuu3wzZ1ySvPANMRHtcR2WqzGDiuLM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMVtRvd3r3hRLSy1xsj8q53kE1PfqyJqJ8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVeC4LsXkUvd67okfGXdYXHsaq91TEMzda", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQXPGZnC3BPZ5ApnQvfTZfXYaXsZZNzzxV", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa9SdiUgkGU8xxCLYF9W6D4XtWpagRk2p4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVsJzxKLR3StSb55GQEBKRLDUhWQvdu4mW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgoqYLgYjAg9Sovw3UrwNZr1uYLbdZBKjo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN8LhGeJiDjidBNUwrRjyXrZW282RDin9J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhR3ygFfHKr4MyUj2b5bBkowgCND8RqMJ9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiQwmW6cybhHYSrDfM2DYyJeCQJMJ7dzG9", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg3sfP7bu2StVvDxELCZyEFMcCZ19pwSnp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXzGnAFwuwN3uqztJ1ARPk8AkSCRKWddrY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeLPwH4xD5CRx5wMJ3zU52P1yPw35GL95v", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdgbvSACGz5uWTjMBcC5MBMRi6gAU4xBg7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQgyrjSUxb1gGoG6qiteuuqfRTPVQxHw4q", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbc1f6SL5AdKmg2xxTcuswEe7FP8Kv241g", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZMqoWSLEtTz3rDAiuPigkgpdwbGqFeA4Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXkrrTfHCdqhHodX5ZYmR4pZ99bykeFqKe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjFKeSTEdusbqF2S4xFQURKsHMy6m6QjbK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY9U7czTSvqgi77fRhuuwmVrWBZYxCqzQ2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXAyhHovPqEDmdUgRtjnrC6UZMVWE9P9qS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUJxJyYzxgukWBNc4Aghs67DaWoN5UFFn5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdpYgDsun4TwoNjz1ZDsyed7GEGXchNw8f", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZeYrnArdbNUW8bgLxaJuWRyXRmrueor9a", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYYocaRuwxoZzv1JWr8egZkGZVgNAkd7o5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNT3JwAF2cQ3CUCfX52x4WFGgksH4731wA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLsdr3KVacCYGuufGkyNerzHgCyNS9EBiw", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj3mCSgWqMECNaSWUDnXbz3a6sQ5SRdXb9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYjxtRJXiDHRaP3urEd8MX5nUV9fbgb8Gq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjgNuzEGvBEotvHo7xynD3h31mntp7PnSs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ6Ur9DWGZVzzppkWcZupGAbU6jND8mN2A", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZsDt43LLsYoif7KSHmyUXcUxhWgQfz51E", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qezvnvta62kW8ZNdiio3h3Eded7sDG89ao", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcBuTqxNmsg3QotEnW8ZCf1EyWHwqBc3w5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT94EE7rzSgazh15xpzhjhuqKFE88cHHgY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcBqZ4ozxs6JPcvCT3beYzki5Na8pwiEPt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZo9xY1NqYwr8XxoiNBVHicHsQDRPDvanM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU6DVbLkztW8oS1Q17j8QEcxisSbxnTZzf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgqdeTtYTKnLAoCH5x3mh8EL4bRixSAoB5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhyRtUUohmkbDzSjZw422cLeXBUBK1Rygw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QecZdcfkyFbKqTXGn8i5s1iG7Rfz6mAtAS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeVSYP9juB5gfwL9QMz3NgYgNj1FLJ9u2x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXVoRnk8DKFU6AjqPAcx3RwDnzDnknxwf5", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qcc3iyh3ektfySjbxgJbQ2g457k7KdF2hH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbLxPwNiMmdaRPYywjuMeu98RDAYaZPXQp", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi2DAZBWbia4KeE52Qt1PVvzuSEAHQAmyh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgFyAZ2mUp1879ZNpKb8zHFCsYDnHhVCmR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QawWJdQGTNHk9VQUwF617GRCBpk2zL3Q7m", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiYaG6TMPjtqQwFz1KeWp2ZX86JCJtaDcp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNvmjT2ZpBSL66SqSEUPPmPK7pddcxauub", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMJshnWZZsr7NRTuJuwHY24UKMHkGorRqU", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgu8a7dGNaMLudiF7LAKGA33BSzEa3Jdwm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbrtqmUoEDdLiwnCWtvNwXaccaSpCKo8uS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQPwgGCF3Bp28VBiDWFku42wDYpf1sMxQe", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd2iY8utUL8wcshE5MCfBR9SVBmKbyHU4F", "amount": 10 }, + { "type": "GENESIS", "recipient": "QamEfYzNmdo1BEzSbfQSqqSrHbA9AJCaeW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcQ6gNFN3b8uHEhCuG9sSgk9LeXjaHKF8f", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNtqkq5KtkKKF1jYQ3GaNFHALANh1gZ1Qt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaWzzJ5XGtKefyCvZ4wCMW56JnJpL8XWYs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfeFfrbAL1pxC5jZSUum1BYnbToo4u5EhW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaRd3tTjcroAPYXvYR8zmojcXPHL9DZxd5", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qeq1BV4i6gN69DmQ9AgkaPmizo17YuGKA6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhcc4R9wJ6mbxB8jCgA7gxsonqGaex7hq1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfSa6ivpmWjcTZKw5Mz7sLKX4S6NgPFrFU", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe3LntnKWfJLkkVcJRqkRqSzqjcJZLrCoa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS5P7zuFKFineYRY4Wej2USv2A38GVDbZv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbB1tCEKriy5wRnEVetWZmByjYLUyFkg4g", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXBXg6c1jNYZ9PeAKGLsBiuMY9MVyYVNgz", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfp2xKR2hiWS29oy8GYJgRANCQyHsSzXMf", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj8yVKdxvUxBe4E9TvvKcjZ2UxUpa68ZP1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZTGHpZ5cyqGBBpiHMTPSGngmqgmh5LB2b", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR5SGcLtFAxk6mAQZiAMMRUyZLovDaQnQf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcTPM3qZFXsArex2Tcjq8KzJmZeTL6LG6A", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi5o9RHLN8menSyT9ATAv3A8ge3vu94KGM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNNRCNBotwc4Z4dyYTwhdCz28EBPHUqgng", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ5xduv5rwt4f54jicU5KB6TkNZQJZDgRp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQrvmCEHwQR5dzvLTxy8edXBzHJ9Uwde1W", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbi3FU8dMLEZHJT7DdZWu5rpXnWT2GTGF9", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi5PGoa9H5zBmfva62SgbyJ5bo2qYo2uKG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUpM5bugMgiZ4AqDiT4aiy6mLJQ7Y9GeRU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQJJE79CuahqQHSJ4xVVcxANHfE1YHMUoi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaddFd123JhgyyZo4SqDzRxkD4v7wyfDxu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbuBHgF86E1WHKtiGswiGpWZxtFRg7L7z4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdDqQ6rBJLrW1DhPuQnw2Nh2pLbHoXB38k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNtb3aiA92Gd9egNvhK7a7uwZY1tDHVSCy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPV8fxpCPPqv972Pn77hR735rQ1h6dzAue", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb2ampydMe4iTvTfh7jtuUbcAuH1xJUpHm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWEGkXDJvwyjHppad4JVvCa6jvttn7aPJN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPHTv62t8XcdkRjnzU6qmN3yqi95o4F4An", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY5NwDSwvBFNhu7M2WxUDvyvDPmExQXryz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiA6aEE1mq9PPkNTAU55crqkuHycdS1Kf3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeabFZdH5srqgfjN9rACGbqkSLdnPHc5Ym", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhKHv48KUL4spnjx8JppAdraah368VHa3D", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRC7iB3Ce2vwSfFexT2gipP5VfFBkzYG3K", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaHF7gJzo1i4yqFqp85QxoQ7WGRuzhm9kL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNxE3CV5AMfqgpKUrLWPYkVjWeJj8FGvZL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbBvBr2gheZkKiR1nJNfzhA17rnFpPeiXr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUe1jYckxbSTYddnQDqa93xJh1Q13pbgwi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaKWS4aJHWPee1mGLK4NKfYsHoLym4qcT3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWU1HEMTbvMKMgjVmRN91ooaAi2TX45XzQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQxVZ98CxWA79KWer8tBtgbbZ5vbdRfTuu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNDHBHKpVz4Lr3EBDkSJ4ZoiSxG34VjTMH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY4E9pEXcEFH3Eh8KL4vuXZZEQMsCRjJLw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSdyWsbqYkFupwWdxt9AbiQoP4cq9ymPgX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhR7nJGFMV9bhj34Ldb9SYiTLMiJWnA2N2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVCFSYhWMLTCmPj6mDnLq8JQ9fDTaPDCDY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVp2aMAFAjcFQe7Mev2XrxsTCYUcbGfsZx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTred1oVKR9QSeuzZ6BudnkK4EUsojwHsb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS51FHhmDJHrJ5jxDVTPXbxe27hoU3aJ7k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QerB77uXd93h64KuMXT1TGuDinYGyBz7Vp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUBx7ioCLLbFuMdYCtxmxLpiG4EoBCtxDF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYexbcwSivr8tvr8K7P5vkWV6wU2Up211G", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVY6jD33ykTCVLjwaL3bnuUupzSVKnLVyc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTDSpCk1BwrfUhFrnJb5jo4u5ce9mYrQtq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWKFaTrMDsBrWB2fbD2GZ5j2y8mt9ofmqN", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj4Y13T7YnRRnZoDEQcSvPHgDz6dHPFzUH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMr2cySkP9ACj9T3pzhSZkPsCeasiLTuuF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPocRpr4MzdpHRfjXDdp1PAbjDVBKMCEax", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP48VJk4UK3XSafgV6b3dLmsJfnDvNX5pD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS8SYFNeDzyiNRL4tJBLQauGMBXkATgFHE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdfAyJ2fGxnzmyXR3J5ekG1LbjD2nhUJZf", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qah57SitxbUZDeAiCFj26k4hvNFjX5cQSJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfRZsM88kdbi8a26SmrZdusR4pVTCLCHmd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgtS5U8K89Ax2mmc2JKCWBHQNVZ7tLwCJn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVgD467m4gCe8y25X14xsMchFvzbeNMay3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWScc4dvcbgPmAQSAUZsfpqCRPE3nivGsU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUBhMg3yy4FtiW6h5136CfQqqHDxr3SUtg", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj54QTsNtZ2HtTt7tPaKMVZdSQtfdNbrGL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVX7DzY5oCJydHWdmEuZMpuAFLpVYwmHzK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLi4GUiww4bKQH6ouEFFEmyHMXgPDvtko1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiEUfeoo8eAKUgFad1qsMziJWw6ZenUxMd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMGeYbe4aXs6CTnstGib3zZd7k6UvTvZsr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWeYhaXNW94WAY1YPm83pXaZfak46AWaKe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZMUskZQiycMLrcCmRAE1xDDLCyTCAVZrf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfVRtTXq5ft8L8CA6XpUKYK7v1Zea8WJvi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeWSpRvWQ5fW4Deac9fhy2KogSYJzrFyKf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTnFhyTywaTZcxQsHuKYXoT4x5DMJ6zM7u", "amount": 10 }, + { "type": "GENESIS", "recipient": "QepQfSL7yZQAKFxsbpnqxiWc12FnrC7jtv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNSGkxLdJwqztSbHRP1a9FV1o48YkYAgGy", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgnt93E7MQRUmisXR8anK81D9SdmCxBVob", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbfLajkHMLZxNTcK2p5B5AKJhVbWSYohog", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qce4cfhZcTbV6FyAfGfzwpP58qpeDF1Cci", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ8wsBG98Q7HxCwvCUUVfdXo9CX3PcEpTX", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa6dNeXGkMdooTd8SxFicZYxbxPGCwLx8s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMU5izcUpRNk8CRzy7VL6CuP1DS4XYnNeP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QibWhLgP23xahRe4cDQ8JmdSavEA2RAbH9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWn5gL9aBWNArVF4e4MRgP8YkUKee39W2y", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfgiHy7s2jPJFe6zvHQTcZWwV8ojLyKvrs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQvXhppxwTDQrhs58Gb51BM3aUrFevPH5j", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbNB37Qtoh2i8Pj6MtzGANVUZerzG3Zb2N", "amount": 10 }, + { "type": "GENESIS", "recipient": "QabS4XAyJpXzPHZyiuUhurnpuHZpACNuny", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMkuFWrxs2Rhwt9KuhVghX3CAhcSXTmN7W", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSrPzymXJwqDbDpmEi14pRjtrrdehZpGyc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QexNnLLCdxJjci43j1FytfzoaDD5RmvoXE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMf8KFSxAsyTdGrNQnFdXQkE2fcQrrVWQ2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVfMdKX45x5FdnRTdKAURPCkymYJRyJgRX", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdo67QsSrDhf4oL8D5jC2efGgUknAKrEWK", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qcb5niZq3fapBq6YHcSFmpPdK7zKAyVqMo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSwnihvcSfvePUZJUKZPuTr2WQb5BiyW1D", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgjEEYEAPWSwS4jEVZcYdJSvLfwoywCyvZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbyC9ue1BEDbSafF4u9EuhuBvpMvm8rTAq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUKzxTDD9AzthoukedkqSYDEHRTFjRFnfm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhTn6DXvAatHbqcz32NJ6Am8nyNDc2ZMQM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTZHY3vX6aLVGWe8A9QjW3uHMGhd1pMAPa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUmfeSj9Ae9NsESzHKsgyF5i7sw3riWbCJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTc149rVuzoJ2kLLDi6TLQ5QQfU45B4VFk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfX1sfG3Z1ix5mm2mdVDkEr7fTnq5HYRCW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfMeaYEra3ZP4576eWgBYwyHX9gbRcHE8x", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg65672zycKy7Tb5SZYxXPNBvh3vPwdKdy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVF8uodXcVdnvU7DbRg4FJBR6dfYNK1vSa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiA92XRA1Sf28iAsrQvZNsYTDJpUAsyZCc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfEuD1CtcefSu7jMYpwDhZHupBwmhaCTsz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QacH68BmZyMkB8dufjzTjGWMYkvUHUwFut", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb1KRviQaL1j93c7CWb36KS6pvfQdUSLzk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbUuHMMxwbbnRZZCNRTcSK4gZ5fSha55Ed", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdGQppMtF7LKj5uNBCQU5LQzvwiwXeP9Uy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVt2cpiE8HLsofz1iEyFcrJg9g7MbGQZTk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPc8yZZztKDmF8SCKBKHcMVEXqyWypmaoU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbjPhMwdXdkFY1sPrE7jMWeWBcSRTZweN6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgc3Q1ZRWc1LKX51GqPtaYzXyLn6SyoobB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS3AQ7DD1RZ6M81XcGdrKibhNgvKFnNwjY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdGfF78c8kwmGFD7DWhtZMGvxG35nT7tZW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVdh3tLLkAZdYKPAMz4CGaSqp7RvRmE1wc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRqjcSqiGWADnD8Z6cF2949PYWwRAsdWd5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiVfxu5mgUkfiUjdwxvnxBJEsZuUaL2nM6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQR6WaNVF8y72Extb6Ndb6bqEDabCUiXs3", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhim4KT9VkcxbE6a61ECZ4nq5dHUtb9okx", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh5GLNHyNt9Zx4umjoBkbsaPViJ8xKiVDi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV1FEkzd4nsDZPddG3sWBdxWCELMkZ6HFk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfrwyMsGvF9Vo6SMyPyKSuveEFikx5fgvc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ2rRvcqCr6nj5kkxwBDT9ZTfN2akMGv1z", "amount": 10 }, + { "type": "GENESIS", "recipient": "QepQTxuer8cnS69aYf7EDAoWQw6GMPLibW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbdB5TN8P2mDRrBi7kXWu6U8vNkMyh7RJ6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjEsV1pxjcHPNPV8m3oCC163w6t9PZZF6p", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qc7yMoMRrA2fXmQ77JuxSfVdXyfPdcnNwp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUbAAYiv8P1oACxGDp4jGWD66t7siiqTtp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQsYuc5XBWaUsoR2QAVs4AVKBmY9FCSrQ4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXo7EkKEDCE1SeReojKyqVUFVQ1sriN1WH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaAo87qyhKXU26y1YR1FTvrLHv2uav8KsE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNJT34EthEvwgonu2vUVHNmosGRRxZhSHh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhTQDuBcjfnhqHu8mfRJAYn6VyFj7YjrHP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQpoQWccsb6UVWnstdZzyMZZjBuWLxSgaV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWQ6MUMNQKiJFnF2iKsFakeVvH4TBogxki", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRXZA3wdXpu8phg8KJFe7RNQhs8D2P3DLq", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd5WLbqdcUcbi5ZyY1rsDTBpBG7X6YAS9r", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYAk7XFu2bG5cKTVApjey95YRtie4Ed13N", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbPqWcpXcNGuGhYZ2hvLNQ6XhyfudvCbi6", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qajx9YjYHukNF2fxq2UbGniGdpQL6jzy5t", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiPKz6cwzu6HiWU7ayBjPhW9i63f93K2Gk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPmCwzYeToHypmcosysxkSu2hnzEPkZ3Kq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfFs5G1TPzDzsa4UUB5PmypRnEFTyS3674", "amount": 10 }, + { "type": "GENESIS", "recipient": "QT5Te5Ya15tV3vSmdy2pPpZdrnztAAZeUL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeiMYN7pcPJY5GUvZo2tYMHvDvRYx1cNak", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY4NuorvFU9AUhonC5owihgNdRork8oo1E", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSDWB7bKAoH5sHRVsUNmTPe9xDkvX2phom", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNBQQ29UH2SA97MLDdnTy7ExxZxLLpfZwU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhP12VMSCpC4PcV55Fx4aFfT2c6RSsMs42", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgS64v2deiY1Z1AiLkrRxQKzJSMCNXVgrD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ2vjwzV4Y5JCGzkwJPDgWysNMB6rFVgrK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWmWNDLYdKkDwy5kRbyRe654wksS8r2nUX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcwaxY5RRmoa3fSntzJXZLrwLmfrjqtFNu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaoJPWKxmGRq4rWNfo4232yVX5WPBuoKqC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSJHRs8N3dbPwYbhbj1L8jFWBzrq7L3duY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWRzfuzym3kfZzuoA5ASpnEvmgeHE18hF6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQUUAxnYkmMPs9WWQcgjwUMVPGpKnQPeYc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYqZN2qwT4zfE8XkAfTnvpQV4ws3JbCMxU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSx29CwBhQsJbQ9hVQoAFEXQR2VYz7KjMK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRnJfCXxGrdEUDVdHDCw9DDV3gKgRu5vVQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgWKwKe9mZLgTu2NeyeDsfuPVE9Ku4Zt2s", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qcj8jG5E9KtEYK12hVmWdo6cdUKven9z7f", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUMJtgeL4xEBWBT4NZdjqvMWGyfdagQ2pB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdPxohH7LJTdUSXXnTb99qhuMSqJFCxc3s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbW5AkBDfr1cLZHtMFANoMKB9ta86CAYD1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPgKTyPyj8DMv2nLZumJYYYwSD7iF3Lw3U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLzWPFyLvezHjzdwnNR5n1jUHpHjdjQ3R7", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgcf647FFAFZ1JP7bEv4sa5rw4qr54uTQW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjuQLSjRyDTVhDgMxzUjLJFbnYdUeXyH23", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbwcDH8PDbr5Kyr5jwBZ9Ys7hzg1A5QpMA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQfhecXaev1FYq2UgMhpzZa4oayc9k1nnQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfpiA1rowLYMDVPf6oe7E9R7WNGQwAKfir", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQCcSHJspqeYhfxbK8UH1UhjHeGzmnQEHQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPUtaNb6ANbWHLJCGMs1o74yeb6pYmHNcG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYrLYW646AtMjd6Nn3e4qzeKkMCzhtBkG9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNfyujwtGFucVnjkaDEhdRprnixYfV2wz8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSFxD72vCMra8P9ohh895NuuPHvof9b7qc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbVAUHJsqRY9JNn9aBV8VEowQQ2BbP9uyv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXtM9SWWqqJGS36qDq6S4MnMt3dnUb4kNp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQCr9Aj6XtEVQXbz4D9fHSDDwn8ANbQd7B", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSqyNKEktA4iXb7cWWTSUMkkc58vCiCJCH", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qeek2544Smo4zkMHvbQ2tVJhKv1gDAp1if", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQUm5WQs1jzN19X7Ls9NY5q9G1BmtbKK3U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QenuGzurgCPaeh9xDxRwoPRjNivgX6h68s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QihN1use3mN5BshhSrSS3hF1iMmwPFcdog", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbu1Si8WLZeXpwiHXzPsSkdBMDV1BFLkEU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRkubxXBe8ABtsWFpdB498EhBy16FNiPMo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVa9VbF2aGbXNh3LfNxnFJ9p8cqSmFnymi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYqQHwpJMPR8aa4PWKEXxmc8uB2AybcRt5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPHyxSLBx92izt17oifcsBqh2WYDTWcgpo", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg3QXvRPXayBGqsGzfvS1a1Eh1WDHowBLM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjJu5DiC3xVkFca2wznFCei4HBbvCRPoJS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbcDt7uDJok9ka4FaVtXaT7LYR1sQMENyL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPR3NwdDuZuZGXW1UZjoZhHKWreLw7iZVi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaoWao3UJjZpwbwj6YgrdWgS1dDvR2vEFK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVX7YLBES6rJGtTeLespEspCxi9oDYxGQ4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRpsUCr13shNTDo78B3r4UthkXa3E5FgFr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QREUmhn4Pty6mjnJxJH7RxnrwN1RvbD7fe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPdzGiWdyHjbBhtCMvpd6QacfozzMPpfNa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMriaRPNU6RZJmipSuZcRi1WVj63wb6GrL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiW6Kd22LCJjCpp5EBotFDeCKjCC7t8vSY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN5GSskzBjKQ7ZnwMMqgko1M3KWKCVqwh8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWwra8uA9M4pvabK41561mgFd2o79thQdT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcQSX8HPRpNDGgQH41U1QV5FJ1TgK9q5Fr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcTrX5Qzbe2djro29T3wKDq9MA8m86HqUH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWpLWkuZ2pMiiPXRM4jupQe3vBp7GiRtvA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdLHqfBmfKG7mnXCUALfgQvKW4S5igrDSV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTBiyoSuy3ZF4yLJzjqUY2imVPsUULFbG1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTEFHfLoXohdbT1FFdHVJeEL22qmMypTJ7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdRwRvbTfT43sHjyG7q4f38PaGvwiDyrWj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QijECkb9URmgXD1oAtvYEe59dPmU4A4fHP", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qeh73yn3ngvB5yX3beKJArFuCJks5i5r7B", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNWzHrRGoZtYhEUznjdxCmMi22LqGa1ndN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgNjGNiAKViM1Mzd9pGHieNJk6CRSFrZKt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVrGMZ8NBLEvEcWXfKkCWXDvUCWF4z4yXC", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe3camytysJJ2BnfqGmw7BUegZXJTvkeeJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfVUkFvVNPxQCKgRFWzFQVk6oFCbuyzyRW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP86kJ98hxBi6rzAJFkoCuwkQXh3DvAGcw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXxMKghXxEKx8RopT2rdiCrBFvoyN1mZMS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdtUw5FRmaKAfJ1Ttu4bUagfX13cTHCpFw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXuHgMUN9FWFVFjbquFqkDw5NKRToVd2t8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjSZxZZJ2MRB3118i1VmSuzamBJNCnUFaR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUWURY2qbSM29to4uuZ1CQXh2VgWp5AJsT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXbg7o4ufCFjeA5uSWDSMB28vAc9XeRSH3", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdmf9fZDUFWxjXZU2hrhTZmKiiMuy6AEyC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWJgQDygXYBXuweFXPTzte1eDMg3CnRUxS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgZJ17ZXdrJEqcAPM4Bnj3NJimpCupDs2x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiZRd7wFASi2jaQfiWSMFS8Qcfrp3MCDxo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZuxbXpcWbPNHoU4yEps383E4rTKkkTdBH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUigfC2QABH3RMuStStggx9YiZ49VdtWTw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVQepwPCzSosioi85mzfCxVMPR3f8mGBjP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWBTMtHSCRrHTabkzf38tqhe5xSB8wtTN4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRY5mhkq1fV9MZ8rtrR1j3MnCidqfstKCX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTnnKbBSqDGHKiD1Qo7yb8ry33mzxZDs4E", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTtyKRb2fSeuhH44cvenzahXSKWiGfV5K5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSbg4JJCm9oZkESD9obePZpGK49gWbmGsc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRtARSe5ppL7WpNaMaWeboWbVcL4Ua3nxo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaQs8c2ccbjPGhpAYdaJRLGyBTWTkDKfmh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRVotWtKboC5APg8YxhjdJuV9JioWFydiC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjXB8Mac8ityiVMWHkXPbi7qgKMuCjKdbW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXLtbT3ru6WfPjTMZ35q2f29kuNh5v3X8s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QisDJUYKUnv4sEW3RfVhNywxVWcHFg21Rq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVpbg3oMF4MAUD8QVQbSfK49YfUYAijEPf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVYPrrnPsn3D3AbiXsCk6wb3EERhTQbauT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdjJKpJt95jGgyQK3HR4qTYQuwAYdYTM5X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcUths9zcKzhmWxpQjdoPkf7ZCrvPqqHum", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfErd2q9pvzPGuoH1NRSUgXxZtz2oyWX6C", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg8zWrXTiAk2r1gFLrh8e2vSen7DdbYU6X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNMF4gGKyQxKBHxC9weivsiGwJ8JFAswgi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWNik2tj86KQh5zGCoskz4Rhcd9K1Qv2gL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdxqwnjHC2qy1j11YMZP9KF9dm8AbyGMby", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSVYtX9qPuCxejLZtUxabTJ4urKFMcbwsb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj6ykh2hXy5jiYRgrmt7D4H2KvMX5oPWac", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNyT6h7qK1zS6GeqXfoMJUf15pyush94yg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdnRL1yDRGhoE2695SCLpPdCzzp5xZLMDc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX8FkJYYLTjXSwdJBAwHtTvHiZWCmAVmCY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXqHgue5R4qJNvPEsxZvbYMpsCRmD7YmRf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTUArhyERXNN76q33wdcxJzVxZoo4YUQk8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZHKbDYdSjS6FF3Mz41xowpjHF3fh6BvFb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf73yQjcdH2hFndu5f7xcb6NDt19TP9DoB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMxU2uvzikxgzj53sE7cTe6iri94z4FuXv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX2gQw3y5xuKD3shthn4cQ2mZ8b6XLysjk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNtoFtG7gBXeocAGwDVvC2JX81qs6gSPHU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUHZiok3byKWVjdp1U1LcVhsqcF5ARHT1q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMZ7Vnq9P9TcB6LK4WLZstuf7ozSUBJSTQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPy3imHu9bFEkNPF28vidDQTZtLGdgpWqC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWMoMkXwPv6f5s8PxRfiy6u3nYfVMpyGve", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRYAgyuRWrLVg7VaB87vBVWm7kUyYFJ71w", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX6PwuE3VRToyWd1Y5jiUsByFvppDeha2U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfoPk8G5fiBXJ2S4Yk8PpcktDj8AZnShvT", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdihq99B7ZnAtqru71PAGQhjhjtAJdAX5k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTGMkcxHVmxv1JkDw8DSWtdB19hTJLG7zd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXgeaEWieLL93jvT6QigYr9JGcJdnFXByP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSSKJUY33kdbz1vkiEooiY45VKiZkD2Dka", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg7TbPrg1q2ydVdNLqJoQtC3RLBUo3t2uD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgcVr77TfcVmb9iSgsSRPeQqejqfKAvgQy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYs3NV1EsGc2GaBYC1jPAPBsZRGfYfwopn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjDgJmv1gnz3VSJHziY3quBHE51qEAj9b2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMoUYGYfYXdVUxAGvxkisHfgzwvf1psMLB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNTdKEnSyyUFfp9SPnbUSgbbnHi73LZ4py", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf4NBSfFKmjQBuuL3ti4xUFLt9cutKrHDw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfjrwPMPQTdkc7rdu1qyUGGmy8uyXB5BH3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPxbzCsTxCsafRUWp1oBHfbqRva6sHyxTk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSKnXn18fM83HS4J96BvSHmYi5CfvZYgWn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN2yfuEHpZqZDZREXUUTp7JDzzAnzD26S5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjCL939qc9yuNuP7KvEnpX3Ykj6vWtU5Xi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMkmZBSS6CqiUZrL6HkgL6NeEAbz4VYgKt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVRxJWg5jsbNcuMFSzEbrQ6ZCWL9qqiQBc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSKeBHV5ndQNEwZf7BMT8YMY63wJPXHUDg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM6nHqVPpe9eXvpeshH3fzKS7vok9ykN2c", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ5HVfecMcnxnUbxhNPk1HV2GMUTqUF5uB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNDN31DNB3cu6E6hKT7YQRv5P5wzbvm8gR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaDVaEtetDZk2SQUcEwrv7srTKVi5nKMXk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcnYqcsiJ5bJnyKGMHRQA3LjB8EP6kbRxs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVg5c7fQ7AjQx3Vtf1esfbNeMjuJ7HSxnS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMG7Q4CQfS8uWRFVNZCkMq9EMeKQMyo8hA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWeVdu8Q6UtPCA7oxcw1vN8V4BYJ2UTLuT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZNRfr4Q2M3GCgUiCrffn4rr1fcNLMLuDo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXhvKsfnptbBhkbyThihqZyU9QESPfATbP", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qeho5fUhWEb58qFoZdMB9LggSnbaQh9vRs", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe8DdBX1a6dzMyX6kA7BXHmJz3hPWB1y7X", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSAejnaEvm4pSS8oXEh3b9XYqmKuHhqLVb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhidz4HLjm1kVrLTd8EPyJEELnoFVqnATQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRtmedUHNdfwaNwBWX8tAK88mwTWa2z8Fe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQa4sXFp5jq7ntE25pvz7xVU3rUWs4eiiQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSz9ksffuikfBjBwFRQJW51wQ5CbSc8HUV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVHXvLjCrNXgcRu5nw2KFNsaZ4SUkcod64", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWnXShDuAWiCmmKsLtRUWrbovhoXafU5sP", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj3PMPgJbk5nYi9wZxpyoNwNPaPAjBfKa5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbG8iHnYMCEt5Gv4gbXT2sTiafwnwy6SWh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTkzizg6HmDCNjA2XoUSJK79dgYTAFdNEg", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfmqayQUFTY8YTqrw8odoQ8P3RwyWo777F", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWNkvkThhZXJQ23AidJ26bUQXiNCNfeNMv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWmJ1XpdQSmZLG4vDS8EoB5w6UrwYzUFNC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQT6fRGwyxAS1uuayVJvetBHBhdKpBvgFt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWbqaN8TxsvxDihfkUUBRobujVbxsbzoTA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdtRTY7sfe2xQKR4jFRWMpFyyP4EoPnvDJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgyC28vh1ri8U1UCkjwQuCinjJS6xmLE11", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLnMwxfVJf82MovsG4i5GPvkny5JNBTQup", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYF6MLarj9k1VPKyog2YHDBboeFngmUnTK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeXgozDEv5NhxmNzbV1HEugcceLoye2b2y", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLrGfcLXyTWmA8CPUZkPM3WywzTAHVuz7x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcfiV9f1vUbBLrTRPLJsyhVgKzKT7uuHKi", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qgxgpn2J8c5LzC2aUkPqixnVkRmd4fjBUm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUmr9efCkGUt1qMNer2vt1xtcy8S9wTtAL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QienqdWpCiDvk5q99F8pt1JYTZsSn6qLrD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUMip9ykZ66AP3Gbg8pGP1ewoZwoTZBtba", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgYWsGqKjL7MrJdQmsHXMhtKxJqW6vWyTw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQMiJaCGrw57PsF4hWmqtnbmyWVLPkq1s7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPSfVkCtF3NJYyhPNN8yNAQY8pgRbFirgW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNrY4iAmR4TQtB77hMrM3u2XXYX2st3wxD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgCQoTy5Y5RNrBjeycXthX7t5HX7oEzz19", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiczLg5bJZsut7zqwka8E7y9Hi6qPh4Jqv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQW5QPRbWBFQpdPa9x9x8AxejhgSTUGTwJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTEDEPGWU1pED5VMo6dPYrN9a7CQe1zWtS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZDN32a1tDV1mZ2jMZekaHiQq8QTfoaJ6a", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRBJVtRZGb99SosM9y5YJ7ogsMdVxXdPu9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QifaDahrcETU3Jc5HEQJVUQdSXVvRYXUTi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaXsvTkVCcfXYBod3LLnT4yBbVyxAcSK6V", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJq4Q8ie25z7QdfzeXoSJYqkG4pYQDU6J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdUDcf7Ey61TxGtfdW8BLTZjBJ7zKGgk9s", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgwhdGRUuSKm4xqpT61xB5iiP29wKFkTXr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSftyUsD8B3F5nkW2YjEikmcUvLoGHjUL1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP7NckFrHLgGKbM8aNYwbGCk4YjsmgeKT2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYwvyiFoaoK74dw54N2xt7UmWH7hwUeCzb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdssSgnCVg8M66bacZqFaYCDXRGCpb3ze9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQTrDiZhETEoAimcJFfFT63rzqBy6RNA34", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfi42mfbpxRE2KnqH4TGQzX5dEuSGaTABT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRYgWAEXBJ21AN8ncvWYN1NhQm4iQV1n6m", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYu45h5kp5TAx5R53mMk7XUE1YgkEym2H8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVX3JsK9vcyLBLjoWY4WwDLF3MoL3tSDMk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMHcurZGnyzPAmdNurcacm1GNCUHRRZ8jf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVkpbCGEwfdkEjkkXPjZJGGqPG4F5YxoD7", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj3Fdad5SPJuMFFAmndBRe9AGumWxvJmZr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRJjhxgFEepMD1Mb3Bzgmd1t2WuSRKxrge", "amount": 10 }, + { "type": "GENESIS", "recipient": "QifSzfbmba5KHi2HyUwdm5C9evXqXENgZk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZbs49mBzkHtoKouBUAD9atYUz6RBH9pQ2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcBREkh5tkffuFLT78SLPJZCzVWXhnweoR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbPcRUYCLY1WssipaRygKeEBm1LHBPZnuR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfypvmTWiTHo2GgpBA9CrGvr9ke5Pi1dvG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUEviSVKeHCwfBm2cpVHzx5aV4uETjARNh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdhGpQJJDuVbZrVdNUS2ec3gNq2D4Tu9pF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZEKMgog8epbcSKGaH3stvFX6mc6EH611J", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qiq3xFZgSv8hiTmMs2inxf5T5tDfarPU4x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZSS1LoXwHpPNzgW6schoQgUNoKoCA3iVF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXaaXiBDAiL5nwVsPhGwabjoEaV11q3DzG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSUBoFU5hhcHduACBiz7kD4UAf8jo8zsTb", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qani4X4UGeXamvzHc8X4RXzA8jWSmH8cAW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSTzv5G8YEhtHpGoUDNFVW9LMNke77kLye", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNneGgUVdTAMkQ9hoY1XbezGZ4joa3Thnp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcNxuwspRac1sGRjotUTZrsNAX5rYr9fM1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTuZoN8Rcm4pLUNP6HXR1t3tU9Z4Jwiu2T", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdvieSs2Lnr8j76TMaZVwiN26kTzsF7mFD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdSppgA8ZA4ojEPdNNj9akBbgDPvnTQH8A", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgSLWX4QEgujL8vB1btx2feZa7Nyueyv8k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSoV8SFqxoEweZ1rJSsWtM5wJJnrbT2LGH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPpr5vJcjoJY8f7Wv4wrQMAyfPx4eB9Kk6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjHyEgbcJaYmmABWCMTcDiQAHsmYZ2ZkMQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QadBi4yLoKjC6XHKGmrVJsVZReR7PzHgmo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcAkNCK6bF9sjZYsroSAwRRygVrq5Lqjbg", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb2vVR5Jq6AmCDXhZutKdLU6fKi8weGPzT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcgtjvYfx4BnV1mmA5FXWtXavXWkqJaYXo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTK2wbbs3LzideTS2UpXLwKduVaFg3aZ6h", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhRPSTf9z5nELnH5otVyyRN2iHLJGM5gxH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPqdmoZnmPKCwugnbtURKgVMv4LCN51Mra", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhz2Enzj62kVerEnscLa1oCmJXYRk1b9rY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY37v4nnj2JdnwxvZRyQKok89PkXNy2DRG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXZrPLnA2yjCrbFkk1TJ4rGVunXDTcUCiH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWae72VAzeus4aVbUYJmtqgPhAYvEWrrAD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRzCnx48eJtqm6gUKhdSEZPVE4PoD9cezH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYFZoo4jcSDgrPxQ7rc8FhPb8fcNgBvrhu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZbh5t9UiQGeR12cTMaEreo8pBQCEUodwm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPiMqnsBRRwGQFJgzNK51siFqUGppfH1cY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QURar6EGNcaXr6TZf2X3gHCMkGBhGQLBZN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgHdmfvGfiGx5kSn3GdRn72pWafGey6Jia", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbRktBVQovHF9Cc59M98VedTAFwgqg3jHJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU8mm9JDdtgHpwWEA6Snou1qvBgwVqQjio", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRPVT5h6VuNWyXWhtL1nMMTi7bGmw3yMDX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVbmnrrGqe9RjwX4EHU7w17AY2mUrJ9y37", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgBLAB1HtEU8nuuSEso6413ir8bv7y9NNY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV7LARZvy2Psz5kLfsD52uEeQwHuM4VYtn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbEJfhEUV4nqBeGsDUYiiJyHW2a7LHzX1q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLeJ8R9FKeyhVivaLuvTt3vcszKDxEBWXk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWazYjG28fWUvGCoxvVCwhz47hty7VgHdt", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP15D6KGREk1eGZ8Pjb3LP8jw9oaywHRCH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSVQDZsQSvsd3BFiAS45WjA6gquH7mKp3s", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qee9GheZLwpyYPEbGci65Cu9ywmfUpiUtA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVYGSLnspzWNtGCDBxtF26JMY1PRR9E8Mr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjQCea9aLKuXQNH6iqADfC7yngwVTdA5A2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXZJY8MgKXviC4xeuMoZ6zaYSm7dJqZYaA", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfv2jWMD3EC6sAGxKx8hRBSQVAt4YmtTvX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSVzQVQCzL3AC8bAHGkbTCiy3xgeWGfsfR", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd31D4nhiCMnPFHoKdjeszqbNP914JZ8ro", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfgfpEia92nL94vxwRiSJp7ee5ZophKhJ9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQdJscTibkMvWkbZitYzrWLtnTxhgt7K7U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR5WMxJWgaBPUiDhyYvbgYfiNXMjvqg6UA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJKN4HYcMBw1BCxEPB79peKqyBE2o8pfz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQBRiZmS55ZEJNPj1VQBCQC7FVvafVdRBF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVyrsn9hn3evAQFm8ECjhRYeAqhDZgwiz7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaYtyv8etaQF7gQP2YKzeLqKyzrp8jrJpv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QStvmZNCzqNeyfzzeKrq5xQh83P1F6ERpt", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe6XX4Eghqm3psn3jSzwcsJ8N9yaaE6qXJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qhy3Zg5D95QWrprgRyWL1Hta6JmMarP2TN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVG8Qdgn2yBsNRQDV7oW54r1whSEwvUk7M", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVsBXTUPszNJrdaT11rDSRewSdUjMQd5cs", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPmFVFFkB72o8Th9D2wJxgZaz6unt9HwW7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRA1jykbch853CAsXXt9sEGBjsp835v3P2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSXfJsEHYTcwmcsJ9yoekCD4HULQpxeCBd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZhb63HLT4RFyczAXhvviLHvkQUi9q6mTX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUHuwaoxNusHj7ZUyTYjRFP9EETt2hixkV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRyvShLAW3tZEaydKZxLAA7R2GmErJFdn5", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf4FAqg7uo9sDZVwSyRctiGgHNfyr77HGD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcxYc7fFZjMCtE3GAdr6YduTcPzWXux7kV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRwYWNYRYpP42uucjSMiSmrpteCyJuaatT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUw5tEoXvnGKK7bUKye9zGLyuumhJxE8ZY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYw5c1Ufeu3Xs6X4wDtEW3rY6mvJdyiV69", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZfcnG7M1KLuNVHFoAz75Q9axCPeZGvmnK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSWBNaUoWQtkDioGsRQVMevrNjMBNhFA9m", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfmyojs61pnVshq3AMb3SueZQJmZWGS6P5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbakZPnxpoFvUUA2ikFXEMfupnzL2g1Hpe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgYwhNYcrYXEVL5vu6xgtB5egYpspHu8Qr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLtzQ3BiNMDJr6UtibNGKNY5q1Lek22mbw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTxsroYE6NqWJEfCYRTN2kXFpvc1L6dywo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QauYtokfSq8oZC7MQJkkRUCHwV9RCJXfop", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiLH8NVybYU3gfwXqmApeeLoebMmdxsvy2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QboQVDYaeYEWoxxuq638FGFwFZwVbv3wZd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTTMNosEkAGiFXLUVMczcmKA12Uj77Dm3G", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbo2JRDzV3DFruTjznyhzF2arhrKuNuz8C", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdbbfH115EuQzAcEPfV4adEVKEhq2rQE7P", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUQYqyWTkyXvnUxM3caZuEtDEDSbfJYpwd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdwmWF5FRsXNwn9aDh4SKWfhEuamnAXokH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNTqdLMtm1k6Q4iYpnvc5BcH9NBxanMkYC", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qi7J6Wzb6pSgaeyGXYmbNo6a3J7csQncYW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMaJeJ5MmK4UG3Zto9JgLhJ26JaPKSp6ha", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeCmhsUEgUe7ddLqCMHcX2be72BPFkQWd8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWTdjKcTE8DpSopLJsy1H6CsqupZSU3ZGB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeDrfr1wmu7okNMRZmbJ2EwZMFesEv9zMV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QizC1Qtg7UUDu4bDKibiTKEMhmGqP7C1Qb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZPQfSTVKjRYJ3r5P6orJsYaz6ZcG87ktd", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjt3WY2Je4xSBFA97ptGQBZfSpJb4jgxR4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTPUBwrW6aRQLHQpPrw4e2bDXR3gRWq3kF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZLRzY7RZy3h6pE5CAk57RUvR48HH2joWi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNAN8iRgrqKwPqrCojJQjBpEiEov5tirL2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiygUw8uXTLzDFJ2E7HBKHMHqiuFWCa6GB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaZpSiB9Nj8WdbL3MHvUzZBXeFSqEMiD7T", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgaJRHFpD7WQEpPKVkUSNzxae4hSUyeJvh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeN8j74amkd8GFyoRcxBaVrkHns2WxKjmS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLmQGf4imhLzZVAbX97MR9JFZik8JQ48B8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY81xXYuMaewGHHcrYNdxJzhi6dNqu2JRY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiVm5E2rvGb5VVfWTAMA1VUAd34k7Gqr1q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhGHn89LXfEj7y4CjSLtadvn8ezL6cAScQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSCP8xGYg88n963nN9ejiDJeiNggwLRLpE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdzyigNad6fXJxykm46yWRH5tN8uDq4beF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQJQfXRwypwuD6oRHjcdRCKeUhdrCsDs8k", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPCYAstwUstQDESpFbPBB1U28LEoRcPq1J", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU4xvi1HzuxYEqQrxhKYnQ2D8hDmRimE14", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVgLeZ8poUf7CJJ2vUGUEwUiKJ1sgszTzE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcU5kKi3mX1VJzne44LZLpr3htebQtn7xy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdkxvWwbkLDnovvksuNDwyDHP3634CnSCU", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf9XqVqSSKDBG5F6AP7nUv1LpUMU6bmRnK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNfqTKfSU3E8RWoJtJgnKxwJDkzfXb18cW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSpEUGYR2rveDE4ryRPVjizHtRKnMRh5fN", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd1GMBYjor3X8AL1WzHFi7egejb45XtLzY", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbbzcfy5WjRejf5tHJLG14P3uvK23ywozr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWUSHC2Bpdr9PUNB8Hj9S7HF5iagWPWNEa", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcftPPiBPU8Y1XuCzdAH6vduXHR5V7YFhv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR6rz7SHGgDC7QnNzWBCo6idbcxcXiLfBz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhgTuLEhWnxFCqCCADVP7Fh2oXXG9j3Dj9", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTzaaunU9vbdibBHKqg5ZpVH1jcu4rCCm6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNCas5mqJLgeJHB5jQKXuCVBhqPfRHdYMF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhqWZL5643Y5C3RwvBSJpv3W6FA8GbFWJC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaVBebFHbsbhBcmrX9ocbnwEMJgzkHvMZQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qdbj1tS7ZGrNdKXqEKfnt9EnwbCLLzyoCY", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjn22uhkomiP9H95G7XAbEVZFjKiGPfezV", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qj3QnnhKZeUbjUtxGLJ8jQVb7XohfDy1Eq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLqNGEMTT6GGi3dChtp56ocae6XxkakTkf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZHLChVNfNNp5rsZQQKRRxkPGriUAyhK5s", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjrgs36ajjTFgrKUMvsDSW8xiNmDG1L2be", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbriz3o7KWJZCafQCt4ftJAAEh8Pvg8o8v", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgPQSW3FVHbEKf4UBZh1WwVhLo4eTSXa44", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSchsxiHAmhvA59HBy4M9y2JobH7nwi5Xf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYEbJEiXbPjqKFpUWkbXBcFFUU1PbppZnw", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNG95Qwbb5P4DGedLHv2kmYvMjG3GxbCEP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSFj8r42yFpaPQNwKWwSVQKVdozTniCk8G", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM4JVyX65WbLUYTyptAMea2MHsefGvUcR5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRey3hPPGc2ewP1Ztw4SFG1fyU1xiqLjCP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ8cte5J5R21uypoaoCvAALzBkYSePZHDF", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcCRjkP1XeD1dvwU4umQ9cFWv8d3hJqjK6", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUH61i6hsZehnXNJF5VefvLQcRCsNg3NPr", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qbzgu32EF5nPsanMMXXsMNz1rQ4hcmnGZA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSCYGst9SJb4gz2H21Vq3DXquxzY73VWm2", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh9Mx3kaTcWfoYJDgeQuDJ487K8EEwTtDo", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRQ7875Nwp9osH7GScPREnfLPX5RczZZ46", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjBn4bLZEeau7hx3Wae6ZWVtc7yCC79xEU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMG32pXpVopiMA6HLoawMi9x4WmwZBpotK", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa8uuqfKV9yZekwTNU2JWnj8rnZc2RRmco", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhRiACrq2Xgw491jZovDL5UqvmVDGXQfdG", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZXGVbvKEp2G4c3dBHbTxtZKmu1x3k4Urx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QX1oGefhKHCchNSUqycfzPjZp5NnwBoAGK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSw5SHLYZLe5NKT2ebMLAr6BbYJNQ6rWrd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQsUPNpkB2iERBFqVHJomgPvBEookzGgFP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSqw9dxfGhQJTstNgkxJmig9YTxVFaTo3i", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjFpwBMrZKsGDmi8tGgXp1m9P7xxcr758T", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa64xQ1Qqmc13H3W8KB6Z5rRPsoRztKZuM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRpCadQWjcJHeieoUnXTiSpqydRbYcA6qh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiG86w5cMT9iv7pekuRohJmFXUwkvjvMXm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgAUczBPFQz7UpVeukv8tEPGEtu4TkMAgv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRjAxvFYSsXSuwTgDQFAxFo1Vy8ntmij9w", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMc2ogsdyB9HUS6gka1XvJst6iWV6XDd1y", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qjf73NRcLF18taDgZvrDXUNysViHiP81j8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLsnsdVELRJDkr355QCJXzzR29whaxbP3m", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgocHNWrSPTrUGp6oiSg9gwsvAHD8pYVHi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbmbTWgUEH57JXHzdgUAy8H9HZD1Bzu2T5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNXG4iaaPiqd2RLA28FJwCk6csUU7Mh6rZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhsWKPi65qKKLVkn7DgopCn4h3f2W1FMvd", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qe2TzQdF5MGsfFbytqEosFkmWA24i4YaQM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QULHofsgHS3B3whoFNXPHrNMJZDv4YUc96", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTwx66zP3H7PxNJjtX41BZZB6nEEpXGTV8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgjfghHZfQtNjjetUUNC5cHza4JebSAeyB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QitLt9FeT84swMehxuWnLqKrtfhaaW8nzm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdLxR3bofPLP2ZkwHKuhW1KzetRNyFeW35", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSkzC9kNFxZnKFiMaiGsdVoAGKjfBBZwcz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ4z5mEXUDQMqBXGQg5Cp2SvGMDTEZaXLD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJfNxUJvvnX9zRCxAFrMFz1YB1cYShNLv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQnyuF6J5AN7MvxZdxLL4r6qjYFmBpf6qd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJ5tQZCqh8KJmhbWKsx2uwUYXYbjyzUxz", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qb6k1h4VvfoRsQeEnDSvsBe9PfJnsaRcax", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNjTDHtHXdVtfcRf8qTqSZgXLDiFBADBqX", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMnrK51UipWbtiA3ogm24mhe7WRAJ17BmM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNdQuxwAjHcojdxYfkxnnkMNMDZ2Ym6sH2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN7HMxm2qCxHNTei5wmBfxFMr4cbb6xBAF", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qfb3KNCYWsEzj7npPJxiNnQKw97Ly3BpEW", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qg36i5Z5f12EYBR5PUZaf59Ub8KeEkYovh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNy6RzB1xWykv3Yb6uUDQ9VgLTRGoFPLKT", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qf8cCCvv57r14pN4oJFVbLym4WWMERkA8H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV4VZbbdmuYtZKE1LXjQsojbb4nTJSw3wR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QReEeUBRRnsXUd4iMtgHAt6pfHZfVYD66H", "amount": 10 }, + { "type": "GENESIS", "recipient": "QThtnUYKiXtx9ga7LtT9qftafdiVDZs2tQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbADUiFmkLpvyTZ6ug8kkE9j8aDcDm8W7o", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYYpvHzcUP4s9jSZzaNn1mqZDM4u26yAfT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQKCYjczFAKAgjYhRL1jjGcA6khh35Fu9F", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSa3xN2kdAwc6PBQw8UmFpK245cK34Aa8Q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYuLzGnHLSvLtrDndk9GGPQnGU5MW2MtYE", "amount": 10 }, + { "type": "GENESIS", "recipient": "QN7zXUcHfBhn28qopFZ1R7pej4i8ndPibm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QedfstymDM3KpQPuNwARywnTniwFekBD56", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTJMjw4LikMSf9LJ2Sfp6QrDZFVhtRauEj", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRE5pZcGwZ7bSEWh7oXAS9Pb8wxcBLwQdJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaeYRDhR9UagFPGQuhjmahtmmEqj8Fscf3", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUyubWVyz5PLPcvCTxe9YgVfRsPhU5PKwH", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdxGiYrxV4Hr4P3hNT988cCo8CqjyNNtN2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY3jWQe5QbqQSMyLwf8JiMAbY2HRdAUQQ7", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbTS7CNqoqhQW79MwDMZRDKoA4U3XuQedT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY8AQrKf1KE7MCKCWG1Lvh7q2mEWWqjnCh", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZtRisdwd1o2raPA7KhCnF88msVJoyc3uZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdRio67LD8QCPmzXiwimvnNgSuXhdHy6hM", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qd6RrfCKZX3nx8wRCtJ6jJA9VJ4o7quuEe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiUbpcT8Uibzua79RRzqbLqA3MUkWG1Q3W", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXKBimE8Vbat755M9zmcKiiV4gkSLc6vfB", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUC5EMMVav2Qt4TDe9Af39reYoFnamxUkn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjEaoBWyAP4Ff29dGUZtGsYdRvHKf8HKb2", "amount": 10 }, + { "type": "GENESIS", "recipient": "QS11w9zba8LPhicybuvxkTZCmTLMCt3HZ1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QW2LGY6cQwmGycv5ELE23z38WqXFzsuTFx", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcL5p8mwKk9g6xpwCXTiHpJWwRUKqUkgKc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPHLQxDwymECG1deHhhxNkEM8jTH3rfmvA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTR34yKDT59X1YJR4Y4HAnHJXXjwVHi1BM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR9EUCjXzD7hQETjnZrKTsQ9XQAWjZtN3d", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQQUNMU47F6LbMjC7wVhaPgw34ytetgbLT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUuugD6cTY5p7RFGnMrV78dfmEBrAAYx1N", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQktyzttrEtkN1iHQNAR3TfS1T5Xse9REv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPaWULDvmSr1cwhNiYzU59fnZkQmLQafWe", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSvTyX62mGPTGLKA8TvmYcuct878LaLrsp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMKunzBtoEQ2Ab8emix8KCXQR9dcfN7gca", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZezsFhUeN3ayGjJ2QnPJpG8tHqfutnKxq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNBe19N5gNvgK1R4PvaYEinsAGTcbaQiNR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVkLgL3tx7aizRF64PAWnLn6VKTY4jGGXC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRsi8YiWAQKrBVNHyEAdcKy9P82NRK66pu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiktanAj2ACcLhcCLWSAf3oboZdrkvWkcu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXKXL8hen7hM8W1fFHUAfKbPxZqqiimTnD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP2NiMK9iATLo2bNRER3yuEk38VP4SC5jL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNkZHXusJXpreoxyo5ULyvPXZjkxA3UvEw", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qca4vfeoNVbtbHzJa5F3v8sWqZh51Fz5mR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiNYuBjPpwDo3b1iETYPZnZwtfQnpQGouR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QV3sZ8AtdRr8YTEPnmxE9tMt7wxX4ruG8U", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYkPYG6CEwpkxq5s3Sy6PHnn3SDXqvPSvb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhGkgG7EtqwuRYQ599DNn1jMfyzkNeZhKk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdHk2qSAMeiPqYRACaNyy1jpAVYzTLrdyv", "amount": 10 }, + { "type": "GENESIS", "recipient": "QTrtqtdN1Kxiu8YDumczZd4QvyRwAzk7FS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWxcJFLecFjrmejcCToGVRJpXueAZJgiEu", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgU2udWFXJpDatHhmXWqFLxYCkYGSDUGLD", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhT1YEHJpbuFrTqkRCyeAn5QdeJsNzjX6p", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjuZxHoptE264839GsNhjcWgrCAzcbDQQL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QP9m28hRAd3Qz96CvBnwipR8J319b2sjzY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QawVhDFeqEd6aGfgRxHsLN7SnrhGqweSHC", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVwpXi2jMj5aMVjZmXbfDiQwhKY3FJyiPk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZBRV9a8UBbdKaz63JqTnY3w2R62W1phiN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhQDb2NkMXo5TALywQwJm4jp5CxydPsrqf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVjGMUHTBkimNLGuDvctX8VPq1NkMAWJfc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYCd9YsdNpFeabaQVzoYUYAbUkXkERZNSZ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbcacPWfXdQe4HpDr5ddbFBuuURLaaS1Dr", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbSToBjt9g75sCM2SUEgJNb6uskeTyzPbb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeToG5yenFa8TfHJUE17898D2RVZ76tYiT", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNBHmU7jgD3HyfD7qFzKAMcgdw7Pr3FTBm", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeFMFEzN6nEtC42MdffLBB2RbyUKMq9BPf", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQmVTLRTBBQ8c2syo379Koydj1RNCAhUw5", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWWKVymgzeYECUwomWkaxioMAmpmotUh62", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM8xDvrXzLRo41SjkNeMTYoP3tKsaLcQze", "amount": 10 }, + { "type": "GENESIS", "recipient": "QRkhzEqETQczL8xHV8P98rwu84755SDbWP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLiSBYE3QzNsWijsF8BTNzziLfyVB6nV4q", "amount": 10 }, + { "type": "GENESIS", "recipient": "QcS9jgiG6AptzioTUfUJr5oXJYQES275xU", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfkPYKpTfotYzN5BhKXENDgt1f3vo8LTSp", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQ9NUxhdgtvvxTSZqYY6k9qxHziqSQ5jcK", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ94CoMHUyNGxtef6QHMUNRd8D3NNUgM5V", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qce4PQd34icYvN463Smi9ahVGxznoax9Wi", "amount": 10 }, + { "type": "GENESIS", "recipient": "QR3MXpsu8ig4PJHJEfKsnDuxrSDLrDzLd8", "amount": 10 }, + { "type": "GENESIS", "recipient": "QXHQEBm9CtVTY9RNdCDxju7yr61DW3K8JL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhFG2as3oZVYSubieisTPHco58pgw2nr5E", "amount": 10 }, + { "type": "GENESIS", "recipient": "QY3dGvuVkQADmQYndkKv7sLBG6JeduhVbz", "amount": 10 }, + { "type": "GENESIS", "recipient": "QMSx3vQagdw6QD4D9SiiDRMhDrNFGjhUpd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVBktFMtw31ye88qjR2LTkfFGgoRkXyf7w", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPr17q2iYVQ5kMEtmUmEBN3WpMF6DjzR2x", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZituXHq3AzDdi9PDhtA5jySAC4VBUk6UJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYaVmr36tGTH4g5iTCeB5tZu3u81yp6M1G", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa51wH3pbN1bDpDWwpDDRrccwVmZo4CdXc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QbQB5hSA7P2ssdYXzxWcbDePL6SDDBUpgQ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeKHm4Rg7ANF6RBfphgS9gkYhLEboJUP8v", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSpjpN64bYczYNsKsgmNmNDAFiKUg9orJA", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdmwboMpKVpnvdYZgiaEXpuEnygDRxyywc", "amount": 10 }, + { "type": "GENESIS", "recipient": "QLnRWFKRGRtQAmX2aGM1F5vXvEb7naUUBG", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qaqb6saKN4YuHVKJ2HEDgKWAzGhJQ43sic", "amount": 10 }, + { "type": "GENESIS", "recipient": "QadBYsejVVWyFneDMpCffjbBpxgF9AEatD", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qh6K3QdnKBkb4u5Z3wD73C4sTjvcBTgiFC", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa7td7KrALcVXpMcv5GzvrtGMAPKHUUECb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaqNZLJBCJTas5Frp43jzxEYvEoWdgYeXJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUJoDzJ8WDG62MSpMfzxUDH1pwJ6aRWZL4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeVt9GFpDSdg73XQbVdCU4LHgMp9eysYa1", "amount": 10 }, + { "type": "GENESIS", "recipient": "QgzD9PSp1P5WVkyifGxCcoV7TXzLWL4GgN", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWDtjA76XhCfXw6gvYfo3MFcbKCX2ZEyLJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QM9wXFKoAYkmDwCkz1Vdsn9vyMeRRKRCzy", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWyp8eCTuCnT32vYQEj5rywCXWxYm36dov", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSGJrJSGub71GrjGSXJSZMFUtHEn7C5TUW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNhPdmMHBUPJL6yvghnTFnRajMBmdqddZd", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZaZctoQRrR2g1bhAfzb5Z5ZMANGVkBG5u", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUhct2oBCmaU6kYguDNcbU6HQss9QELpLJ", "amount": 10 }, + { "type": "GENESIS", "recipient": "QSwQUg1aWMQ7kQcR6WMWa5SHxarGdG3DgW", "amount": 10 }, + { "type": "GENESIS", "recipient": "QdQmZTpA8a2YnrZAykVhNpGhk4kVmjnwRL", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiVTD43EpJ4iFXKJwnofocwcopw1iYo1TP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QeXsnu3X1FsmLMRPYPJcsfJBVrTtwW4qrR", "amount": 10 }, + { "type": "GENESIS", "recipient": "QVTgyvvRGrd56BrFLvQoaF3DAYBXaobwef", "amount": 10 }, + { "type": "GENESIS", "recipient": "QczCL1E9G6fpifK2pFgDQiV2N5M7X54vAV", "amount": 10 }, + { "type": "GENESIS", "recipient": "QYQs34RxFv7rtYAx9mErUabnJDvCfBe8gY", "amount": 10 }, + { "type": "GENESIS", "recipient": "QanakqWSmEB6oQkrWVDRArG4wTHPs3zw4T", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNiGHSk13xXy54KuCqQ5PQZBQa13DhPb84", "amount": 10 }, + { "type": "GENESIS", "recipient": "QemRZy1gnzY1j5czckXAoBqW2Ae32onBPn", "amount": 10 }, + { "type": "GENESIS", "recipient": "QNzqkJgXKy4Gi22hGgyMMThFeG6KSYUwEb", "amount": 10 }, + { "type": "GENESIS", "recipient": "QZ3cnhqAJVyCwYBZmgjnvDz76bKyJCXa1d", "amount": 10 }, + { "type": "GENESIS", "recipient": "Qa4ZKZEgKNRDNADY97aB95VYQMa2CUYV7y", "amount": 10 }, + { "type": "GENESIS", "recipient": "QWceBxyxTA9AUocwwennBg3eLb97W5K7E4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaX4UkVvH27H3RkMtKebMoBvJCcEbDiUjq", "amount": 10 }, + { "type": "GENESIS", "recipient": "QjJnJQfaPYJdcRsKHABNKL9VYKbQBJ4Jkk", "amount": 10 }, + { "type": "GENESIS", "recipient": "QhqaWQkLXTzotnRoUnT8T6sQneiwnR4nkM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QQwemW9rxyyZRv428hr374p92KLhk3qjKP", "amount": 10 }, + { "type": "GENESIS", "recipient": "QaeT4E1ihqYKa5jTxByN9n33v5aP6f8s9C", "amount": 10 }, + { "type": "GENESIS", "recipient": "QUaiuJWKnNr9ZZBzGWd2jSKoS2W6nTFGuM", "amount": 10 }, + { "type": "GENESIS", "recipient": "QiVQroJR4kUYmvexsCZGxUD3noQ3JSStS4", "amount": 10 }, + { "type": "GENESIS", "recipient": "QPcDkEHxDKmJBnXoVE5rPmkgm5jX2wBX3Z", "amount": 10 }, + { "type": "GENESIS", "recipient": "QfSgCJLRfEWixHQ2nF5Nqz2T7rnNsy7uWS", "amount": 10 }, + { "type": "GENESIS", "recipient": "QU7EUWDZz7qJVPih3wL9RKTHRfPFy4ASHC", "amount": 10 } + ] + } +} \ No newline at end of file From 1528e05e0b9b305e98a424cc557346f1c6394750 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 14:29:52 +0000 Subject: [PATCH 067/215] Testnet arbitraryOptionalFeeTimestamp set to Sun Mar 12 2023 at 12:00:00 UTC --- testnet/testchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testnet/testchain.json b/testnet/testchain.json index 31b691ec..aef9ed9a 100644 --- a/testnet/testchain.json +++ b/testnet/testchain.json @@ -86,7 +86,7 @@ "selfSponsorshipAlgoV1Height": 9999999, "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0, - "arbitraryOptionalFeeTimestamp": 9999999999999 + "arbitraryOptionalFeeTimestamp": 1678622400000 }, "genesisInfo": { "version": 4, From 101023ba1d209a1debee117015193cb6d44830fa Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 16:39:14 +0000 Subject: [PATCH 068/215] Updated link. --- Q-Apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Q-Apps.md b/Q-Apps.md index 08adaba5..75e78164 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -586,7 +586,7 @@ Select "Preview" in the UI after choosing the zip. This allows for full Q-App te ### Testnets -For an end-to-end test of Q-App publishing, you can use the official testnet, or set up a single node testnet of your own (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](TestNets.md#quick-start). +For an end-to-end test of Q-App publishing, you can use the official testnet, or set up a single node testnet of your own (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](testnet/README.md#quick-start). ### Debugging From b5cb5f1da3d21f49ecd62edc38b460d4a4224ca2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 19:46:58 +0000 Subject: [PATCH 069/215] Fixed bug causing cache invalidation to be skipped, due to incorrect message reuse. The "Data Management" screen should now update correctly without a core restart. --- .../arbitrary/ArbitraryDataFileManager.java | 21 +++++++++---------- .../ArbitraryDataFileRequestThread.java | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java index e2de1ae0..835f5474 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java @@ -148,10 +148,10 @@ public class ArbitraryDataFileManager extends Thread { if (!arbitraryDataFileRequests.containsKey(Base58.encode(hash))) { LOGGER.debug("Requesting data file {} from peer {}", hash58, peer); Long startTime = NTP.getTime(); - ArbitraryDataFileMessage receivedArbitraryDataFileMessage = fetchArbitraryDataFile(peer, null, signature, hash, null); + ArbitraryDataFile receivedArbitraryDataFile = fetchArbitraryDataFile(peer, null, signature, hash, null); Long endTime = NTP.getTime(); - if (receivedArbitraryDataFileMessage != null && receivedArbitraryDataFileMessage.getArbitraryDataFile() != null) { - LOGGER.debug("Received data file {} from peer {}. Time taken: {} ms", receivedArbitraryDataFileMessage.getArbitraryDataFile().getHash58(), peer, (endTime-startTime)); + if (receivedArbitraryDataFile != null) { + LOGGER.debug("Received data file {} from peer {}. Time taken: {} ms", receivedArbitraryDataFile.getHash58(), peer, (endTime-startTime)); receivedAtLeastOneFile = true; // Remove this hash from arbitraryDataFileHashResponses now that we have received it @@ -193,11 +193,11 @@ public class ArbitraryDataFileManager extends Thread { return receivedAtLeastOneFile; } - private ArbitraryDataFileMessage fetchArbitraryDataFile(Peer peer, Peer requestingPeer, byte[] signature, byte[] hash, Message originalMessage) throws DataException { + private ArbitraryDataFile fetchArbitraryDataFile(Peer peer, Peer requestingPeer, byte[] signature, byte[] hash, Message originalMessage) throws DataException { ArbitraryDataFile existingFile = ArbitraryDataFile.fromHash(hash, signature); boolean fileAlreadyExists = existingFile.exists(); String hash58 = Base58.encode(hash); - ArbitraryDataFileMessage arbitraryDataFileMessage; + ArbitraryDataFile arbitraryDataFile; // Fetch the file if it doesn't exist locally if (!fileAlreadyExists) { @@ -227,28 +227,27 @@ public class ArbitraryDataFileManager extends Thread { } ArbitraryDataFileMessage peersArbitraryDataFileMessage = (ArbitraryDataFileMessage) response; - arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, peersArbitraryDataFileMessage.getArbitraryDataFile()); + arbitraryDataFile = peersArbitraryDataFileMessage.getArbitraryDataFile(); } else { LOGGER.debug(String.format("File hash %s already exists, so skipping the request", hash58)); - arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, existingFile); + arbitraryDataFile = existingFile; } // We might want to forward the request to the peer that originally requested it - this.handleArbitraryDataFileForwarding(requestingPeer, arbitraryDataFileMessage, originalMessage); + this.handleArbitraryDataFileForwarding(requestingPeer, new ArbitraryDataFileMessage(signature, arbitraryDataFile), originalMessage); boolean isRelayRequest = (requestingPeer != null); if (isRelayRequest) { if (!fileAlreadyExists) { // File didn't exist locally before the request, and it's a forwarding request, so delete it LOGGER.debug("Deleting file {} because it was needed for forwarding only", Base58.encode(hash)); - ArbitraryDataFile dataFile = arbitraryDataFileMessage.getArbitraryDataFile(); // Keep trying to delete the data until it is deleted, or we reach 10 attempts - dataFile.delete(10); + arbitraryDataFile.delete(10); } } - return arbitraryDataFileMessage; + return arbitraryDataFile; } private void handleFileListRequests(byte[] signature) { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileRequestThread.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileRequestThread.java index 2d1beadc..654c6844 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileRequestThread.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileRequestThread.java @@ -114,7 +114,7 @@ public class ArbitraryDataFileRequestThread implements Runnable { return; } - LOGGER.debug("Fetching file {} from peer {} via request thread...", hash58, peer); + LOGGER.trace("Fetching file {} from peer {} via request thread...", hash58, peer); arbitraryDataFileManager.fetchArbitraryDataFiles(repository, peer, signature, arbitraryTransactionData, Arrays.asList(hash)); } catch (DataException e) { From 4840804d328d842127c9d6a3a6c978e67990ed67 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 10:22:26 +0000 Subject: [PATCH 070/215] Fixed qdn utility usage docs. --- tools/qdn | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/qdn b/tools/qdn index 869bf5c4..ea52e3c9 100755 --- a/tools/qdn +++ b/tools/qdn @@ -8,11 +8,11 @@ if [ -z "$*" ]; then echo "Usage:" echo echo "Host/update data:" - echo "qdata POST [service] [name] PATH [dirpath] " - echo "qdata POST [service] [name] STRING [data-string] " + echo "qdn POST [service] [name] PATH [dirpath] " + echo "qdn POST [service] [name] STRING [data-string] " echo echo "Fetch data:" - echo "qdata GET [service] [name] " + echo "qdn GET [service] [name] " echo echo "Notes:" echo "- When requesting a resource, please use 'default' to indicate a file with no identifier." From edae7fd84430925b5bdeadf9d39dd5e15cdbffea Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 12:46:14 +0000 Subject: [PATCH 071/215] Added optional "encoding" query string param for various chat APIs and websockets, as base58 is too slow for the amount of data it is now processing. Usage: Add `encoding=BASE64` query string parameter to opt in to base64 encoding of returned chat data. Defaults to BASE58 for backwards support. Compatible endpoints: GET /chat/messages GET /chat/message/{signature} GET /chat/active/{address} GET /websockets/chat/active/* GET /websockets/chat/messages --- .../org/qortal/api/resource/ChatResource.java | 12 ++++-- .../api/websocket/ActiveChatsWebSocket.java | 12 +++++- .../api/websocket/ChatMessagesWebSocket.java | 14 ++++++- .../org/qortal/data/chat/ActiveChats.java | 31 ++++++++++++++-- .../org/qortal/data/chat/ChatMessage.java | 37 ++++++++++++++++--- .../org/qortal/repository/ChatRepository.java | 9 +++-- .../hsqldb/HSQLDBChatRepository.java | 23 +++++++----- .../java/org/qortal/test/RepositoryTests.java | 3 +- 8 files changed, 112 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index 150b6f63..986bb03d 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -40,6 +40,8 @@ import org.qortal.utils.Base58; import com.google.common.primitives.Bytes; +import static org.qortal.data.chat.ChatMessage.Encoding; + @Path("/chat") @Tag(name = "Chat") public class ChatResource { @@ -73,6 +75,7 @@ public class ChatResource { @QueryParam("chatreference") String chatReference, @QueryParam("haschatreference") Boolean hasChatReference, @QueryParam("sender") String sender, + @QueryParam("encoding") Encoding encoding, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { @@ -109,6 +112,7 @@ public class ChatResource { hasChatReference, involvingAddresses, sender, + encoding, limit, offset, reverse); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); @@ -131,7 +135,7 @@ public class ChatResource { } ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public ChatMessage getMessageBySignature(@PathParam("signature") String signature58) { + public ChatMessage getMessageBySignature(@PathParam("signature") String signature58, @QueryParam("encoding") Encoding encoding) { byte[] signature = Base58.decode(signature58); try (final Repository repository = RepositoryManager.getRepository()) { @@ -141,7 +145,7 @@ public class ChatResource { throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Message not found"); } - return repository.getChatRepository().toChatMessage(chatTransactionData); + return repository.getChatRepository().toChatMessage(chatTransactionData, encoding); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -164,12 +168,12 @@ public class ChatResource { } ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public ActiveChats getActiveChats(@PathParam("address") String address) { + public ActiveChats getActiveChats(@PathParam("address") String address, @QueryParam("encoding") Encoding encoding) { if (address == null || !Crypto.isValidAddress(address)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getChatRepository().getActiveChats(address); + return repository.getChatRepository().getActiveChats(address, encoding); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } diff --git a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java index 405fe7e5..d683f519 100644 --- a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java @@ -2,6 +2,7 @@ package org.qortal.api.websocket; import java.io.IOException; import java.io.StringWriter; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -21,6 +22,8 @@ import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import static org.qortal.data.chat.ChatMessage.Encoding; + @WebSocket @SuppressWarnings("serial") public class ActiveChatsWebSocket extends ApiWebSocket { @@ -75,7 +78,7 @@ public class ActiveChatsWebSocket extends ApiWebSocket { } try (final Repository repository = RepositoryManager.getRepository()) { - ActiveChats activeChats = repository.getChatRepository().getActiveChats(ourAddress); + ActiveChats activeChats = repository.getChatRepository().getActiveChats(ourAddress, getTargetEncoding(session)); StringWriter stringWriter = new StringWriter(); @@ -93,4 +96,11 @@ public class ActiveChatsWebSocket extends ApiWebSocket { } } + private Encoding getTargetEncoding(Session session) { + // Default to Base58 if not specified, for backwards support + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + String encoding = (queryParams.get("encoding") != null && !queryParams.get("encoding").isEmpty()) ? queryParams.get("encoding").get(0) : "BASE58"; + return Encoding.valueOf(encoding); + } + } diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index c6d7aaed..aeb1b10b 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -22,6 +22,8 @@ import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import static org.qortal.data.chat.ChatMessage.Encoding; + @WebSocket @SuppressWarnings("serial") public class ChatMessagesWebSocket extends ApiWebSocket { @@ -35,6 +37,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { @Override public void onWebSocketConnect(Session session) { Map> queryParams = session.getUpgradeRequest().getParameterMap(); + Encoding encoding = getTargetEncoding(session); List txGroupIds = queryParams.get("txGroupId"); if (txGroupIds != null && txGroupIds.size() == 1) { @@ -50,6 +53,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + encoding, null, null, null); sendMessages(session, chatMessages); @@ -81,6 +85,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, involvingAddresses, null, + encoding, null, null, null); sendMessages(session, chatMessages); @@ -155,7 +160,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { // Convert ChatTransactionData to ChatMessage ChatMessage chatMessage; try (final Repository repository = RepositoryManager.getRepository()) { - chatMessage = repository.getChatRepository().toChatMessage(chatTransactionData); + chatMessage = repository.getChatRepository().toChatMessage(chatTransactionData, getTargetEncoding(session)); } catch (DataException e) { // No output this time? return; @@ -164,4 +169,11 @@ public class ChatMessagesWebSocket extends ApiWebSocket { sendMessages(session, Collections.singletonList(chatMessage)); } + private Encoding getTargetEncoding(Session session) { + // Default to Base58 if not specified, for backwards support + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + String encoding = (queryParams.get("encoding") != null && !queryParams.get("encoding").isEmpty()) ? queryParams.get("encoding").get(0) : "BASE58"; + return Encoding.valueOf(encoding); + } + } diff --git a/src/main/java/org/qortal/data/chat/ActiveChats.java b/src/main/java/org/qortal/data/chat/ActiveChats.java index d5ebcf3f..248af82e 100644 --- a/src/main/java/org/qortal/data/chat/ActiveChats.java +++ b/src/main/java/org/qortal/data/chat/ActiveChats.java @@ -1,10 +1,15 @@ package org.qortal.data.chat; +import org.bouncycastle.util.encoders.Base64; +import org.qortal.utils.Base58; + import java.util.List; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import static org.qortal.data.chat.ChatMessage.Encoding; + @XmlAccessorType(XmlAccessType.FIELD) public class ActiveChats { @@ -18,20 +23,38 @@ public class ActiveChats { private String sender; private String senderName; private byte[] signature; - private byte[] data; + private Encoding encoding; + private String data; protected GroupChat() { /* JAXB */ } - public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName, byte[] signature, byte[] data) { + public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName, + byte[] signature, Encoding encoding, byte[] data) { this.groupId = groupId; this.groupName = groupName; this.timestamp = timestamp; this.sender = sender; this.senderName = senderName; this.signature = signature; - this.data = data; + this.encoding = encoding != null ? encoding : Encoding.BASE58; + + if (data != null) { + switch (this.encoding) { + case BASE64: + this.data = Base64.toBase64String(data); + break; + + case BASE58: + default: + this.data = Base58.encode(data); + break; + } + } + else { + this.data = null; + } } public int getGroupId() { @@ -58,7 +81,7 @@ public class ActiveChats { return this.signature; } - public byte[] getData() { + public String getData() { return this.data; } } diff --git a/src/main/java/org/qortal/data/chat/ChatMessage.java b/src/main/java/org/qortal/data/chat/ChatMessage.java index 5d16bb7c..5d9ecb4e 100644 --- a/src/main/java/org/qortal/data/chat/ChatMessage.java +++ b/src/main/java/org/qortal/data/chat/ChatMessage.java @@ -1,11 +1,19 @@ package org.qortal.data.chat; +import org.bouncycastle.util.encoders.Base64; +import org.qortal.utils.Base58; + import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @XmlAccessorType(XmlAccessType.FIELD) public class ChatMessage { + public enum Encoding { + BASE58, + BASE64 + } + // Properties private long timestamp; @@ -29,7 +37,9 @@ public class ChatMessage { private byte[] chatReference; - private byte[] data; + private Encoding encoding; + + private String data; private boolean isText; private boolean isEncrypted; @@ -44,8 +54,8 @@ public class ChatMessage { // For repository use public ChatMessage(long timestamp, int txGroupId, byte[] reference, byte[] senderPublicKey, String sender, - String senderName, String recipient, String recipientName, byte[] chatReference, byte[] data, - boolean isText, boolean isEncrypted, byte[] signature) { + String senderName, String recipient, String recipientName, byte[] chatReference, + Encoding encoding, byte[] data, boolean isText, boolean isEncrypted, byte[] signature) { this.timestamp = timestamp; this.txGroupId = txGroupId; this.reference = reference; @@ -55,7 +65,24 @@ public class ChatMessage { this.recipient = recipient; this.recipientName = recipientName; this.chatReference = chatReference; - this.data = data; + this.encoding = encoding != null ? encoding : Encoding.BASE58; + + if (data != null) { + switch (this.encoding) { + case BASE64: + this.data = Base64.toBase64String(data); + break; + + case BASE58: + default: + this.data = Base58.encode(data); + break; + } + } + else { + this.data = null; + } + this.isText = isText; this.isEncrypted = isEncrypted; this.signature = signature; @@ -97,7 +124,7 @@ public class ChatMessage { return this.chatReference; } - public byte[] getData() { + public String getData() { return this.data; } diff --git a/src/main/java/org/qortal/repository/ChatRepository.java b/src/main/java/org/qortal/repository/ChatRepository.java index 34ad77dd..7443fb51 100644 --- a/src/main/java/org/qortal/repository/ChatRepository.java +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -6,6 +6,8 @@ import org.qortal.data.chat.ActiveChats; import org.qortal.data.chat.ChatMessage; import org.qortal.data.transaction.ChatTransactionData; +import static org.qortal.data.chat.ChatMessage.Encoding; + public interface ChatRepository { /** @@ -15,10 +17,11 @@ public interface ChatRepository { */ public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] reference, byte[] chatReferenceBytes, Boolean hasChatReference, - List involving, String senderAddress, Integer limit, Integer offset, Boolean reverse) throws DataException; + List involving, String senderAddress, Encoding encoding, + Integer limit, Integer offset, Boolean reverse) throws DataException; - public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) throws DataException; + public ChatMessage toChatMessage(ChatTransactionData chatTransactionData, Encoding encoding) throws DataException; - public ActiveChats getActiveChats(String address) throws DataException; + public ActiveChats getActiveChats(String address, Encoding encoding) throws DataException; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java index 55467d87..9e310e78 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -14,6 +14,8 @@ import org.qortal.repository.ChatRepository; import org.qortal.repository.DataException; import org.qortal.transaction.Transaction.TransactionType; +import static org.qortal.data.chat.ChatMessage.Encoding; + public class HSQLDBChatRepository implements ChatRepository { protected HSQLDBRepository repository; @@ -24,8 +26,8 @@ public class HSQLDBChatRepository implements ChatRepository { @Override public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] referenceBytes, - byte[] chatReferenceBytes, Boolean hasChatReference, List involving, String senderAddress, - Integer limit, Integer offset, Boolean reverse) throws DataException { + byte[] chatReferenceBytes, Boolean hasChatReference, List involving, String senderAddress, + Encoding encoding, Integer limit, Integer offset, Boolean reverse) throws DataException { // Check args meet expectations if ((txGroupId != null && involving != null && !involving.isEmpty()) || (txGroupId == null && (involving == null || involving.size() != 2))) @@ -127,7 +129,7 @@ public class HSQLDBChatRepository implements ChatRepository { byte[] signature = resultSet.getBytes(13); ChatMessage chatMessage = new ChatMessage(timestamp, groupId, reference, senderPublicKey, sender, - senderName, recipient, recipientName, chatReference, data, isText, isEncrypted, signature); + senderName, recipient, recipientName, chatReference, encoding, data, isText, isEncrypted, signature); chatMessages.add(chatMessage); } while (resultSet.next()); @@ -139,7 +141,7 @@ public class HSQLDBChatRepository implements ChatRepository { } @Override - public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) throws DataException { + public ChatMessage toChatMessage(ChatTransactionData chatTransactionData, Encoding encoding) throws DataException { String sql = "SELECT SenderNames.name, RecipientNames.name " + "FROM ChatTransactions " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -166,21 +168,22 @@ public class HSQLDBChatRepository implements ChatRepository { byte[] signature = chatTransactionData.getSignature(); return new ChatMessage(timestamp, groupId, reference, senderPublicKey, sender, - senderName, recipient, recipientName, chatReference, data, isText, isEncrypted, signature); + senderName, recipient, recipientName, chatReference, encoding, data, + isText, isEncrypted, signature); } catch (SQLException e) { throw new DataException("Unable to fetch convert chat transaction from repository", e); } } @Override - public ActiveChats getActiveChats(String address) throws DataException { - List groupChats = getActiveGroupChats(address); + public ActiveChats getActiveChats(String address, Encoding encoding) throws DataException { + List groupChats = getActiveGroupChats(address, encoding); List directChats = getActiveDirectChats(address); return new ActiveChats(groupChats, directChats); } - private List getActiveGroupChats(String address) throws DataException { + private List getActiveGroupChats(String address, Encoding encoding) throws DataException { // Find groups where address is a member and potential latest message details String groupsSql = "SELECT group_id, group_name, latest_timestamp, sender, sender_name, signature, data " + "FROM GroupMembers " @@ -213,7 +216,7 @@ public class HSQLDBChatRepository implements ChatRepository { byte[] signature = resultSet.getBytes(6); byte[] data = resultSet.getBytes(7); - GroupChat groupChat = new GroupChat(groupId, groupName, timestamp, sender, senderName, signature, data); + GroupChat groupChat = new GroupChat(groupId, groupName, timestamp, sender, senderName, signature, encoding, data); groupChats.add(groupChat); } while (resultSet.next()); } @@ -247,7 +250,7 @@ public class HSQLDBChatRepository implements ChatRepository { data = resultSet.getBytes(5); } - GroupChat groupChat = new GroupChat(0, null, timestamp, sender, senderName, signature, data); + GroupChat groupChat = new GroupChat(0, null, timestamp, sender, senderName, signature, encoding, data); groupChats.add(groupChat); } catch (SQLException e) { throw new DataException("Unable to fetch active group chats from repository", e); diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index bb6510d5..30cbaea5 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -9,6 +9,7 @@ import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountBalanceData; import org.qortal.data.account.AccountData; +import org.qortal.data.chat.ChatMessage; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -417,7 +418,7 @@ public class RepositoryTests extends Common { try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { String address = Crypto.toAddress(new byte[32]); - hsqldb.getChatRepository().getActiveChats(address); + hsqldb.getChatRepository().getActiveChats(address, ChatMessage.Encoding.BASE58); } catch (DataException e) { fail("HSQLDB bug #1580"); } From 5386db8a3fffc2dd1f7b1cd7d63b35c5380eea68 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 13:11:01 +0000 Subject: [PATCH 072/215] Added ping/pong functionality to CHAT websockets. --- .../org/qortal/api/websocket/ActiveChatsWebSocket.java | 5 ++++- .../org/qortal/api/websocket/ChatMessagesWebSocket.java | 9 ++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java index d683f519..960ac8c1 100644 --- a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.io.StringWriter; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; import org.eclipse.jetty.websocket.api.Session; @@ -65,7 +66,9 @@ public class ActiveChatsWebSocket extends ApiWebSocket { @OnWebSocketMessage public void onWebSocketMessage(Session session, String message) { - /* ignored */ + if (Objects.equals(message, "ping")) { + session.getRemote().sendStringByFuture("pong"); + } } private void onNotify(Session session, ChatTransactionData chatTransactionData, String ourAddress, AtomicReference previousOutput) { diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index aeb1b10b..01df36f0 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -2,10 +2,7 @@ package org.qortal.api.websocket; import java.io.IOException; import java.io.StringWriter; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.*; import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.api.WebSocketException; @@ -112,7 +109,9 @@ public class ChatMessagesWebSocket extends ApiWebSocket { @OnWebSocketMessage public void onWebSocketMessage(Session session, String message) { - /* ignored */ + if (Objects.equals(message, "ping")) { + session.getRemote().sendStringByFuture("pong"); + } } private void onNotify(Session session, ChatTransactionData chatTransactionData, int txGroupId) { From 05eb3373676d7f2a4373aeded7531ea519894a41 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 13:15:57 +0000 Subject: [PATCH 073/215] Added optional limit/offset/reverse query string params to GET /websockets/chat/messages. Without this, the websocket returns all messages on connection, which is very time consuming. --- .../qortal/api/websocket/ChatMessagesWebSocket.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index 01df36f0..e443ee78 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -36,6 +36,15 @@ public class ChatMessagesWebSocket extends ApiWebSocket { Map> queryParams = session.getUpgradeRequest().getParameterMap(); Encoding encoding = getTargetEncoding(session); + List limitList = queryParams.get("limit"); + Integer limit = (limitList != null && limitList.size() == 1) ? Integer.parseInt(limitList.get(0)) : null; + + List offsetList = queryParams.get("offset"); + Integer offset = (offsetList != null && offsetList.size() == 1) ? Integer.parseInt(offsetList.get(0)) : null; + + List reverseList = queryParams.get("offset"); + Boolean reverse = (reverseList != null && reverseList.size() == 1) ? Boolean.getBoolean(reverseList.get(0)) : null; + List txGroupIds = queryParams.get("txGroupId"); if (txGroupIds != null && txGroupIds.size() == 1) { int txGroupId = Integer.parseInt(txGroupIds.get(0)); @@ -51,7 +60,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, encoding, - null, null, null); + limit, offset, reverse); sendMessages(session, chatMessages); } catch (DataException e) { @@ -83,7 +92,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { involvingAddresses, null, encoding, - null, null, null); + limit, offset, reverse); sendMessages(session, chatMessages); } catch (DataException e) { From 9968865d0eb6fd298dcd00082e28067f8e15cbc8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 13:17:23 +0000 Subject: [PATCH 074/215] Updated parsing of "encoding" in websockets, for consistency with other params. --- .../java/org/qortal/api/websocket/ActiveChatsWebSocket.java | 3 ++- .../java/org/qortal/api/websocket/ChatMessagesWebSocket.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java index 960ac8c1..9ac9f87d 100644 --- a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java @@ -102,7 +102,8 @@ public class ActiveChatsWebSocket extends ApiWebSocket { private Encoding getTargetEncoding(Session session) { // Default to Base58 if not specified, for backwards support Map> queryParams = session.getUpgradeRequest().getParameterMap(); - String encoding = (queryParams.get("encoding") != null && !queryParams.get("encoding").isEmpty()) ? queryParams.get("encoding").get(0) : "BASE58"; + List encodingList = queryParams.get("encoding"); + String encoding = (encodingList != null && encodingList.size() == 1) ? encodingList.get(0) : "BASE58"; return Encoding.valueOf(encoding); } diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index e443ee78..3046c1c1 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -180,7 +180,8 @@ public class ChatMessagesWebSocket extends ApiWebSocket { private Encoding getTargetEncoding(Session session) { // Default to Base58 if not specified, for backwards support Map> queryParams = session.getUpgradeRequest().getParameterMap(); - String encoding = (queryParams.get("encoding") != null && !queryParams.get("encoding").isEmpty()) ? queryParams.get("encoding").get(0) : "BASE58"; + List encodingList = queryParams.get("encoding"); + String encoding = (encodingList != null && encodingList.size() == 1) ? encodingList.get(0) : "BASE58"; return Encoding.valueOf(encoding); } From d9cac6db39d447b400f9bbed1a2c697603d3f3cb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 19:33:41 +0000 Subject: [PATCH 075/215] Allow "data:" URLs to be played in app/website media players. E.g: src="data:video/mp4;base64,VideoContentEncodedInBase64GoesHere" --- src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 584dd12a..890aca7b 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -131,7 +131,7 @@ public class ArbitraryDataRenderer { byte[] data = Files.readAllBytes(Paths.get(filePath)); // TODO: limit file size that can be read into memory HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext, service, identifier, theme); htmlParser.addAdditionalHeaderTags(); - response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob:; img-src 'self' data: blob:;"); + response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:;"); response.setContentType(context.getMimeType(filename)); response.setContentLength(htmlParser.getData().length); response.getOutputStream().write(htmlParser.getData()); From 565610019785d2f45f5f04d41f19a899a0c088e7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 19:47:57 +0000 Subject: [PATCH 076/215] Added "identifier", "name", and "prefix" parameters to GET /arbitrary/resources/search endpoint. - "identifier" is an alternative to "query" that will search identifiers only. - "name" is an alternative to "query" that will search names only. - "query" remains the same as before - it searches both name and identifier fields. - "prefix" is a boolean, and when true it will only match the beginning of each field. Works with "identifier", "name", and "query" params. --- .../api/resource/ArbitraryResource.java | 8 +++- .../repository/ArbitraryRepository.java | 2 +- .../hsqldb/HSQLDBArbitraryRepository.java | 46 +++++++++++++------ 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 6fe59b10..9510dced 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -157,7 +157,10 @@ public class ArbitraryResource { @ApiErrors({ApiError.REPOSITORY_ISSUE}) public List searchResources( @QueryParam("service") Service service, - @QueryParam("query") String query, + @Parameter(description = "Query (searches both name and identifier fields)") @QueryParam("query") String query, + @Parameter(description = "Identifier (searches identifier field only)") @QueryParam("identifier") String identifier, + @Parameter(description = "Name (searches name field only)") @QueryParam("name") String name, + @Parameter(description = "Prefix only (if true, only the beginning of fields are matched)") @QueryParam("prefix") Boolean prefixOnly, @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @@ -168,9 +171,10 @@ public class ArbitraryResource { try (final Repository repository = RepositoryManager.getRepository()) { boolean defaultRes = Boolean.TRUE.equals(defaultResource); + boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly); List resources = repository.getArbitraryRepository() - .searchArbitraryResources(service, query, defaultRes, limit, offset, reverse); + .searchArbitraryResources(service, query, identifier, name, usePrefixOnly, defaultRes, limit, offset, reverse); if (resources == null) { return new ArrayList<>(); diff --git a/src/main/java/org/qortal/repository/ArbitraryRepository.java b/src/main/java/org/qortal/repository/ArbitraryRepository.java index 75fb0509..5581bc59 100644 --- a/src/main/java/org/qortal/repository/ArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/ArbitraryRepository.java @@ -26,7 +26,7 @@ public interface ArbitraryRepository { public List getArbitraryResources(Service service, String identifier, List names, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; - public List searchArbitraryResources(Service service, String query, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List searchArbitraryResources(Service service, String query, String identifier, String name, boolean prefixOnly, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; public List getArbitraryResourceCreatorNames(Service service, String identifier, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index c21dd038..55b033eb 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -378,16 +378,11 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { } @Override - public List searchArbitraryResources(Service service, String query, + public List searchArbitraryResources(Service service, String query, String identifier, String name, boolean prefixOnly, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); List bindParams = new ArrayList<>(); - // For now we are searching anywhere in the fields - // Note that this will bypass any indexes so may not scale well - // Longer term we probably want to copy resources to their own table anyway - String queryWildcard = String.format("%%%s%%", query.toLowerCase()); - sql.append("SELECT name, service, identifier, MAX(size) AS max_size FROM ArbitraryTransactions WHERE 1=1"); if (service != null) { @@ -395,16 +390,39 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { sql.append(service.value); } - if (defaultResource) { - // Default resource requested - use NULL identifier and search name only - sql.append(" AND LCASE(name) LIKE ? AND identifier IS NULL"); + // Handle general query matches + if (query != null) { + // Search anywhere in the fields, unless "prefixOnly" has been requested + // Note that without prefixOnly it will bypass any indexes so may not scale well + // Longer term we probably want to copy resources to their own table anyway + String queryWildcard = prefixOnly ? String.format("%s%%", query.toLowerCase()) : String.format("%%%s%%", query.toLowerCase()); + + if (defaultResource) { + // Default resource requested - use NULL identifier and search name only + sql.append(" AND LCASE(name) LIKE ? AND identifier IS NULL"); + bindParams.add(queryWildcard); + } else { + // Non-default resource requested + // In this case we search the identifier as well as the name + sql.append(" AND (LCASE(name) LIKE ? OR LCASE(identifier) LIKE ?)"); + bindParams.add(queryWildcard); + bindParams.add(queryWildcard); + } + } + + // Handle identifier matches + if (identifier != null) { + // Search anywhere in the identifier, unless "prefixOnly" has been requested + String queryWildcard = prefixOnly ? String.format("%s%%", identifier.toLowerCase()) : String.format("%%%s%%", identifier.toLowerCase()); + sql.append(" AND LCASE(identifier) LIKE ?"); bindParams.add(queryWildcard); } - else { - // Non-default resource requested - // In this case we search the identifier as well as the name - sql.append(" AND (LCASE(name) LIKE ? OR LCASE(identifier) LIKE ?)"); - bindParams.add(queryWildcard); + + // Handle name matches + if (name != null) { + // Search anywhere in the identifier, unless "prefixOnly" has been requested + String queryWildcard = prefixOnly ? String.format("%s%%", name.toLowerCase()) : String.format("%%%s%%", name.toLowerCase()); + sql.append(" AND LCASE(name) LIKE ?"); bindParams.add(queryWildcard); } From 469c1af0efd53457c9c0a2429fea12610ea053a2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 22:11:34 +0000 Subject: [PATCH 077/215] Added new search features to the SEARCH_QDN_RESOURCES action. Existing action renamed to LIST_QDN_RESOURCES, which is an alternative for listing QDN resources without using a search query. --- Q-Apps.md | 28 +++++++++++++++++++++++----- src/main/resources/q-apps/q-apps.js | 21 ++++++++++++++++++--- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 75e78164..a0a7e344 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -138,6 +138,7 @@ Here is a list of currently supported actions: - GET_ACCOUNT_DATA - GET_ACCOUNT_NAMES - GET_NAME_DATA +- LIST_QDN_RESOURCES - SEARCH_QDN_RESOURCES - GET_QDN_RESOURCE_STATUS - FETCH_QDN_RESOURCE @@ -209,16 +210,33 @@ let res = await qortalRequest({ ``` +### List QDN resources +``` +let res = await qortalRequest({ + action: "LIST_QDN_RESOURCES", + service: "THUMBNAIL", + identifier: "qortal_avatar", // Optional + default: true, // Optional + includeStatus: false, // Optional - will take time to respond, so only request if necessary + includeMetadata: false, // Optional - will take time to respond, so only request if necessary + limit: 100, + offset: 0, + reverse: true +}); +``` + ### Search QDN resources ``` let res = await qortalRequest({ action: "SEARCH_QDN_RESOURCES", service: "THUMBNAIL", - identifier: "qortal_avatar", // Optional - default: true, // Optional - nameListFilter: "FollowedNames", // Optional - includeStatus: false, - includeMetadata: false, + query: "search query goes here", // Optional - searches both "identifier" and "name" fields + identifier: "search query goes here", // Optional - searches only the "identifier" field + name: "search query goes here", // Optional - searches only the "name" field + prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters + default: false, // Optional - if true, only resources without identifiers are returned + includeStatus: false, // Optional - will take time to respond, so only request if necessary + includeMetadata: false, // Optional - will take time to respond, so only request if necessary limit: 100, offset: 0, reverse: true diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 40c8716c..bc93b45c 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -155,12 +155,27 @@ window.addEventListener("message", (event) => { window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path); break; - case "SEARCH_QDN_RESOURCES": + case "LIST_QDN_RESOURCES": url = "/arbitrary/resources?"; if (data.service != null) url = url.concat("&service=" + data.service); if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); - if (data.default != null) url = url.concat("&default=" + data.default); - if (data.nameListFilter != null) url = url.concat("&namefilter=" + data.nameListFilter); + if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); + if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); + if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + response = httpGet(url); + break; + + case "SEARCH_QDN_RESOURCES": + url = "/arbitrary/resources/search?"; + if (data.service != null) url = url.concat("&service=" + data.service); + if (data.query != null) url = url.concat("&query=" + data.query); + if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); + if (data.name != null) url = url.concat("&name=" + data.name); + if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString()); + if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); if (data.limit != null) url = url.concat("&limit=" + data.limit); From 534a44d0ce3563754287341957431f32a5c46222 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 22:58:14 +0000 Subject: [PATCH 078/215] Fixed bugs with URL building. --- src/main/resources/q-apps/q-apps.js | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index bc93b45c..2d1bfeb5 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -39,8 +39,15 @@ function handleResponse(event, response) { } } -function buildResourceUrl(service, name, identifier, path) { - if (_qdnContext == "render") { +function buildResourceUrl(service, name, identifier, path, isLink) { + if (isLink == false) { + // If this URL isn't being used as a link, then we need to fetch the data + // synchronously, instead of showing the loading screen. + url = "/arbitrary/" + service + "/" + name; + if (identifier != null) url = url.concat("/" + identifier); + if (path != null) url = url.concat("?filepath=" + path); + } + else if (_qdnContext == "render") { url = "/render/" + service + "/" + name; if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); if (identifier != null) url = url.concat("?identifier=" + identifier); @@ -55,7 +62,8 @@ function buildResourceUrl(service, name, identifier, path) { url = "/" + name; if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); } - url = url.concat((url.includes("?") ? "" : "?") + "&theme=" + _qdnTheme); + + if (isLink) url = url.concat((url.includes("?") ? "" : "?") + "&theme=" + _qdnTheme); return url; } @@ -102,7 +110,7 @@ function extractComponents(url) { return null; } -function convertToResourceUrl(url) { +function convertToResourceUrl(url, isLink) { if (!url.startsWith("qortal://")) { return null; } @@ -111,7 +119,7 @@ function convertToResourceUrl(url) { return null; } - return buildResourceUrl(c.service, c.name, c.identifier, c.path); + return buildResourceUrl(c.service, c.name, c.identifier, c.path, isLink); } window.addEventListener("message", (event) => { @@ -147,12 +155,12 @@ window.addEventListener("message", (event) => { break; case "GET_QDN_RESOURCE_URL": - response = buildResourceUrl(data.service, data.name, data.identifier, data.path); + response = buildResourceUrl(data.service, data.name, data.identifier, data.path, false); break; case "LINK_TO_QDN_RESOURCE": if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE - window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path); + window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path, true); break; case "LIST_QDN_RESOURCES": @@ -361,7 +369,7 @@ document.addEventListener('DOMContentLoaded', () => { const imgElements = document.querySelectorAll('img'); imgElements.forEach((img) => { let url = img.src; - const newUrl = convertToResourceUrl(url); + const newUrl = convertToResourceUrl(url, false); if (newUrl != null) { document.querySelector('img').src = newUrl; } @@ -377,7 +385,7 @@ document.addEventListener('DOMContentLoaded', () => { let observer = new MutationObserver((changes) => { changes.forEach(change => { if (change.attributeName.includes('src')) { - const newUrl = convertToResourceUrl(img.src); + const newUrl = convertToResourceUrl(img.src, false); if (newUrl != null) { document.querySelector('img').src = newUrl; } From 2bee3cbb5cbee5f2fd3ba91ae87d59855ed8588b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 10:40:27 +0000 Subject: [PATCH 079/215] Treat service as an int in ArbitraryTransactionData --- .../qortal/arbitrary/ArbitraryDataTransactionBuilder.java | 2 +- .../qortal/data/transaction/ArbitraryTransactionData.java | 6 +++--- .../repository/hsqldb/HSQLDBArbitraryRepository.java | 8 ++++---- .../transaction/HSQLDBArbitraryTransactionRepository.java | 4 ++-- .../transaction/ArbitraryTransactionTransformer.java | 2 +- .../test/common/transaction/ArbitraryTestTransaction.java | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java index b27e511c..2faf945d 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java @@ -274,7 +274,7 @@ public class ArbitraryDataTransactionBuilder { final List payments = new ArrayList<>(); ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, - version, service, nonce, size, name, identifier, method, + version, service.value, nonce, size, name, identifier, method, secret, compression, digest, dataType, metadataHash, payments); this.arbitraryTransactionData = transactionData; diff --git a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java index acd5c3a6..477b1da0 100644 --- a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java @@ -73,7 +73,7 @@ public class ArbitraryTransactionData extends TransactionData { @Schema(example = "sender_public_key") private byte[] senderPublicKey; - private Service service; + private int service; private int nonce; private int size; @@ -103,7 +103,7 @@ public class ArbitraryTransactionData extends TransactionData { } public ArbitraryTransactionData(BaseTransactionData baseTransactionData, - int version, Service service, int nonce, int size, + int version, int service, int nonce, int size, String name, String identifier, Method method, byte[] secret, Compression compression, byte[] data, DataType dataType, byte[] metadataHash, List payments) { super(TransactionType.ARBITRARY, baseTransactionData); @@ -135,7 +135,7 @@ public class ArbitraryTransactionData extends TransactionData { } public Service getService() { - return this.service; + return Service.valueOf(this.service); } public int getNonce() { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index 55b033eb..5c3a88f7 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -202,7 +202,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { int version = resultSet.getInt(11); int nonce = resultSet.getInt(12); - Service serviceResult = Service.valueOf(resultSet.getInt(13)); + int serviceInt = resultSet.getInt(13); int size = resultSet.getInt(14); boolean isDataRaw = resultSet.getBoolean(15); // NOT NULL, so no null to false DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH; @@ -216,7 +216,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { // FUTURE: get payments from signature if needed. Avoiding for now to reduce database calls. ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, - version, serviceResult, nonce, size, nameResult, identifierResult, method, secret, + version, serviceInt, nonce, size, nameResult, identifierResult, method, secret, compression, data, dataType, metadataHash, null); arbitraryTransactionData.add(transactionData); @@ -277,7 +277,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { int version = resultSet.getInt(11); int nonce = resultSet.getInt(12); - Service serviceResult = Service.valueOf(resultSet.getInt(13)); + int serviceInt = resultSet.getInt(13); int size = resultSet.getInt(14); boolean isDataRaw = resultSet.getBoolean(15); // NOT NULL, so no null to false DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH; @@ -291,7 +291,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { // FUTURE: get payments from signature if needed. Avoiding for now to reduce database calls. ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, - version, serviceResult, nonce, size, nameResult, identifierResult, methodResult, secret, + version, serviceInt, nonce, size, nameResult, identifierResult, methodResult, secret, compression, data, dataType, metadataHash, null); return transactionData; diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java index c7f4c958..345338c7 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java @@ -31,7 +31,7 @@ public class HSQLDBArbitraryTransactionRepository extends HSQLDBTransactionRepos int version = resultSet.getInt(1); int nonce = resultSet.getInt(2); - Service service = Service.valueOf(resultSet.getInt(3)); + int serviceInt = resultSet.getInt(3); int size = resultSet.getInt(4); boolean isDataRaw = resultSet.getBoolean(5); // NOT NULL, so no null to false DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH; @@ -44,7 +44,7 @@ public class HSQLDBArbitraryTransactionRepository extends HSQLDBTransactionRepos ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.valueOf(resultSet.getInt(12)); List payments = this.getPaymentsFromSignature(baseTransactionData.getSignature()); - return new ArbitraryTransactionData(baseTransactionData, version, service, nonce, size, name, + return new ArbitraryTransactionData(baseTransactionData, version, serviceInt, nonce, size, name, identifier, method, secret, compression, data, dataType, metadataHash, payments); } catch (SQLException e) { throw new DataException("Unable to fetch arbitrary transaction from repository", e); diff --git a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java index b1554e8d..6a5043cd 100644 --- a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java @@ -131,7 +131,7 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { payments.add(PaymentTransformer.fromByteBuffer(byteBuffer)); } - Service service = Service.valueOf(byteBuffer.getInt()); + int service = byteBuffer.getInt(); // We might be receiving hash of data instead of actual raw data boolean isRaw = byteBuffer.get() != 0; diff --git a/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java b/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java index d831eaf1..1290fd0a 100644 --- a/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java +++ b/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java @@ -45,7 +45,7 @@ public class ArbitraryTestTransaction extends TestTransaction { List payments = new ArrayList<>(); payments.add(new PaymentData(recipient, assetId, amount)); - return new ArbitraryTransactionData(generateBase(account), version, service, nonce, size,name, identifier, + return new ArbitraryTransactionData(generateBase(account), version, service.value, nonce, size,name, identifier, method, secret, compression, data, dataType, metadataHash, payments); } From 50780aba53121c625938e1854ad833283e57a436 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 10:41:14 +0000 Subject: [PATCH 080/215] Set max size of APP service to 50MB. --- src/main/java/org/qortal/arbitrary/misc/Service.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index a52571f2..fa47f020 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -83,7 +83,7 @@ public enum Service { DOCUMENT(800, false, null, true, null), LIST(900, true, null, true, null), PLAYLIST(910, true, null, true, null), - APP(1000, false, null, false, null), + APP(1000, true, 50*1024*1024L, false, null), METADATA(1100, false, null, true, null), JSON(1110, true, 25*1024L, true, null) { @Override From a555f503eb595f3880580609bf30045b17b0f4cf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 10:40:27 +0000 Subject: [PATCH 081/215] Treat service as an int in ArbitraryTransactionData --- .../qortal/arbitrary/ArbitraryDataTransactionBuilder.java | 2 +- .../qortal/data/transaction/ArbitraryTransactionData.java | 6 +++--- .../repository/hsqldb/HSQLDBArbitraryRepository.java | 8 ++++---- .../transaction/HSQLDBArbitraryTransactionRepository.java | 4 ++-- .../transaction/ArbitraryTransactionTransformer.java | 2 +- .../test/common/transaction/ArbitraryTestTransaction.java | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java index b27e511c..2faf945d 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java @@ -274,7 +274,7 @@ public class ArbitraryDataTransactionBuilder { final List payments = new ArrayList<>(); ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, - version, service, nonce, size, name, identifier, method, + version, service.value, nonce, size, name, identifier, method, secret, compression, digest, dataType, metadataHash, payments); this.arbitraryTransactionData = transactionData; diff --git a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java index acd5c3a6..477b1da0 100644 --- a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java @@ -73,7 +73,7 @@ public class ArbitraryTransactionData extends TransactionData { @Schema(example = "sender_public_key") private byte[] senderPublicKey; - private Service service; + private int service; private int nonce; private int size; @@ -103,7 +103,7 @@ public class ArbitraryTransactionData extends TransactionData { } public ArbitraryTransactionData(BaseTransactionData baseTransactionData, - int version, Service service, int nonce, int size, + int version, int service, int nonce, int size, String name, String identifier, Method method, byte[] secret, Compression compression, byte[] data, DataType dataType, byte[] metadataHash, List payments) { super(TransactionType.ARBITRARY, baseTransactionData); @@ -135,7 +135,7 @@ public class ArbitraryTransactionData extends TransactionData { } public Service getService() { - return this.service; + return Service.valueOf(this.service); } public int getNonce() { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index c21dd038..2158c272 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -202,7 +202,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { int version = resultSet.getInt(11); int nonce = resultSet.getInt(12); - Service serviceResult = Service.valueOf(resultSet.getInt(13)); + int serviceInt = resultSet.getInt(13); int size = resultSet.getInt(14); boolean isDataRaw = resultSet.getBoolean(15); // NOT NULL, so no null to false DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH; @@ -216,7 +216,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { // FUTURE: get payments from signature if needed. Avoiding for now to reduce database calls. ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, - version, serviceResult, nonce, size, nameResult, identifierResult, method, secret, + version, serviceInt, nonce, size, nameResult, identifierResult, method, secret, compression, data, dataType, metadataHash, null); arbitraryTransactionData.add(transactionData); @@ -277,7 +277,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { int version = resultSet.getInt(11); int nonce = resultSet.getInt(12); - Service serviceResult = Service.valueOf(resultSet.getInt(13)); + int serviceInt = resultSet.getInt(13); int size = resultSet.getInt(14); boolean isDataRaw = resultSet.getBoolean(15); // NOT NULL, so no null to false DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH; @@ -291,7 +291,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { // FUTURE: get payments from signature if needed. Avoiding for now to reduce database calls. ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, - version, serviceResult, nonce, size, nameResult, identifierResult, methodResult, secret, + version, serviceInt, nonce, size, nameResult, identifierResult, methodResult, secret, compression, data, dataType, metadataHash, null); return transactionData; diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java index c7f4c958..345338c7 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java @@ -31,7 +31,7 @@ public class HSQLDBArbitraryTransactionRepository extends HSQLDBTransactionRepos int version = resultSet.getInt(1); int nonce = resultSet.getInt(2); - Service service = Service.valueOf(resultSet.getInt(3)); + int serviceInt = resultSet.getInt(3); int size = resultSet.getInt(4); boolean isDataRaw = resultSet.getBoolean(5); // NOT NULL, so no null to false DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH; @@ -44,7 +44,7 @@ public class HSQLDBArbitraryTransactionRepository extends HSQLDBTransactionRepos ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.valueOf(resultSet.getInt(12)); List payments = this.getPaymentsFromSignature(baseTransactionData.getSignature()); - return new ArbitraryTransactionData(baseTransactionData, version, service, nonce, size, name, + return new ArbitraryTransactionData(baseTransactionData, version, serviceInt, nonce, size, name, identifier, method, secret, compression, data, dataType, metadataHash, payments); } catch (SQLException e) { throw new DataException("Unable to fetch arbitrary transaction from repository", e); diff --git a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java index b1554e8d..6a5043cd 100644 --- a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java @@ -131,7 +131,7 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { payments.add(PaymentTransformer.fromByteBuffer(byteBuffer)); } - Service service = Service.valueOf(byteBuffer.getInt()); + int service = byteBuffer.getInt(); // We might be receiving hash of data instead of actual raw data boolean isRaw = byteBuffer.get() != 0; diff --git a/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java b/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java index d831eaf1..1290fd0a 100644 --- a/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java +++ b/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java @@ -45,7 +45,7 @@ public class ArbitraryTestTransaction extends TestTransaction { List payments = new ArrayList<>(); payments.add(new PaymentData(recipient, assetId, amount)); - return new ArbitraryTransactionData(generateBase(account), version, service, nonce, size,name, identifier, + return new ArbitraryTransactionData(generateBase(account), version, service.value, nonce, size,name, identifier, method, secret, compression, data, dataType, metadataHash, payments); } From 87ed49a2eead9fdd599c158163ab4768082d3b79 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 15:11:53 +0000 Subject: [PATCH 082/215] Added optional "filename" parameter when publishing data from a string or base64-encoded string. This causes the data to be stored with the requested filename, instead of generating a random one. Also, randomly generated filenames now use a timestamp instead of a random number. --- .../api/resource/ArbitraryResource.java | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 9510dced..3d21042e 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -795,7 +795,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, path, null, null, false, - fee, title, description, tags, category, preview); + fee, null, title, description, tags, category, preview); } @POST @@ -842,7 +842,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, path, null, null, false, - fee, title, description, tags, category, preview); + fee, null, title, description, tags, category, preview); } @@ -880,6 +880,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("filename") String filename, @QueryParam("fee") Long fee, @QueryParam("preview") Boolean preview, String base64) { @@ -890,7 +891,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, null, null, base64, false, - fee, title, description, tags, category, preview); + fee, filename, title, description, tags, category, preview); } @POST @@ -925,6 +926,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("filename") String filename, @QueryParam("fee") Long fee, @QueryParam("preview") Boolean preview, String base64) { @@ -935,7 +937,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64, false, - fee, title, description, tags, category, preview); + fee, filename, title, description, tags, category, preview); } @@ -982,7 +984,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, null, null, base64Zip, true, - fee, title, description, tags, category, preview); + fee, null, title, description, tags, category, preview); } @POST @@ -1027,7 +1029,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64Zip, true, - fee, title, description, tags, category, preview); + fee, null, title, description, tags, category, preview); } @@ -1067,6 +1069,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("filename") String filename, @QueryParam("fee") Long fee, @QueryParam("preview") Boolean preview, String string) { @@ -1077,7 +1080,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, null, string, null, false, - fee, title, description, tags, category, preview); + fee, filename, title, description, tags, category, preview); } @POST @@ -1114,6 +1117,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("filename") String filename, @QueryParam("fee") Long fee, @QueryParam("preview") Boolean preview, String string) { @@ -1124,7 +1128,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, null, string, null, false, - fee, title, description, tags, category, preview); + fee, filename, title, description, tags, category, preview); } @@ -1163,7 +1167,7 @@ public class ArbitraryResource { } private String upload(Service service, String name, String identifier, - String path, String string, String base64, boolean zipped, Long fee, + String path, String string, String base64, boolean zipped, Long fee, String filename, String title, String description, List tags, Category category, Boolean preview) { // Fetch public key from registered name @@ -1189,7 +1193,12 @@ public class ArbitraryResource { if (path == null) { // See if we have a string instead if (string != null) { - File tempFile = File.createTempFile("qortal-", ""); + if (filename == null) { + // Use current time as filename + filename = String.format("qortal-%d", NTP.getTime()); + } + java.nio.file.Path tempDirectory = Files.createTempDirectory("qortal-"); + File tempFile = Paths.get(tempDirectory.toString(), filename).toFile(); tempFile.deleteOnExit(); BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile.toPath().toString())); writer.write(string); @@ -1199,7 +1208,12 @@ public class ArbitraryResource { } // ... or base64 encoded raw data else if (base64 != null) { - File tempFile = File.createTempFile("qortal-", ""); + if (filename == null) { + // Use current time as filename + filename = String.format("qortal-%d", NTP.getTime()); + } + java.nio.file.Path tempDirectory = Files.createTempDirectory("qortal-"); + File tempFile = Paths.get(tempDirectory.toString(), filename).toFile(); tempFile.deleteOnExit(); Files.write(tempFile.toPath(), Base64.decode(base64)); path = tempFile.toPath().toString(); From 4ce3b2a7862f3c14fa89c154253070e3135def75 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 15:16:41 +0000 Subject: [PATCH 083/215] Added `GET /resource/filename/{service}/{name}/{identifier}` endpoint. This allows the filename of single file resources to be returned via the API. Useful to help determine to file format of the data. --- .../api/resource/ArbitraryResource.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 3d21042e..499b4874 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -278,6 +278,33 @@ public class ArbitraryResource { return ArbitraryTransactionUtils.getStatus(service, name, null, build); } + @GET + @Path("/resource/filename/{service}/{name}/{identifier}") + @Operation( + summary = "Get filename in published data", + description = "This causes a download of the data if it's not local. A filename will only be returned for single file resources.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @SecurityRequirement(name = "apiKey") + public String getResourceFilename(@HeaderParam(Security.API_KEY_HEADER) String apiKey, + @PathParam("service") Service service, + @PathParam("name") String name, + @PathParam("identifier") String identifier) { + + if (!Settings.getInstance().isQDNAuthBypassEnabled()) + Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey); + + return this.getFilename(service, name, identifier); + } + @GET @Path("/resource/status/{service}/{name}/{identifier}") @Operation( @@ -1350,4 +1377,30 @@ public class ArbitraryResource { throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage()); } } + + private String getFilename(Service service, String name, String identifier) { + + ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + try { + arbitraryDataReader.loadSynchronously(false); + java.nio.file.Path outputPath = arbitraryDataReader.getFilePath(); + if (outputPath == null) { + // Assume the resource doesn't exist + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, "File not found"); + } + + String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal"); + if (files.length == 1) { + LOGGER.info("File: {}", files[0]); + return files[0]; + } + else { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Filename not available for multi file resources"); + } + + } catch (Exception e) { + LOGGER.debug(String.format("Unable to load %s %s: %s", service, name, e.getMessage())); + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage()); + } + } } From 46b225cdfbdd66a723d8a6a7b3c875252de82845 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 15:18:36 +0000 Subject: [PATCH 084/215] Treat service as an int in other parts of ArbitraryTransactionData too --- .../qortal/data/transaction/ArbitraryTransactionData.java | 4 ++++ .../transaction/ArbitraryTransactionTransformer.java | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java index 477b1da0..3ab06ecc 100644 --- a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java @@ -138,6 +138,10 @@ public class ArbitraryTransactionData extends TransactionData { return Service.valueOf(this.service); } + public int getServiceInt() { + return this.service; + } + public int getNonce() { return this.nonce; } diff --git a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java index 6a5043cd..1ae80e1f 100644 --- a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.List; import com.google.common.base.Utf8; -import org.qortal.arbitrary.misc.Service; import org.qortal.crypto.Crypto; import org.qortal.data.PaymentData; import org.qortal.data.transaction.ArbitraryTransactionData; @@ -226,7 +225,7 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { for (PaymentData paymentData : payments) bytes.write(PaymentTransformer.toBytes(paymentData)); - bytes.write(Ints.toByteArray(arbitraryTransactionData.getService().value)); + bytes.write(Ints.toByteArray(arbitraryTransactionData.getServiceInt())); bytes.write((byte) (arbitraryTransactionData.getDataType() == DataType.RAW_DATA ? 1 : 0)); @@ -299,7 +298,7 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { bytes.write(PaymentTransformer.toBytes(paymentData)); } - bytes.write(Ints.toByteArray(arbitraryTransactionData.getService().value)); + bytes.write(Ints.toByteArray(arbitraryTransactionData.getServiceInt())); bytes.write(Ints.toByteArray(arbitraryTransactionData.getData().length)); From f9f34a61ace0cdc91fb1463621876a1ef0226adc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 15:18:36 +0000 Subject: [PATCH 085/215] Treat service as an int in other parts of ArbitraryTransactionData too --- .../qortal/data/transaction/ArbitraryTransactionData.java | 4 ++++ .../transaction/ArbitraryTransactionTransformer.java | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java index 477b1da0..3ab06ecc 100644 --- a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java @@ -138,6 +138,10 @@ public class ArbitraryTransactionData extends TransactionData { return Service.valueOf(this.service); } + public int getServiceInt() { + return this.service; + } + public int getNonce() { return this.nonce; } diff --git a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java index 6a5043cd..1ae80e1f 100644 --- a/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ArbitraryTransactionTransformer.java @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.List; import com.google.common.base.Utf8; -import org.qortal.arbitrary.misc.Service; import org.qortal.crypto.Crypto; import org.qortal.data.PaymentData; import org.qortal.data.transaction.ArbitraryTransactionData; @@ -226,7 +225,7 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { for (PaymentData paymentData : payments) bytes.write(PaymentTransformer.toBytes(paymentData)); - bytes.write(Ints.toByteArray(arbitraryTransactionData.getService().value)); + bytes.write(Ints.toByteArray(arbitraryTransactionData.getServiceInt())); bytes.write((byte) (arbitraryTransactionData.getDataType() == DataType.RAW_DATA ? 1 : 0)); @@ -299,7 +298,7 @@ public class ArbitraryTransactionTransformer extends TransactionTransformer { bytes.write(PaymentTransformer.toBytes(paymentData)); } - bytes.write(Ints.toByteArray(arbitraryTransactionData.getService().value)); + bytes.write(Ints.toByteArray(arbitraryTransactionData.getServiceInt())); bytes.write(Ints.toByteArray(arbitraryTransactionData.getData().length)); From 1b9afce21fa27a41b730e1f452c61a5f608dac57 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 16:39:23 +0000 Subject: [PATCH 086/215] Filename API renamed to `GET /resource/properties/{service}/{name}/{identifier}`. Now returns filename, size, and mimeType where available. --- .../org/qortal/api/model/FileProperties.java | 16 +++++++ .../api/resource/ArbitraryResource.java | 44 ++++++++++--------- 2 files changed, 40 insertions(+), 20 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/FileProperties.java diff --git a/src/main/java/org/qortal/api/model/FileProperties.java b/src/main/java/org/qortal/api/model/FileProperties.java new file mode 100644 index 00000000..c63506dd --- /dev/null +++ b/src/main/java/org/qortal/api/model/FileProperties.java @@ -0,0 +1,16 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class FileProperties { + + public String filename; + public String mimeType; + public Long size; + + public FileProperties() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 499b4874..73212e85 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -12,6 +12,8 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.io.*; +import java.net.FileNameMap; +import java.net.URLConnection; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; @@ -26,11 +28,13 @@ import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; +import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.ArrayUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bouncycastle.util.encoders.Base64; import org.qortal.api.*; +import org.qortal.api.model.FileProperties; import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; import org.qortal.arbitrary.*; import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; @@ -279,30 +283,26 @@ public class ArbitraryResource { } @GET - @Path("/resource/filename/{service}/{name}/{identifier}") + @Path("/resource/properties/{service}/{name}/{identifier}") @Operation( - summary = "Get filename in published data", - description = "This causes a download of the data if it's not local. A filename will only be returned for single file resources.", + summary = "Get properties of a QDN resource", + description = "This attempts a download of the data if it's not available locally. A filename will only be returned for single file resources. mimeType is only returned when it can be determined.", responses = { @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string" - ) - ) + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = FileProperties.class)) ) } ) @SecurityRequirement(name = "apiKey") - public String getResourceFilename(@HeaderParam(Security.API_KEY_HEADER) String apiKey, - @PathParam("service") Service service, - @PathParam("name") String name, - @PathParam("identifier") String identifier) { + public FileProperties getResourceProperties(@HeaderParam(Security.API_KEY_HEADER) String apiKey, + @PathParam("service") Service service, + @PathParam("name") String name, + @PathParam("identifier") String identifier) { if (!Settings.getInstance().isQDNAuthBypassEnabled()) Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey); - return this.getFilename(service, name, identifier); + return this.getFileProperties(service, name, identifier); } @GET @@ -1378,8 +1378,7 @@ public class ArbitraryResource { } } - private String getFilename(Service service, String name, String identifier) { - + private FileProperties getFileProperties(Service service, String name, String identifier) { ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); try { arbitraryDataReader.loadSynchronously(false); @@ -1389,15 +1388,20 @@ public class ArbitraryResource { throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, "File not found"); } + FileProperties fileProperties = new FileProperties(); + fileProperties.size = FileUtils.sizeOfDirectory(outputPath.toFile()); + String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal"); if (files.length == 1) { - LOGGER.info("File: {}", files[0]); - return files[0]; - } - else { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Filename not available for multi file resources"); + String filename = files[0]; + FileNameMap fileNameMap = URLConnection.getFileNameMap(); + String mimeType = fileNameMap.getContentTypeFor(filename); + fileProperties.filename = filename; + fileProperties.mimeType = mimeType; } + return fileProperties; + } catch (Exception e) { LOGGER.debug(String.format("Unable to load %s %s: %s", service, name, e.getMessage())); throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage()); From 5ecc633fd79a972757f8f4b2b6f6474f3835c8a3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 17:50:13 +0000 Subject: [PATCH 087/215] `GET /arbitrary/resource/properties/{service}/{name}/{identifier}` can now extract the MIME type from the file's contents as an alternative to using the filename. --- pom.xml | 6 ++++++ .../org/qortal/api/resource/ArbitraryResource.java | 10 ++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 35c77bcc..333d898a 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ 4.10 1.45.1 3.19.4 + 1.17 src/main/java @@ -728,5 +729,10 @@ protobuf-java ${protobuf.version} + + com.j256.simplemagic + simplemagic + ${simplemagic.version} + diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 73212e85..84e2d3b0 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -1,6 +1,8 @@ package org.qortal.api.resource; import com.google.common.primitives.Bytes; +import com.j256.simplemagic.ContentInfo; +import com.j256.simplemagic.ContentInfoUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -12,8 +14,6 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.io.*; -import java.net.FileNameMap; -import java.net.URLConnection; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; @@ -1394,8 +1394,10 @@ public class ArbitraryResource { String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal"); if (files.length == 1) { String filename = files[0]; - FileNameMap fileNameMap = URLConnection.getFileNameMap(); - String mimeType = fileNameMap.getContentTypeFor(filename); + java.nio.file.Path filePath = Paths.get(outputPath.toString(), files[0]); + ContentInfoUtil util = new ContentInfoUtil(); + ContentInfo info = util.findMatch(filePath.toFile()); + String mimeType = (info != null) ? info.getMimeType() : null; fileProperties.filename = filename; fileProperties.mimeType = mimeType; } From 3a64336d9f287c433e97fde18cfd0041cbc1d5f9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 17:57:07 +0000 Subject: [PATCH 088/215] If the MIME type can't be determined from the file's contents, fall back to using the filename. --- .../org/qortal/api/resource/ArbitraryResource.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 84e2d3b0..c4c19652 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -14,6 +14,8 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.io.*; +import java.net.FileNameMap; +import java.net.URLConnection; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; @@ -1397,7 +1399,16 @@ public class ArbitraryResource { java.nio.file.Path filePath = Paths.get(outputPath.toString(), files[0]); ContentInfoUtil util = new ContentInfoUtil(); ContentInfo info = util.findMatch(filePath.toFile()); - String mimeType = (info != null) ? info.getMimeType() : null; + String mimeType; + if (info != null) { + // Attempt to extract MIME type from file contents + mimeType = info.getMimeType(); + } + else { + // Fall back to using the filename + FileNameMap fileNameMap = URLConnection.getFileNameMap(); + mimeType = fileNameMap.getContentTypeFor(filename); + } fileProperties.filename = filename; fileProperties.mimeType = mimeType; } From 519bb10c609d4fa95569d2844df96e519f380d79 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Mar 2023 18:15:28 +0000 Subject: [PATCH 089/215] Updated docs for `PUBLISH_QDN_RESOURCE`, to include "filename" parameter. --- Q-Apps.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index a0a7e344..841d8cb3 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -284,12 +284,13 @@ _Requires user approval_ await qortalRequest({ action: "PUBLISH_QDN_RESOURCE", name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list - service: "WEBSITE", + service: "IMAGE", data64: "base64_encoded_data", - title: "Title", - description: "Description", - category: "TECHNOLOGY", - tags: ["tag1", "tag2", "tag3", "tag4", "tag5"] + filename: "image.jpg", // Optional - to help apps determine the file's type + // title: "Title", // Optional + // description: "Description", // Optional + // category: "TECHNOLOGY", // Optional + // tags: ["tag1", "tag2", "tag3", "tag4", "tag5"] // Optional }); ``` From 713fd4f0c6c48173b7cf904a561b4812d465f689 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Mar 2023 08:56:06 +0000 Subject: [PATCH 090/215] Added `GET_QDN_RESOURCE_PROPERTIES` Q-App action. --- Q-Apps.md | 12 ++++++++++++ src/main/resources/q-apps/q-apps.js | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 841d8cb3..1f237ad3 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -141,6 +141,7 @@ Here is a list of currently supported actions: - LIST_QDN_RESOURCES - SEARCH_QDN_RESOURCES - GET_QDN_RESOURCE_STATUS +- GET_QDN_RESOURCE_PROPERTIES - FETCH_QDN_RESOURCE - PUBLISH_QDN_RESOURCE - GET_WALLET_BALANCE @@ -278,6 +279,17 @@ let res = await qortalRequest({ }); ``` +### Get QDN resource properties +``` +let res = await qortalRequest({ + action: "GET_QDN_RESOURCE_PROPERTIES", + name: "QortalDemo", + service: "THUMBNAIL", + identifier: "qortal_avatar" // Optional +}); +// Returns: filename, size, mimeType (where available) +``` + ### Publish QDN resource _Requires user approval_ ``` diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 2d1bfeb5..7a5df87f 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -208,6 +208,12 @@ window.addEventListener("message", (event) => { response = httpGet(url); break; + case "GET_QDN_RESOURCE_PROPERTIES": + let identifier = (data.identifier != null) ? data.identifier : "default"; + url = "/arbitrary/resource/properties/" + data.service + "/" + data.name + "/" + identifier; + response = httpGet(url); + break; + case "SEARCH_CHAT_MESSAGES": url = "/chat/messages?"; if (data.before != null) url = url.concat("&before=" + data.before); From 2848ae695cc93aacf609091f8e751eac9082f675 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Mar 2023 10:17:56 +0000 Subject: [PATCH 091/215] More improvements to Service handling. --- .../api/resource/ArbitraryResource.java | 3 ++ .../PirateChainWalletController.java | 2 +- .../ArbitraryDataCleanupManager.java | 2 +- .../arbitrary/ArbitraryDataManager.java | 7 ++- .../HSQLDBArbitraryTransactionRepository.java | 2 +- .../arbitrary/ArbitraryTransactionTests.java | 48 ++++++++++++++++++- 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index c4c19652..8dbf467d 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -540,6 +540,9 @@ public class ArbitraryResource { } for (ArbitraryTransactionData transactionData : transactionDataList) { + if (transactionData.getService() == null) { + continue; + } ArbitraryResourceInfo arbitraryResourceInfo = new ArbitraryResourceInfo(); arbitraryResourceInfo.name = transactionData.getName(); arbitraryResourceInfo.service = transactionData.getService(); diff --git a/src/main/java/org/qortal/controller/PirateChainWalletController.java b/src/main/java/org/qortal/controller/PirateChainWalletController.java index 333c2cda..90e65329 100644 --- a/src/main/java/org/qortal/controller/PirateChainWalletController.java +++ b/src/main/java/org/qortal/controller/PirateChainWalletController.java @@ -163,7 +163,7 @@ public class PirateChainWalletController extends Thread { // Library not found, so check if we've fetched the resource from QDN ArbitraryTransactionData t = this.getTransactionData(repository); - if (t == null) { + if (t == null || t.getService() == null) { // Can't find the transaction - maybe on a different chain? return; } diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java index 34acf0cb..f3d9d8cd 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java @@ -137,7 +137,7 @@ public class ArbitraryDataCleanupManager extends Thread { // Fetch the transaction data ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); - if (arbitraryTransactionData == null) { + if (arbitraryTransactionData == null || arbitraryTransactionData.getService() == null) { continue; } diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java index 6b3f0160..99e490b6 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java @@ -398,6 +398,11 @@ public class ArbitraryDataManager extends Thread { // Entrypoint to request new metadata from peers public ArbitraryDataTransactionMetadata fetchMetadata(ArbitraryTransactionData arbitraryTransactionData) { + if (arbitraryTransactionData.getService() == null) { + // Can't fetch metadata without a valid service + return null; + } + ArbitraryDataResource resource = new ArbitraryDataResource( arbitraryTransactionData.getName(), ArbitraryDataFile.ResourceIdType.NAME, @@ -489,7 +494,7 @@ public class ArbitraryDataManager extends Thread { public void invalidateCache(ArbitraryTransactionData arbitraryTransactionData) { String signature58 = Base58.encode(arbitraryTransactionData.getSignature()); - if (arbitraryTransactionData.getName() != null) { + if (arbitraryTransactionData.getName() != null && arbitraryTransactionData.getService() != null) { String resourceId = arbitraryTransactionData.getName().toLowerCase(); Service service = arbitraryTransactionData.getService(); String identifier = arbitraryTransactionData.getIdentifier(); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java index 345338c7..57b75a29 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java @@ -66,7 +66,7 @@ public class HSQLDBArbitraryTransactionRepository extends HSQLDBTransactionRepos HSQLDBSaver saveHelper = new HSQLDBSaver("ArbitraryTransactions"); saveHelper.bind("signature", arbitraryTransactionData.getSignature()).bind("sender", arbitraryTransactionData.getSenderPublicKey()) - .bind("version", arbitraryTransactionData.getVersion()).bind("service", arbitraryTransactionData.getService().value) + .bind("version", arbitraryTransactionData.getVersion()).bind("service", arbitraryTransactionData.getServiceInt()) .bind("nonce", arbitraryTransactionData.getNonce()).bind("size", arbitraryTransactionData.getSize()) .bind("is_data_raw", arbitraryTransactionData.getDataType() == DataType.RAW_DATA).bind("data", arbitraryTransactionData.getData()) .bind("metadata_hash", arbitraryTransactionData.getMetadataHash()).bind("name", arbitraryTransactionData.getName()) diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java index 2c2d52b2..01c1f0f3 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java @@ -6,12 +6,14 @@ import org.junit.Test; import org.qortal.account.PrivateKeyAccount; import org.qortal.arbitrary.ArbitraryDataFile; import org.qortal.arbitrary.ArbitraryDataTransactionBuilder; -import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataManager; +import org.qortal.data.PaymentData; import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -25,9 +27,11 @@ import org.qortal.transaction.Transaction; import org.qortal.utils.Base58; import org.qortal.utils.NTP; -import javax.xml.crypto.Data; import java.io.IOException; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; import static org.junit.Assert.*; @@ -423,4 +427,44 @@ public class ArbitraryTransactionTests extends Common { assertTrue(transaction.isSignatureValid()); } } + + @Test + public void testInvalidService() { + byte[] randomHash = new byte[32]; + new Random().nextBytes(randomHash); + + byte[] lastReference = new byte[64]; + new Random().nextBytes(lastReference); + + Long now = NTP.getTime(); + + final BaseTransactionData baseTransactionData = new BaseTransactionData(now, Group.NO_GROUP, + lastReference, randomHash, 0L, null); + final String name = "test"; + final String identifier = "test"; + final ArbitraryTransactionData.Method method = ArbitraryTransactionData.Method.PUT; + final ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.ZIP; + final int size = 999; + final int version = 5; + final int nonce = 0; + final byte[] secret = randomHash; + final ArbitraryTransactionData.DataType dataType = ArbitraryTransactionData.DataType.DATA_HASH; + final byte[] digest = randomHash; + final byte[] metadataHash = null; + final List payments = new ArrayList<>(); + final int validService = Service.IMAGE.value; + final int invalidService = 99999999; + + // Try with valid service + ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, + version, validService, nonce, size, name, identifier, method, + secret, compression, digest, dataType, metadataHash, payments); + assertEquals(Service.IMAGE, transactionData.getService()); + + // Try with invalid service + transactionData = new ArbitraryTransactionData(baseTransactionData, + version, invalidService, nonce, size, name, identifier, method, + secret, compression, digest, dataType, metadataHash, payments); + assertNull(transactionData.getService()); + } } From 73a7c1fe7e678899aba3d729725a77747231f877 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Mar 2023 10:17:56 +0000 Subject: [PATCH 092/215] More improvements to Service handling. --- .../api/resource/ArbitraryResource.java | 3 ++ .../PirateChainWalletController.java | 2 +- .../ArbitraryDataCleanupManager.java | 2 +- .../arbitrary/ArbitraryDataManager.java | 7 ++- .../HSQLDBArbitraryTransactionRepository.java | 2 +- .../arbitrary/ArbitraryTransactionTests.java | 48 ++++++++++++++++++- 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 235e3edc..f60fe9f0 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -501,6 +501,9 @@ public class ArbitraryResource { } for (ArbitraryTransactionData transactionData : transactionDataList) { + if (transactionData.getService() == null) { + continue; + } ArbitraryResourceInfo arbitraryResourceInfo = new ArbitraryResourceInfo(); arbitraryResourceInfo.name = transactionData.getName(); arbitraryResourceInfo.service = transactionData.getService(); diff --git a/src/main/java/org/qortal/controller/PirateChainWalletController.java b/src/main/java/org/qortal/controller/PirateChainWalletController.java index 333c2cda..90e65329 100644 --- a/src/main/java/org/qortal/controller/PirateChainWalletController.java +++ b/src/main/java/org/qortal/controller/PirateChainWalletController.java @@ -163,7 +163,7 @@ public class PirateChainWalletController extends Thread { // Library not found, so check if we've fetched the resource from QDN ArbitraryTransactionData t = this.getTransactionData(repository); - if (t == null) { + if (t == null || t.getService() == null) { // Can't find the transaction - maybe on a different chain? return; } diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java index 34acf0cb..f3d9d8cd 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java @@ -137,7 +137,7 @@ public class ArbitraryDataCleanupManager extends Thread { // Fetch the transaction data ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); - if (arbitraryTransactionData == null) { + if (arbitraryTransactionData == null || arbitraryTransactionData.getService() == null) { continue; } diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java index 6b3f0160..99e490b6 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java @@ -398,6 +398,11 @@ public class ArbitraryDataManager extends Thread { // Entrypoint to request new metadata from peers public ArbitraryDataTransactionMetadata fetchMetadata(ArbitraryTransactionData arbitraryTransactionData) { + if (arbitraryTransactionData.getService() == null) { + // Can't fetch metadata without a valid service + return null; + } + ArbitraryDataResource resource = new ArbitraryDataResource( arbitraryTransactionData.getName(), ArbitraryDataFile.ResourceIdType.NAME, @@ -489,7 +494,7 @@ public class ArbitraryDataManager extends Thread { public void invalidateCache(ArbitraryTransactionData arbitraryTransactionData) { String signature58 = Base58.encode(arbitraryTransactionData.getSignature()); - if (arbitraryTransactionData.getName() != null) { + if (arbitraryTransactionData.getName() != null && arbitraryTransactionData.getService() != null) { String resourceId = arbitraryTransactionData.getName().toLowerCase(); Service service = arbitraryTransactionData.getService(); String identifier = arbitraryTransactionData.getIdentifier(); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java index 345338c7..57b75a29 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBArbitraryTransactionRepository.java @@ -66,7 +66,7 @@ public class HSQLDBArbitraryTransactionRepository extends HSQLDBTransactionRepos HSQLDBSaver saveHelper = new HSQLDBSaver("ArbitraryTransactions"); saveHelper.bind("signature", arbitraryTransactionData.getSignature()).bind("sender", arbitraryTransactionData.getSenderPublicKey()) - .bind("version", arbitraryTransactionData.getVersion()).bind("service", arbitraryTransactionData.getService().value) + .bind("version", arbitraryTransactionData.getVersion()).bind("service", arbitraryTransactionData.getServiceInt()) .bind("nonce", arbitraryTransactionData.getNonce()).bind("size", arbitraryTransactionData.getSize()) .bind("is_data_raw", arbitraryTransactionData.getDataType() == DataType.RAW_DATA).bind("data", arbitraryTransactionData.getData()) .bind("metadata_hash", arbitraryTransactionData.getMetadataHash()).bind("name", arbitraryTransactionData.getName()) diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java index 2c2d52b2..01c1f0f3 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java @@ -6,12 +6,14 @@ import org.junit.Test; import org.qortal.account.PrivateKeyAccount; import org.qortal.arbitrary.ArbitraryDataFile; import org.qortal.arbitrary.ArbitraryDataTransactionBuilder; -import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataManager; +import org.qortal.data.PaymentData; import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -25,9 +27,11 @@ import org.qortal.transaction.Transaction; import org.qortal.utils.Base58; import org.qortal.utils.NTP; -import javax.xml.crypto.Data; import java.io.IOException; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; import static org.junit.Assert.*; @@ -423,4 +427,44 @@ public class ArbitraryTransactionTests extends Common { assertTrue(transaction.isSignatureValid()); } } + + @Test + public void testInvalidService() { + byte[] randomHash = new byte[32]; + new Random().nextBytes(randomHash); + + byte[] lastReference = new byte[64]; + new Random().nextBytes(lastReference); + + Long now = NTP.getTime(); + + final BaseTransactionData baseTransactionData = new BaseTransactionData(now, Group.NO_GROUP, + lastReference, randomHash, 0L, null); + final String name = "test"; + final String identifier = "test"; + final ArbitraryTransactionData.Method method = ArbitraryTransactionData.Method.PUT; + final ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.ZIP; + final int size = 999; + final int version = 5; + final int nonce = 0; + final byte[] secret = randomHash; + final ArbitraryTransactionData.DataType dataType = ArbitraryTransactionData.DataType.DATA_HASH; + final byte[] digest = randomHash; + final byte[] metadataHash = null; + final List payments = new ArrayList<>(); + final int validService = Service.IMAGE.value; + final int invalidService = 99999999; + + // Try with valid service + ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, + version, validService, nonce, size, name, identifier, method, + secret, compression, digest, dataType, metadataHash, payments); + assertEquals(Service.IMAGE, transactionData.getService()); + + // Try with invalid service + transactionData = new ArbitraryTransactionData(baseTransactionData, + version, invalidService, nonce, size, name, identifier, method, + secret, compression, digest, dataType, metadataHash, payments); + assertNull(transactionData.getService()); + } } From 2a7a2d3220bfe96375c11fae384772896564884a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Mar 2023 10:41:37 +0000 Subject: [PATCH 093/215] Added gateway-specific Q-Apps handler. For now, just show a warning alert if an app requires authentication / interactive features. --- src/main/java/org/qortal/api/HTMLParser.java | 8 ++++ .../org/qortal/api/resource/AppsResource.java | 26 +++++++++++++ src/main/resources/q-apps/q-apps-gateway.js | 38 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 src/main/resources/q-apps/q-apps-gateway.js diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index dbc75243..d4e3bac1 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -7,6 +7,8 @@ import org.jsoup.nodes.Document; import org.jsoup.select.Elements; import org.qortal.arbitrary.misc.Service; +import java.util.Objects; + public class HTMLParser { private static final Logger LOGGER = LogManager.getLogger(HTMLParser.class); @@ -43,6 +45,12 @@ public class HTMLParser { String qAppsScriptElement = String.format("", this.qdnContext, theme, service, name, identifier, path); + String qdnContextVar = String.format("", this.qdnContext, theme, service, name, identifier, path, baseUrl); head.get(0).prepend(qdnContextVar); // Add base href tag - String baseElement = String.format("", baseUrl); + String baseElement = String.format("", baseUrl); head.get(0).prepend(baseElement); // Add meta charset tag From 32967791258f93d22d7b282ed1503386e8dafb1d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Apr 2023 17:11:20 +0100 Subject: [PATCH 121/215] Update address bar when navigating within an app. --- src/main/resources/q-apps/q-apps.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index ff39ce72..56f13717 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -385,6 +385,20 @@ document.addEventListener('DOMContentLoaded', () => { }); }); +/** + * Handle app navigation + */ +navigation.addEventListener('navigate', (event) => { + let pathname = new URL(event.destination.url).pathname; + qortalRequest({ + action: "QDN_RESOURCE_DISPLAYED", + service: _qdnService, + name: _qdnName, + identifier: _qdnIdentifier, + path: (pathname.startsWith(_qdnBase)) ? pathname.slice(_qdnBase.length) : pathname + }); +}); + /** * Intercept image loads from the DOM */ From ce52b3949501cccf66240093a077686b9f48c664 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Apr 2023 17:55:41 +0100 Subject: [PATCH 122/215] Fixed bug with base path. --- src/main/java/org/qortal/api/HTMLParser.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 3cba9a62..eac813a9 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -24,8 +24,7 @@ public class HTMLParser { public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data, String qdnContext, Service service, String identifier, String theme) { - String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : ""; - this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; + this.linkPrefix = usePrefix ? String.format("%s/%s", prefix, resourceId) : ""; this.data = data; this.qdnContext = qdnContext; this.resourceId = resourceId; From 7f53983d77cd1b197b8cdd27b8c7c949da321d2d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Apr 2023 18:21:19 +0100 Subject: [PATCH 123/215] Added support for hash routing in URL shown in address bar. --- src/main/resources/q-apps/q-apps.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 56f13717..0661a095 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -389,13 +389,14 @@ document.addEventListener('DOMContentLoaded', () => { * Handle app navigation */ navigation.addEventListener('navigate', (event) => { - let pathname = new URL(event.destination.url).pathname; + const url = new URL(event.destination.url); + let fullpath = url.pathname + url.hash; qortalRequest({ action: "QDN_RESOURCE_DISPLAYED", service: _qdnService, name: _qdnName, identifier: _qdnIdentifier, - path: (pathname.startsWith(_qdnBase)) ? pathname.slice(_qdnBase.length) : pathname + path: (fullpath.startsWith(_qdnBase)) ? fullpath.slice(_qdnBase.length) : fullpath }); }); From e2a2a1f95661a4f13c461e7af89b62c3e4c24c14 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 11 Apr 2023 19:03:56 +0100 Subject: [PATCH 124/215] Fixed bug with GET_QDN_RESOURCE_URL action. --- src/main/resources/q-apps/q-apps.js | 64 +++++++++++------------------ 1 file changed, 23 insertions(+), 41 deletions(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 0661a095..57ac70da 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -21,7 +21,7 @@ function httpGetAsyncWithEvent(event, url) { }) .catch((error) => { - let res = new Object(); + let res = {}; res.error = error; handleResponse(JSON.stringify(res), responseText); }) @@ -160,30 +160,27 @@ window.addEventListener("message", (event) => { console.log("Core received event: " + JSON.stringify(event.data)); let url; - let response; let data = event.data; switch (data.action) { case "GET_ACCOUNT_DATA": - response = httpGetAsyncWithEvent(event, "/addresses/" + data.address); - break; + return httpGetAsyncWithEvent(event, "/addresses/" + data.address); case "GET_ACCOUNT_NAMES": - response = httpGetAsyncWithEvent(event, "/names/address/" + data.address); - break; + return httpGetAsyncWithEvent(event, "/names/address/" + data.address); case "GET_NAME_DATA": - response = httpGetAsyncWithEvent(event, "/names/" + data.name); - break; + return httpGetAsyncWithEvent(event, "/names/" + data.name); case "GET_QDN_RESOURCE_URL": - response = buildResourceUrl(data.service, data.name, data.identifier, data.path, false); - break; + const response = buildResourceUrl(data.service, data.name, data.identifier, data.path, false); + handleResponse(event, response); + return; case "LINK_TO_QDN_RESOURCE": if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path, true); - break; + return; case "LIST_QDN_RESOURCES": url = "/arbitrary/resources?"; @@ -196,8 +193,7 @@ window.addEventListener("message", (event) => { if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "SEARCH_QDN_RESOURCES": url = "/arbitrary/resources/search?"; @@ -212,8 +208,7 @@ window.addEventListener("message", (event) => { if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "FETCH_QDN_RESOURCE": url = "/arbitrary/" + data.service + "/" + data.name; @@ -222,20 +217,17 @@ window.addEventListener("message", (event) => { if (data.filepath != null) url = url.concat("&filepath=" + data.filepath); if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString()) if (data.encoding != null) url = url.concat("&encoding=" + data.encoding); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "GET_QDN_RESOURCE_STATUS": url = "/arbitrary/resource/status/" + data.service + "/" + data.name; if (data.identifier != null) url = url.concat("/" + data.identifier); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "GET_QDN_RESOURCE_PROPERTIES": let identifier = (data.identifier != null) ? data.identifier : "default"; url = "/arbitrary/resource/properties/" + data.service + "/" + data.name + "/" + identifier; - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "SEARCH_CHAT_MESSAGES": url = "/chat/messages?"; @@ -249,32 +241,27 @@ window.addEventListener("message", (event) => { if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "LIST_GROUPS": url = "/groups?"; if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "GET_BALANCE": url = "/addresses/balance/" + data.address; if (data.assetId != null) url = url.concat("&assetId=" + data.assetId); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "GET_AT": url = "/at" + data.atAddress; - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "GET_AT_DATA": url = "/at/" + data.atAddress + "/data"; - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "LIST_ATS": url = "/at/byfunction/" + data.codeHash58 + "?"; @@ -282,8 +269,7 @@ window.addEventListener("message", (event) => { if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "FETCH_BLOCK": if (data.signature != null) { @@ -293,16 +279,14 @@ window.addEventListener("message", (event) => { } url = url.concat("?"); if (data.includeOnlineSignatures != null) url = url.concat("&includeOnlineSignatures=" + data.includeOnlineSignatures); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "FETCH_BLOCK_RANGE": url = "/blocks/range/" + data.height + "?"; if (data.count != null) url = url.concat("&count=" + data.count); if (data.reverse != null) url = url.concat("&reverse=" + data.reverse); if (data.includeOnlineSignatures != null) url = url.concat("&includeOnlineSignatures=" + data.includeOnlineSignatures); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "SEARCH_TRANSACTIONS": url = "/transactions/search?"; @@ -315,15 +299,13 @@ window.addEventListener("message", (event) => { if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); case "GET_PRICE": url = "/crosschain/price/" + data.blockchain + "?"; if (data.maxtrades != null) url = url.concat("&maxtrades=" + data.maxtrades); if (data.inverse != null) url = url.concat("&inverse=" + data.inverse); - response = httpGetAsyncWithEvent(event, url); - break; + return httpGetAsyncWithEvent(event, url); default: // Pass to parent (UI), in case they can fulfil this request From e60cd96514217a63b7460f62a81e0ac8e099b10a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Apr 2023 11:02:27 +0100 Subject: [PATCH 125/215] Fixed occasional NPE seen in ArbitraryDataFileMessage --- .../controller/arbitrary/ArbitraryDataFileManager.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java index 34db2fde..48c41496 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java @@ -231,6 +231,11 @@ public class ArbitraryDataFileManager extends Thread { arbitraryDataFile = existingFile; } + if (arbitraryDataFile == null) { + // We don't have a file, so give up here + return null; + } + // We might want to forward the request to the peer that originally requested it this.handleArbitraryDataFileForwarding(requestingPeer, new ArbitraryDataFileMessage(signature, arbitraryDataFile), originalMessage); From b08e845dbbcfc7b6e00b87a94922235fae32668e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Apr 2023 16:24:27 +0100 Subject: [PATCH 126/215] Updated docs to include sending of foreign coins --- Q-Apps.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 67b4b86a..3cc48b26 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -454,6 +454,17 @@ await qortalRequest({ }); ``` +### Send foreign coin to address +_Requires user approval_ +``` +await qortalRequest({ + action: "SEND_COIN", + coin: "LTC", + destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y", + amount: 1.00000000, // 1 LTC + fee: 0.00000020 // fee per byte +}); + ### Search or list chat messages ``` From 20893879ca6599926788674619e53fb682fed8a2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Apr 2023 17:17:05 +0100 Subject: [PATCH 127/215] Allow multiple name parameters to optionally be included in GET /arbitrary/resources/search Also updated SEARCH_QDN_RESOURCES action, to allow multiple names to be optionally specified via the "names" parameter. --- Q-Apps.md | 18 ++++++++++++++++++ .../qortal/api/resource/ArbitraryResource.java | 4 ++-- .../qortal/repository/ArbitraryRepository.java | 2 +- .../hsqldb/HSQLDBArbitraryRepository.java | 18 ++++++++++++------ src/main/resources/q-apps/q-apps.js | 1 + 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 3cc48b26..860a2a3f 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -323,6 +323,24 @@ let res = await qortalRequest({ }); ``` +### Search QDN resources (multiple names) +``` +let res = await qortalRequest({ + action: "SEARCH_QDN_RESOURCES", + service: "THUMBNAIL", + query: "search query goes here", // Optional - searches both "identifier" and "name" fields + identifier: "search query goes here", // Optional - searches only the "identifier" field + names: ["QortalDemo", "crowetic", "AlphaX"], // Optional - searches only the "name" field for any of the supplied names + prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters + default: false, // Optional - if true, only resources without identifiers are returned + includeStatus: false, // Optional - will take time to respond, so only request if necessary + includeMetadata: false, // Optional - will take time to respond, so only request if necessary + limit: 100, + offset: 0, + reverse: true +}); +``` + ### Fetch QDN single file resource ``` let res = await qortalRequest({ diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 5b839dce..7adf1cec 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -171,7 +171,7 @@ public class ArbitraryResource { @QueryParam("service") Service service, @Parameter(description = "Query (searches both name and identifier fields)") @QueryParam("query") String query, @Parameter(description = "Identifier (searches identifier field only)") @QueryParam("identifier") String identifier, - @Parameter(description = "Name (searches name field only)") @QueryParam("name") String name, + @Parameter(description = "Name (searches name field only)") @QueryParam("name") List names, @Parameter(description = "Prefix only (if true, only the beginning of fields are matched)") @QueryParam("prefix") Boolean prefixOnly, @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @@ -186,7 +186,7 @@ public class ArbitraryResource { boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly); List resources = repository.getArbitraryRepository() - .searchArbitraryResources(service, query, identifier, name, usePrefixOnly, defaultRes, limit, offset, reverse); + .searchArbitraryResources(service, query, identifier, names, usePrefixOnly, defaultRes, limit, offset, reverse); if (resources == null) { return new ArrayList<>(); diff --git a/src/main/java/org/qortal/repository/ArbitraryRepository.java b/src/main/java/org/qortal/repository/ArbitraryRepository.java index 5581bc59..cd1b582b 100644 --- a/src/main/java/org/qortal/repository/ArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/ArbitraryRepository.java @@ -26,7 +26,7 @@ public interface ArbitraryRepository { public List getArbitraryResources(Service service, String identifier, List names, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; - public List searchArbitraryResources(Service service, String query, String identifier, String name, boolean prefixOnly, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; public List getArbitraryResourceCreatorNames(Service service, String identifier, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index bbd7de9a..443f7c6b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -360,7 +360,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { } @Override - public List searchArbitraryResources(Service service, String query, String identifier, String name, boolean prefixOnly, + public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); List bindParams = new ArrayList<>(); @@ -404,11 +404,17 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { } // Handle name matches - if (name != null) { - // Search anywhere in the identifier, unless "prefixOnly" has been requested - String queryWildcard = prefixOnly ? String.format("%s%%", name.toLowerCase()) : String.format("%%%s%%", name.toLowerCase()); - sql.append(" AND LCASE(name) LIKE ?"); - bindParams.add(queryWildcard); + if (names != null && !names.isEmpty()) { + sql.append(" AND ("); + + for (int i = 0; i < names.size(); ++i) { + // Search anywhere in the name, unless "prefixOnly" has been requested + String queryWildcard = prefixOnly ? String.format("%s%%", names.get(i).toLowerCase()) : String.format("%%%s%%", names.get(i).toLowerCase()); + if (i > 0) sql.append(" OR "); + sql.append("LCASE(name) LIKE ?"); + bindParams.add(queryWildcard); + } + sql.append(")"); } sql.append(" GROUP BY name, service, identifier ORDER BY date_created"); diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 57ac70da..28b81692 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -201,6 +201,7 @@ window.addEventListener("message", (event) => { if (data.query != null) url = url.concat("&query=" + data.query); if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); if (data.name != null) url = url.concat("&name=" + data.name); + if (data.names != null) data.names.forEach((x, i) => url = url.concat("&name=" + x)); if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString()); if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); From ea7a2224d3e1f217ee8ee76c23d8d94f171241f3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Apr 2023 17:44:06 +0100 Subject: [PATCH 128/215] Allow the name of a list to be specified as a "namefilter" param in GET /arbitrary/resources/search. Any names in the list will be included in the search (same as if they were specified manually via &name=). --- .../java/org/qortal/api/resource/ArbitraryResource.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 7adf1cec..5725c155 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -172,6 +172,7 @@ public class ArbitraryResource { @Parameter(description = "Query (searches both name and identifier fields)") @QueryParam("query") String query, @Parameter(description = "Identifier (searches identifier field only)") @QueryParam("identifier") String identifier, @Parameter(description = "Name (searches name field only)") @QueryParam("name") List names, + @Parameter(description = "Filter names by list (partial matches allowed)") @QueryParam("namefilter") String nameListFilter, @Parameter(description = "Prefix only (if true, only the beginning of fields are matched)") @QueryParam("prefix") Boolean prefixOnly, @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @@ -185,6 +186,11 @@ public class ArbitraryResource { boolean defaultRes = Boolean.TRUE.equals(defaultResource); boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly); + if (nameListFilter != null) { + // Load names from supplied list of names + names.addAll(ResourceListManager.getInstance().getStringsInList(nameListFilter)); + } + List resources = repository.getArbitraryRepository() .searchArbitraryResources(service, query, identifier, names, usePrefixOnly, defaultRes, limit, offset, reverse); From 892b667f869f5ea1a0af357a83f4e89d095616d1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Apr 2023 09:57:26 +0100 Subject: [PATCH 129/215] Fixed console errors seen in certain cases. --- src/main/resources/q-apps/q-apps.js | 57 +++++++++++++++-------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 28b81692..f72d8794 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -355,33 +355,7 @@ else if (document.attachEvent) { document.attachEvent('onclick', interceptClickEvent); } -/** - * Send current page details to UI - */ -document.addEventListener('DOMContentLoaded', () => { - qortalRequest({ - action: "QDN_RESOURCE_DISPLAYED", - service: _qdnService, - name: _qdnName, - identifier: _qdnIdentifier, - path: _qdnPath - }); -}); -/** - * Handle app navigation - */ -navigation.addEventListener('navigate', (event) => { - const url = new URL(event.destination.url); - let fullpath = url.pathname + url.hash; - qortalRequest({ - action: "QDN_RESOURCE_DISPLAYED", - service: _qdnService, - name: _qdnName, - identifier: _qdnIdentifier, - path: (fullpath.startsWith(_qdnBase)) ? fullpath.slice(_qdnBase.length) : fullpath - }); -}); /** * Intercept image loads from the DOM @@ -490,4 +464,33 @@ const qortalRequest = (request) => * Make a Qortal (Q-Apps) request with a custom timeout, specified in milliseconds */ const qortalRequestWithTimeout = (request, timeout) => - Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(timeout, "The request timed out")]); \ No newline at end of file + Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(timeout, "The request timed out")]); + + +/** + * Send current page details to UI + */ +document.addEventListener('DOMContentLoaded', () => { + qortalRequest({ + action: "QDN_RESOURCE_DISPLAYED", + service: _qdnService, + name: _qdnName, + identifier: _qdnIdentifier, + path: _qdnPath + }); +}); + +/** + * Handle app navigation + */ +navigation.addEventListener('navigate', (event) => { + const url = new URL(event.destination.url); + let fullpath = url.pathname + url.hash; + qortalRequest({ + action: "QDN_RESOURCE_DISPLAYED", + service: _qdnService, + name: _qdnName, + identifier: _qdnIdentifier, + path: (fullpath.startsWith(_qdnBase)) ? fullpath.slice(_qdnBase.length) : fullpath + }); +}); From ed055604138f8bfb9e37a454bba913f4c6d0d8b8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Apr 2023 10:11:33 +0100 Subject: [PATCH 130/215] Gateway auth alert box replaced with a modal overlay in the lower right hand corner of the screen. --- src/main/resources/q-apps/q-apps-gateway.js | 36 ++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/main/resources/q-apps/q-apps-gateway.js b/src/main/resources/q-apps/q-apps-gateway.js index a861830d..d8d15d06 100644 --- a/src/main/resources/q-apps/q-apps-gateway.js +++ b/src/main/resources/q-apps/q-apps-gateway.js @@ -1,5 +1,37 @@ console.log("Gateway mode"); +function qdnGatewayShowModal(message) { + const modalElementId = "qdnGatewayModal"; + + if (document.getElementById(modalElementId) != null) { + document.body.removeChild(document.getElementById(modalElementId)); + } + + var modalElement = document.createElement('div'); + modalElement.style.cssText = 'position:fixed; z-index:99999; background:#fff; padding:20px; border-radius:5px; font-family:sans-serif; bottom:20px; right:20px; color:#000; max-width:400px; box-shadow:0 3px 10px rgb(0 0 0 / 0.2); font-family:arial; font-weight:normal; font-size:16px;'; + modalElement.innerHTML = message + "

"; + modalElement.id = modalElementId; + + var closeButton = document.createElement('button'); + closeButton.style.cssText = 'background-color:#008CBA; border:none; color:white; cursor:pointer; float: right; margin: 10px; padding:15px; border-radius:5px; display:inline-block; text-align:center; text-decoration:none; font-family:arial; font-weight:normal; font-size:16px;'; + closeButton.innerText = "Close"; + closeButton.addEventListener ("click", function() { + document.body.removeChild(document.getElementById(modalElementId)); + }); + modalElement.appendChild(closeButton); + + var qortalButton = document.createElement('button'); + qortalButton.style.cssText = 'background-color:#4CAF50; border:none; color:white; cursor:pointer; float: right; margin: 10px; padding:15px; border-radius:5px; text-align:center; text-decoration:none; display:inline-block; font-family:arial; font-weight:normal; font-size:16px;'; + qortalButton.innerText = "Learn more"; + qortalButton.addEventListener ("click", function() { + document.body.removeChild(document.getElementById(modalElementId)); + window.open("https://qortal.org"); + }); + modalElement.appendChild(qortalButton); + + document.body.appendChild(modalElement); +} + window.addEventListener("message", (event) => { if (event == null || event.data == null || event.data.length == 0) { return; @@ -24,8 +56,10 @@ window.addEventListener("message", (event) => { case "GET_WALLET_BALANCE": case "SEND_COIN": const errorString = "Authentication was requested, but this is not yet supported when viewing via a gateway. To use interactive features, please access using the Qortal UI desktop app. More info at: https://qortal.org"; - alert(errorString); response = "{\"error\": \"" + errorString + "\"}" + + const modalText = "This app is powered by the Qortal blockchain. You are viewing in read-only mode. To use interactive features, please access using the Qortal UI desktop app."; + qdnGatewayShowModal(modalText); break; default: From 57485bfe3604502e122210bfefac80f140538e0f Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Sat, 15 Apr 2023 09:11:27 -0400 Subject: [PATCH 131/215] Removed check from poll tx that creator is owner --- .../transaction/CreatePollTransaction.java | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/qortal/transaction/CreatePollTransaction.java b/src/main/java/org/qortal/transaction/CreatePollTransaction.java index 1d969965..a56322a7 100644 --- a/src/main/java/org/qortal/transaction/CreatePollTransaction.java +++ b/src/main/java/org/qortal/transaction/CreatePollTransaction.java @@ -51,21 +51,6 @@ public class CreatePollTransaction extends Transaction { if (!Crypto.isValidAddress(this.createPollTransactionData.getOwner())) return ValidationResult.INVALID_ADDRESS; - Account creator = getCreator(); - Account owner = getOwner(); - - String creatorAddress = creator.getAddress(); - String ownerAddress = owner.getAddress(); - - // Check Owner address is the same as the creator public key - if (!creatorAddress.equals(ownerAddress)) { - return ValidationResult.INVALID_ADDRESS; - } - - // Check creator has enough funds - if (creator.getConfirmedBalance(Asset.QORT) < this.createPollTransactionData.getFee()) - return ValidationResult.NO_BALANCE; - // Check name size bounds String pollName = this.createPollTransactionData.getPollName(); int pollNameLength = Utf8.encodedLength(pollName); @@ -103,6 +88,12 @@ public class CreatePollTransaction extends Transaction { optionNames.add(pollOptionData.getOptionName()); } + Account creator = getCreator(); + + // Check creator has enough funds + if (creator.getConfirmedBalance(Asset.QORT) < this.createPollTransactionData.getFee()) + return ValidationResult.NO_BALANCE; + return ValidationResult.OK; } From 735de93848dee8c3382ae27c7aaf676754277167 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Sat, 15 Apr 2023 09:25:28 -0400 Subject: [PATCH 132/215] Removed internal use parameter from API endpoint --- .../qortal/data/transaction/VoteOnPollTransactionData.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java b/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java index ac467255..a23d5e2b 100644 --- a/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/VoteOnPollTransactionData.java @@ -3,6 +3,7 @@ package org.qortal.data.transaction; import javax.xml.bind.Unmarshaller; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlTransient; import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue; import org.qortal.transaction.Transaction.TransactionType; @@ -20,6 +21,9 @@ public class VoteOnPollTransactionData extends TransactionData { private byte[] voterPublicKey; private String pollName; private int optionIndex; + // For internal use when orphaning + @XmlTransient + @Schema(hidden = true) private Integer previousOptionIndex; // Constructors From 0258d2bcb6aa2465d9e2c27e25f321d0d43e442d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Apr 2023 14:31:41 +0100 Subject: [PATCH 133/215] Fixed layout issues recently introduced in documentation. --- Q-Apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Q-Apps.md b/Q-Apps.md index 860a2a3f..61a96b5f 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -482,7 +482,7 @@ await qortalRequest({ amount: 1.00000000, // 1 LTC fee: 0.00000020 // fee per byte }); - +``` ### Search or list chat messages ``` From 250245d5e12b514bc84a814c4c1a16ff832846c1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Apr 2023 14:34:30 +0100 Subject: [PATCH 134/215] Added new list management actions to Q-Apps documentation. --- Q-Apps.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 61a96b5f..c659abd7 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -173,6 +173,7 @@ To take things a step further, the qortalRequest() function can be used to inter - Join groups - Deploy ATs (smart contracts) - Send QORT or any supported foreign coin +- Add/remove items from lists In addition to the above, qortalRequest() also supports many read-only functions that are also available via direct core API calls. Using qortalRequest() helps with futureproofing, as the core APIs can be modified without breaking functionality of existing Q-Apps. @@ -239,6 +240,9 @@ Here is a list of currently supported actions: - GET_PRICE - GET_QDN_RESOURCE_URL - LINK_TO_QDN_RESOURCE +- GET_LIST_ITEMS +- ADD_LIST_ITEMS +- DELETE_LIST_ITEM More functionality will be added in the future. @@ -690,6 +694,36 @@ let res = await qortalRequest({ }); ``` +### Get the contents of a list +_Requires user approval_ +``` +let res = await qortalRequest({ + action: "GET_LIST_ITEMS", + list_name: "followedNames" +}); +``` + +### Add one or more items to a list +_Requires user approval_ +``` +let res = await qortalRequest({ + action: "ADD_LIST_ITEMS", + list_name: "blockedNames", + items: ["QortalDemo"] +}); +``` + +### Delete a single item from a list +_Requires user approval_. +Items must be deleted one at a time. +``` +let res = await qortalRequest({ + action: "DELETE_LIST_ITEM", + list_name: "blockedNames", + item: "QortalDemo" +}); +``` + # Section 4: Examples From 61b7cdd025aaddb0bc979613f1143ae2d48d851f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Apr 2023 15:24:10 +0100 Subject: [PATCH 135/215] Added "followedonly" and "excludeblocked" params to `GET /arbitrary/resources` and `GET /arbitrary/resources/search`, as well as `LIST_QDN_RESOURCES` and `SEARCH_QDN_RESOURCES` Q-Apps actions. --- Q-Apps.md | 4 ++ .../api/resource/ArbitraryResource.java | 10 ++- .../repository/ArbitraryRepository.java | 4 +- .../hsqldb/HSQLDBArbitraryRepository.java | 67 ++++++++++++++++++- src/main/resources/q-apps/q-apps.js | 4 ++ 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index c659abd7..4b20d04b 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -303,6 +303,8 @@ let res = await qortalRequest({ default: true, // Optional includeStatus: false, // Optional - will take time to respond, so only request if necessary includeMetadata: false, // Optional - will take time to respond, so only request if necessary + followedOnly: false, // Optional - include followed names only + excludeBlocked: false, // Optional - exclude blocked content limit: 100, offset: 0, reverse: true @@ -321,6 +323,8 @@ let res = await qortalRequest({ default: false, // Optional - if true, only resources without identifiers are returned includeStatus: false, // Optional - will take time to respond, so only request if necessary includeMetadata: false, // Optional - will take time to respond, so only request if necessary + followedOnly: false, // Optional - include followed names only + excludeBlocked: false, // Optional - exclude blocked content limit: 100, offset: 0, reverse: true diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 5725c155..f5985078 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -102,6 +102,8 @@ public class ArbitraryResource { @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse, + @Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly, + @Parameter(description = "Exclude blocked content") @QueryParam("excludeblocked") Boolean excludeBlocked, @Parameter(description = "Filter names by list") @QueryParam("namefilter") String nameFilter, @Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus, @Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) { @@ -135,7 +137,7 @@ public class ArbitraryResource { } List resources = repository.getArbitraryRepository() - .getArbitraryResources(service, identifier, names, defaultRes, limit, offset, reverse); + .getArbitraryResources(service, identifier, names, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse); if (resources == null) { return new ArrayList<>(); @@ -178,6 +180,8 @@ public class ArbitraryResource { @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse, + @Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly, + @Parameter(description = "Exclude blocked content") @QueryParam("excludeblocked") Boolean excludeBlocked, @Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus, @Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) { @@ -192,7 +196,7 @@ public class ArbitraryResource { } List resources = repository.getArbitraryRepository() - .searchArbitraryResources(service, query, identifier, names, usePrefixOnly, defaultRes, limit, offset, reverse); + .searchArbitraryResources(service, query, identifier, names, usePrefixOnly, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse); if (resources == null) { return new ArrayList<>(); @@ -253,7 +257,7 @@ public class ArbitraryResource { String name = creatorName.name; if (name != null) { List resources = repository.getArbitraryRepository() - .getArbitraryResources(service, identifier, Arrays.asList(name), defaultRes, null, null, reverse); + .getArbitraryResources(service, identifier, Arrays.asList(name), defaultRes, null, null, null, null, reverse); if (includeStatus != null && includeStatus) { resources = ArbitraryTransactionUtils.addStatusToResources(resources); diff --git a/src/main/java/org/qortal/repository/ArbitraryRepository.java b/src/main/java/org/qortal/repository/ArbitraryRepository.java index cd1b582b..9eea6bc2 100644 --- a/src/main/java/org/qortal/repository/ArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/ArbitraryRepository.java @@ -24,9 +24,9 @@ public interface ArbitraryRepository { public ArbitraryTransactionData getLatestTransaction(String name, Service service, Method method, String identifier) throws DataException; - public List getArbitraryResources(Service service, String identifier, List names, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List getArbitraryResources(Service service, String identifier, List names, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Integer limit, Integer offset, Boolean reverse) throws DataException; - public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Integer limit, Integer offset, Boolean reverse) throws DataException; public List getArbitraryResourceCreatorNames(Service service, String identifier, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index 443f7c6b..8d94c8bd 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -16,6 +16,7 @@ import org.qortal.arbitrary.ArbitraryDataFile; import org.qortal.transaction.ArbitraryTransaction; import org.qortal.transaction.Transaction.ApprovalStatus; import org.qortal.utils.Base58; +import org.qortal.utils.ListUtils; import java.sql.ResultSet; import java.sql.SQLException; @@ -284,7 +285,8 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { @Override public List getArbitraryResources(Service service, String identifier, List names, - boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException { + boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, + Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); List bindParams = new ArrayList<>(); @@ -319,6 +321,36 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { sql.append(")"); } + // Handle "followed only" + if (followedOnly != null && followedOnly) { + List followedNames = ListUtils.followedNames(); + if (followedNames != null && !followedNames.isEmpty()) { + sql.append(" AND name IN (?"); + bindParams.add(followedNames.get(0)); + + for (int i = 1; i < followedNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(followedNames.get(i)); + } + sql.append(")"); + } + } + + // Handle "exclude blocked" + if (excludeBlocked != null && excludeBlocked) { + List blockedNames = ListUtils.blockedNames(); + if (blockedNames != null && !blockedNames.isEmpty()) { + sql.append(" AND name NOT IN (?"); + bindParams.add(blockedNames.get(0)); + + for (int i = 1; i < blockedNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(blockedNames.get(i)); + } + sql.append(")"); + } + } + sql.append(" GROUP BY name, service, identifier ORDER BY name COLLATE SQL_TEXT_UCC_NO_PAD"); if (reverse != null && reverse) { @@ -361,7 +393,8 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { @Override public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, - boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException { + boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, + Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); List bindParams = new ArrayList<>(); @@ -417,6 +450,36 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { sql.append(")"); } + // Handle "followed only" + if (followedOnly != null && followedOnly) { + List followedNames = ListUtils.followedNames(); + if (followedNames != null && !followedNames.isEmpty()) { + sql.append(" AND name IN (?"); + bindParams.add(followedNames.get(0)); + + for (int i = 1; i < followedNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(followedNames.get(i)); + } + sql.append(")"); + } + } + + // Handle "exclude blocked" + if (excludeBlocked != null && excludeBlocked) { + List blockedNames = ListUtils.blockedNames(); + if (blockedNames != null && !blockedNames.isEmpty()) { + sql.append(" AND name NOT IN (?"); + bindParams.add(blockedNames.get(0)); + + for (int i = 1; i < blockedNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(blockedNames.get(i)); + } + sql.append(")"); + } + } + sql.append(" GROUP BY name, service, identifier ORDER BY date_created"); if (reverse != null && reverse) { diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index f72d8794..ca2d75c0 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -190,6 +190,8 @@ window.addEventListener("message", (event) => { if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); + if (data.followedOnly != null) url = url.concat("&followedonly=" + new Boolean(data.followedOnly).toString()); + if (data.excludeBlocked != null) url = url.concat("&excludeblocked=" + new Boolean(data.excludeBlocked).toString()); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); @@ -206,6 +208,8 @@ window.addEventListener("message", (event) => { if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); + if (data.followedOnly != null) url = url.concat("&followedonly=" + new Boolean(data.followedOnly).toString()); + if (data.excludeBlocked != null) url = url.concat("&excludeblocked=" + new Boolean(data.excludeBlocked).toString()); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); From 28bd4adcd2a32c61d90889260f007cb79447806e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Apr 2023 15:42:47 +0100 Subject: [PATCH 136/215] Removed `GET /arbitrary/resources/names` endpoint, as it's unused and doesn't scale well. --- .../api/resource/ArbitraryResource.java | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index f5985078..e07bcd2e 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -216,67 +216,6 @@ public class ArbitraryResource { } } - @GET - @Path("/resources/names") - @Operation( - summary = "List arbitrary resources available on chain, grouped by creator's name", - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceInfo.class)) - ) - } - ) - @ApiErrors({ApiError.REPOSITORY_ISSUE}) - public List getResourcesGroupedByName( - @QueryParam("service") Service service, - @QueryParam("identifier") String identifier, - @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource, - @Parameter(ref = "limit") @QueryParam("limit") Integer limit, - @Parameter(ref = "offset") @QueryParam("offset") Integer offset, - @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse, - @Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus, - @Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) { - - 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 ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "identifier cannot be specified when requesting a default resource"); - } - - List creatorNames = repository.getArbitraryRepository() - .getArbitraryResourceCreatorNames(service, identifier, defaultRes, limit, offset, reverse); - - for (ArbitraryResourceNameInfo creatorName : creatorNames) { - String name = creatorName.name; - if (name != null) { - List resources = repository.getArbitraryRepository() - .getArbitraryResources(service, identifier, Arrays.asList(name), defaultRes, null, null, null, null, reverse); - - if (includeStatus != null && includeStatus) { - resources = ArbitraryTransactionUtils.addStatusToResources(resources); - } - if (includeMetadata != null && includeMetadata) { - resources = ArbitraryTransactionUtils.addMetadataToResources(resources); - } - - creatorName.resources = resources; - } - } - - return creatorNames; - - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - @GET @Path("/resource/status/{service}/{name}") @Operation( From a286db2dfdccb7452fcf58f54e3476e959c387eb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Apr 2023 15:55:52 +0100 Subject: [PATCH 137/215] "namefilter" param in `GET /arbitrary/resources/search` is now exact match, which makes more sense when filtering results by names in a list. --- .../qortal/api/resource/ArbitraryResource.java | 16 +++++++++------- .../qortal/repository/ArbitraryRepository.java | 2 +- .../hsqldb/HSQLDBArbitraryRepository.java | 16 ++++++++++++++-- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index e07bcd2e..8b03b608 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -174,29 +174,31 @@ public class ArbitraryResource { @Parameter(description = "Query (searches both name and identifier fields)") @QueryParam("query") String query, @Parameter(description = "Identifier (searches identifier field only)") @QueryParam("identifier") String identifier, @Parameter(description = "Name (searches name field only)") @QueryParam("name") List names, - @Parameter(description = "Filter names by list (partial matches allowed)") @QueryParam("namefilter") String nameListFilter, @Parameter(description = "Prefix only (if true, only the beginning of fields are matched)") @QueryParam("prefix") Boolean prefixOnly, @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource, - @Parameter(ref = "limit") @QueryParam("limit") Integer limit, - @Parameter(ref = "offset") @QueryParam("offset") Integer offset, - @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse, + @Parameter(description = "Filter names by list (exact matches only)") @QueryParam("namefilter") String nameListFilter, @Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly, @Parameter(description = "Exclude blocked content") @QueryParam("excludeblocked") Boolean excludeBlocked, @Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus, - @Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) { + @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 (final Repository repository = RepositoryManager.getRepository()) { boolean defaultRes = Boolean.TRUE.equals(defaultResource); boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly); + List exactMatchNames = new ArrayList<>(); + if (nameListFilter != null) { // Load names from supplied list of names - names.addAll(ResourceListManager.getInstance().getStringsInList(nameListFilter)); + exactMatchNames.addAll(ResourceListManager.getInstance().getStringsInList(nameListFilter)); } List resources = repository.getArbitraryRepository() - .searchArbitraryResources(service, query, identifier, names, usePrefixOnly, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse); + .searchArbitraryResources(service, query, identifier, names, usePrefixOnly, exactMatchNames, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse); if (resources == null) { return new ArrayList<>(); diff --git a/src/main/java/org/qortal/repository/ArbitraryRepository.java b/src/main/java/org/qortal/repository/ArbitraryRepository.java index 9eea6bc2..9d9ed8ce 100644 --- a/src/main/java/org/qortal/repository/ArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/ArbitraryRepository.java @@ -26,7 +26,7 @@ public interface ArbitraryRepository { public List getArbitraryResources(Service service, String identifier, List names, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Integer limit, Integer offset, Boolean reverse) throws DataException; - public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, List namesFilter, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Integer limit, Integer offset, Boolean reverse) throws DataException; public List getArbitraryResourceCreatorNames(Service service, String identifier, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index 8d94c8bd..0a4e429f 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -393,7 +393,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { @Override public List searchArbitraryResources(Service service, String query, String identifier, List names, boolean prefixOnly, - boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, + List exactMatchNames, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); List bindParams = new ArrayList<>(); @@ -436,7 +436,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { bindParams.add(queryWildcard); } - // Handle name matches + // Handle name searches if (names != null && !names.isEmpty()) { sql.append(" AND ("); @@ -450,6 +450,18 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { sql.append(")"); } + // Handle name exact matches + if (exactMatchNames != null && !exactMatchNames.isEmpty()) { + sql.append(" AND name IN (?"); + bindParams.add(exactMatchNames.get(0)); + + for (int i = 1; i < exactMatchNames.size(); ++i) { + sql.append(", ?"); + bindParams.add(exactMatchNames.get(i)); + } + sql.append(")"); + } + // Handle "followed only" if (followedOnly != null && followedOnly) { List followedNames = ListUtils.followedNames(); From 3f00cda8478aa906b835d0da2877aa2a6575878d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Apr 2023 16:02:25 +0100 Subject: [PATCH 138/215] "nameListFilter" added to `LIST_QDN_RESOURCES` and `SEARCH_QDN_RESOURCES` Q-Apps actions. --- Q-Apps.md | 4 ++++ src/main/resources/q-apps/q-apps.js | 2 ++ 2 files changed, 6 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 4b20d04b..53377145 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -323,6 +323,7 @@ let res = await qortalRequest({ default: false, // Optional - if true, only resources without identifiers are returned includeStatus: false, // Optional - will take time to respond, so only request if necessary includeMetadata: false, // Optional - will take time to respond, so only request if necessary + nameListFilter: "QApp1234Subscriptions", // Optional - will only return results if they are from a name included in supplied list followedOnly: false, // Optional - include followed names only excludeBlocked: false, // Optional - exclude blocked content limit: 100, @@ -343,6 +344,9 @@ let res = await qortalRequest({ default: false, // Optional - if true, only resources without identifiers are returned includeStatus: false, // Optional - will take time to respond, so only request if necessary includeMetadata: false, // Optional - will take time to respond, so only request if necessary + nameListFilter: "QApp1234Subscriptions", // Optional - will only return results if they are from a name included in supplied list + followedOnly: false, // Optional - include followed names only + excludeBlocked: false, // Optional - exclude blocked content limit: 100, offset: 0, reverse: true diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index ca2d75c0..a0bf7923 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -190,6 +190,7 @@ window.addEventListener("message", (event) => { if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); + if (data.nameListFilter != null) url = url.concat("&namefilter=" + data.nameListFilter); if (data.followedOnly != null) url = url.concat("&followedonly=" + new Boolean(data.followedOnly).toString()); if (data.excludeBlocked != null) url = url.concat("&excludeblocked=" + new Boolean(data.excludeBlocked).toString()); if (data.limit != null) url = url.concat("&limit=" + data.limit); @@ -208,6 +209,7 @@ window.addEventListener("message", (event) => { if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); + if (data.nameListFilter != null) url = url.concat("&namefilter=" + data.nameListFilter); if (data.followedOnly != null) url = url.concat("&followedonly=" + new Boolean(data.followedOnly).toString()); if (data.excludeBlocked != null) url = url.concat("&excludeblocked=" + new Boolean(data.excludeBlocked).toString()); if (data.limit != null) url = url.concat("&limit=" + data.limit); From cfe6dfcd1c9c23c365e75448c4592cde6ec55fcb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Apr 2023 18:27:55 +0100 Subject: [PATCH 139/215] If nameFilter contains an empty or nonexistent list, return an empty array. --- .../org/qortal/api/resource/ArbitraryResource.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 8b03b608..6d0b10a8 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -104,7 +104,7 @@ public class ArbitraryResource { @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse, @Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly, @Parameter(description = "Exclude blocked content") @QueryParam("excludeblocked") Boolean excludeBlocked, - @Parameter(description = "Filter names by list") @QueryParam("namefilter") String nameFilter, + @Parameter(description = "Filter names by list") @QueryParam("namefilter") String nameListFilter, @Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus, @Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) { @@ -127,11 +127,11 @@ public class ArbitraryResource { // Filter using single name names = Arrays.asList(name); } - else if (nameFilter != null) { + else if (nameListFilter != null) { // Filter using supplied list of names - names = ResourceListManager.getInstance().getStringsInList(nameFilter); + names = ResourceListManager.getInstance().getStringsInList(nameListFilter); if (names.isEmpty()) { - // List doesn't exist or is empty - so there will be no matches + // If list is empty (or doesn't exist) we can shortcut with empty response return new ArrayList<>(); } } @@ -195,6 +195,11 @@ public class ArbitraryResource { if (nameListFilter != null) { // Load names from supplied list of names exactMatchNames.addAll(ResourceListManager.getInstance().getStringsInList(nameListFilter)); + + // If list is empty (or doesn't exist) we can shortcut with empty response + if (exactMatchNames.isEmpty()) { + return new ArrayList<>(); + } } List resources = repository.getArbitraryRepository() From e041748b4870529871700fee1e29703c6d869b52 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 16 Apr 2023 13:59:25 +0100 Subject: [PATCH 140/215] Improved name rebuilding code, to handle some more complex scenarios. --- .../NamesDatabaseIntegrityCheck.java | 118 +++++++++++++----- .../qortal/test/naming/IntegrityTests.java | 2 +- 2 files changed, 85 insertions(+), 35 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java index 004fa692..99eaf105 100644 --- a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java +++ b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java @@ -13,7 +13,9 @@ import org.qortal.repository.RepositoryManager; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.utils.Unicode; +import java.math.BigInteger; import java.util.*; +import java.util.stream.Collectors; public class NamesDatabaseIntegrityCheck { @@ -28,16 +30,8 @@ public class NamesDatabaseIntegrityCheck { private List nameTransactions = new ArrayList<>(); + public int rebuildName(String name, Repository repository) { - return this.rebuildName(name, repository, null); - } - - public int rebuildName(String name, Repository repository, List referenceNames) { - // "referenceNames" tracks the linked names that have already been rebuilt, to prevent circular dependencies - if (referenceNames == null) { - referenceNames = new ArrayList<>(); - } - int modificationCount = 0; try { List transactions = this.fetchAllTransactionsInvolvingName(name, repository); @@ -46,6 +40,14 @@ public class NamesDatabaseIntegrityCheck { return modificationCount; } + // If this name has been updated at any point, we need to add transactions from the other names to the sequence + int added = this.addAdditionalTransactionsRelatingToName(transactions, name, repository); + while (added > 0) { + // Keep going until all have been added + LOGGER.trace("{} added for {}. Looking for more transactions...", added, name); + added = this.addAdditionalTransactionsRelatingToName(transactions, name, repository); + } + // Loop through each past transaction and re-apply it to the Names table for (TransactionData currentTransaction : transactions) { @@ -61,29 +63,14 @@ public class NamesDatabaseIntegrityCheck { // Process UPDATE_NAME transactions if (currentTransaction.getType() == TransactionType.UPDATE_NAME) { UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) currentTransaction; - - if (Objects.equals(updateNameTransactionData.getNewName(), name) && - !Objects.equals(updateNameTransactionData.getName(), updateNameTransactionData.getNewName())) { - // This renames an existing name, so we need to process that instead - - if (!referenceNames.contains(name)) { - referenceNames.add(name); - this.rebuildName(updateNameTransactionData.getName(), repository, referenceNames); - } - else { - // We've already processed this name so there's nothing more to do - } - } - else { - Name nameObj = new Name(repository, name); - if (nameObj != null && nameObj.getNameData() != null) { - nameObj.update(updateNameTransactionData); - modificationCount++; - LOGGER.trace("Processed UPDATE_NAME transaction for name {}", name); - } else { - // Something went wrong - throw new DataException(String.format("Name data not found for name %s", updateNameTransactionData.getName())); - } + Name nameObj = new Name(repository, updateNameTransactionData.getName()); + if (nameObj != null && nameObj.getNameData() != null) { + nameObj.update(updateNameTransactionData); + modificationCount++; + LOGGER.trace("Processed UPDATE_NAME transaction for name {}", name); + } else { + // Something went wrong + throw new DataException(String.format("Name data not found for name %s", updateNameTransactionData.getName())); } } @@ -354,8 +341,8 @@ public class NamesDatabaseIntegrityCheck { } } - // Sort by lowest timestamp first - transactions.sort(Comparator.comparingLong(TransactionData::getTimestamp)); + // Sort by lowest block height first + sortTransactions(transactions); return transactions; } @@ -419,4 +406,67 @@ public class NamesDatabaseIntegrityCheck { return names; } + private int addAdditionalTransactionsRelatingToName(List transactions, String name, Repository repository) throws DataException { + int added = 0; + + // If this name has been updated at any point, we need to add transactions from the other names to the sequence + List otherNames = new ArrayList<>(); + List updateNameTransactions = transactions.stream().filter(t -> t.getType() == TransactionType.UPDATE_NAME).collect(Collectors.toList()); + for (TransactionData transactionData : updateNameTransactions) { + UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData; + // If the newName field isn't empty, and either the "name" or "newName" is different from our reference name, + // we should remember this additional name, in case it has relevant transactions associated with it. + if (updateNameTransactionData.getNewName() != null && !updateNameTransactionData.getNewName().isEmpty()) { + if (!Objects.equals(updateNameTransactionData.getName(), name)) { + otherNames.add(updateNameTransactionData.getName()); + } + if (!Objects.equals(updateNameTransactionData.getNewName(), name)) { + otherNames.add(updateNameTransactionData.getNewName()); + } + } + } + + + for (String otherName : otherNames) { + List otherNameTransactions = this.fetchAllTransactionsInvolvingName(otherName, repository); + for (TransactionData otherNameTransactionData : otherNameTransactions) { + if (!transactions.contains(otherNameTransactionData)) { + // Add new transaction relating to other name + transactions.add(otherNameTransactionData); + added++; + } + } + } + + if (added > 0) { + // New transaction(s) added, so re-sort + sortTransactions(transactions); + } + + return added; + } + + private void sortTransactions(List transactions) { + Collections.sort(transactions, new Comparator() { + public int compare(Object o1, Object o2) { + TransactionData td1 = (TransactionData) o1; + TransactionData td2 = (TransactionData) o2; + + // Sort by block height first + int heightComparison = td1.getBlockHeight().compareTo(td2.getBlockHeight()); + if (heightComparison != 0) { + return heightComparison; + } + + // Same height so compare timestamps + int timestampComparison = Long.compare(td1.getTimestamp(), td2.getTimestamp()); + if (timestampComparison != 0) { + return timestampComparison; + } + + // Same timestamp so compare signatures + return new BigInteger(td1.getSignature()).compareTo(new BigInteger(td2.getSignature())); + }}); + } + } diff --git a/src/test/java/org/qortal/test/naming/IntegrityTests.java b/src/test/java/org/qortal/test/naming/IntegrityTests.java index d52d4983..767ea388 100644 --- a/src/test/java/org/qortal/test/naming/IntegrityTests.java +++ b/src/test/java/org/qortal/test/naming/IntegrityTests.java @@ -128,7 +128,7 @@ public class IntegrityTests extends Common { // Run the database integrity check for the initial name, to ensure it doesn't get into a loop NamesDatabaseIntegrityCheck integrityCheck = new NamesDatabaseIntegrityCheck(); - assertEquals(2, integrityCheck.rebuildName(initialName, repository)); + assertEquals(4, integrityCheck.rebuildName(initialName, repository)); // 4 transactions total // Ensure the new name still exists and the data is still correct assertTrue(repository.getNameRepository().nameExists(initialName)); From 8331241d7582249f78a96b50cf3851b80d180e5f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 18 Apr 2023 19:01:45 +0100 Subject: [PATCH 141/215] Bump version to 3.9.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 3e59c66d..083901a6 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.9.0 + 3.9.1 jar true From 358e67b05061849a5e4b148beaa185cca6a9dc75 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 19 Apr 2023 20:56:47 +0100 Subject: [PATCH 142/215] Added "bindAddressFallback" setting, which defaults to "0.0.0.0". Should fix problems on systems unable to use IPv6 wildcard (::) for listening, and avoids having to manually specify "bindAddress": "0.0.0.0" in settings.json. --- src/main/java/org/qortal/api/ApiService.java | 5 +- .../java/org/qortal/api/DomainMapService.java | 5 +- .../java/org/qortal/api/GatewayService.java | 5 +- src/main/java/org/qortal/network/Network.java | 60 +++++++++++++------ .../java/org/qortal/settings/Settings.java | 5 ++ 5 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 78c9250c..f74082f2 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -41,6 +41,7 @@ import org.glassfish.jersey.servlet.ServletContainer; import org.qortal.api.resource.AnnotationPostProcessor; import org.qortal.api.resource.ApiDefinition; import org.qortal.api.websocket.*; +import org.qortal.network.Network; import org.qortal.settings.Settings; public class ApiService { @@ -123,13 +124,13 @@ public class ApiService { ServerConnector portUnifiedConnector = new ServerConnector(this.server, new DetectorConnectionFactory(sslConnectionFactory), httpConnectionFactory); - portUnifiedConnector.setHost(Settings.getInstance().getBindAddress()); + portUnifiedConnector.setHost(Network.getInstance().getBindAddress()); portUnifiedConnector.setPort(Settings.getInstance().getApiPort()); this.server.addConnector(portUnifiedConnector); } else { // Non-SSL - InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); + InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress()); InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getApiPort()); this.server = new Server(endpoint); } diff --git a/src/main/java/org/qortal/api/DomainMapService.java b/src/main/java/org/qortal/api/DomainMapService.java index ba0fa067..a2678e38 100644 --- a/src/main/java/org/qortal/api/DomainMapService.java +++ b/src/main/java/org/qortal/api/DomainMapService.java @@ -16,6 +16,7 @@ import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.servlet.ServletContainer; import org.qortal.api.resource.AnnotationPostProcessor; import org.qortal.api.resource.ApiDefinition; +import org.qortal.network.Network; import org.qortal.settings.Settings; import javax.net.ssl.KeyManagerFactory; @@ -99,13 +100,13 @@ public class DomainMapService { ServerConnector portUnifiedConnector = new ServerConnector(this.server, new DetectorConnectionFactory(sslConnectionFactory), httpConnectionFactory); - portUnifiedConnector.setHost(Settings.getInstance().getBindAddress()); + portUnifiedConnector.setHost(Network.getInstance().getBindAddress()); portUnifiedConnector.setPort(Settings.getInstance().getDomainMapPort()); this.server.addConnector(portUnifiedConnector); } else { // Non-SSL - InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); + InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress()); InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDomainMapPort()); this.server = new Server(endpoint); } diff --git a/src/main/java/org/qortal/api/GatewayService.java b/src/main/java/org/qortal/api/GatewayService.java index 030a0f2f..6625ed0a 100644 --- a/src/main/java/org/qortal/api/GatewayService.java +++ b/src/main/java/org/qortal/api/GatewayService.java @@ -15,6 +15,7 @@ import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.servlet.ServletContainer; import org.qortal.api.resource.AnnotationPostProcessor; import org.qortal.api.resource.ApiDefinition; +import org.qortal.network.Network; import org.qortal.settings.Settings; import javax.net.ssl.KeyManagerFactory; @@ -98,13 +99,13 @@ public class GatewayService { ServerConnector portUnifiedConnector = new ServerConnector(this.server, new DetectorConnectionFactory(sslConnectionFactory), httpConnectionFactory); - portUnifiedConnector.setHost(Settings.getInstance().getBindAddress()); + portUnifiedConnector.setHost(Network.getInstance().getBindAddress()); portUnifiedConnector.setPort(Settings.getInstance().getGatewayPort()); this.server.addConnector(portUnifiedConnector); } else { // Non-SSL - InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); + InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress()); InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getGatewayPort()); this.server = new Server(endpoint); } diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index f8f73c2a..ca79f367 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -124,6 +124,8 @@ public class Network { private final List selfPeers = new ArrayList<>(); + private String bindAddress = null; + private final ExecuteProduceConsume networkEPC; private Selector channelSelector; private ServerSocketChannel serverChannel; @@ -159,25 +161,43 @@ public class Network { // Grab P2P port from settings int listenPort = Settings.getInstance().getListenPort(); - // Grab P2P bind address from settings - try { - InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); - InetSocketAddress endpoint = new InetSocketAddress(bindAddr, listenPort); + // Grab P2P bind addresses from settings + List bindAddresses = new ArrayList<>(); + if (Settings.getInstance().getBindAddress() != null) { + bindAddresses.add(Settings.getInstance().getBindAddress()); + } + if (Settings.getInstance().getBindAddressFallback() != null) { + bindAddresses.add(Settings.getInstance().getBindAddressFallback()); + } - channelSelector = Selector.open(); + for (int i=0; i Date: Thu, 20 Apr 2023 16:23:57 -0400 Subject: [PATCH 143/215] Added API call for restarting node --- .../qortal/api/resource/AdminResource.java | 32 ++++++++ .../org/qortal/controller/AutoUpdate.java | 73 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 154f9159..1f516633 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -42,6 +42,7 @@ import org.qortal.api.model.ActivitySummary; import org.qortal.api.model.NodeInfo; import org.qortal.api.model.NodeStatus; import org.qortal.block.BlockChain; +import org.qortal.controller.AutoUpdate; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; import org.qortal.controller.Synchronizer.SynchronizationResult; @@ -199,6 +200,37 @@ public class AdminResource { return "true"; } + @GET + @Path("/restart") + @Operation( + summary = "Restart", + description = "Restart", + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @SecurityRequirement(name = "apiKey") + public String restart(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { + Security.checkApiCallAllowed(request); + + new Thread(() -> { + // Short sleep to allow HTTP response body to be emitted + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + // Not important + } + + AutoUpdate.attemptRestart(); + + }).start(); + + return "true"; + } + @GET @Path("/summary") @Operation( diff --git a/src/main/java/org/qortal/controller/AutoUpdate.java b/src/main/java/org/qortal/controller/AutoUpdate.java index 2ec7c94a..fde52fb1 100644 --- a/src/main/java/org/qortal/controller/AutoUpdate.java +++ b/src/main/java/org/qortal/controller/AutoUpdate.java @@ -293,4 +293,77 @@ public class AutoUpdate extends Thread { } } + public static boolean attemptRestart() { + LOGGER.info(String.format("Restarting node...")); + + // Give repository a chance to backup in case things go badly wrong (if enabled) + if (Settings.getInstance().getRepositoryBackupInterval() > 0) { + try { + // Timeout if the database isn't ready for backing up after 60 seconds + long timeout = 60 * 1000L; + RepositoryManager.backup(true, "backup", timeout); + + } catch (TimeoutException e) { + LOGGER.info("Attempt to backup repository failed due to timeout: {}", e.getMessage()); + // Continue with the node restart anyway... + } + } + + // Call ApplyUpdate to end this process (unlocking current JAR so it can be replaced) + String javaHome = System.getProperty("java.home"); + LOGGER.debug(String.format("Java home: %s", javaHome)); + + Path javaBinary = Paths.get(javaHome, "bin", "java"); + LOGGER.debug(String.format("Java binary: %s", javaBinary)); + + try { + List javaCmd = new ArrayList<>(); + // Java runtime binary itself + javaCmd.add(javaBinary.toString()); + + // JVM arguments + javaCmd.addAll(ManagementFactory.getRuntimeMXBean().getInputArguments()); + + // Disable, but retain, any -agentlib JVM arg as sub-process might fail if it tries to reuse same port + javaCmd = javaCmd.stream() + .map(arg -> arg.replace("-agentlib", AGENTLIB_JVM_HOLDER_ARG)) + .collect(Collectors.toList()); + + // Remove JNI options as they won't be supported by command-line 'java' + // These are typically added by the AdvancedInstaller Java launcher EXE + javaCmd.removeAll(Arrays.asList("abort", "exit", "vfprintf")); + + // Call ApplyUpdate using JAR + javaCmd.addAll(Arrays.asList("-cp", JAR_FILENAME, ApplyUpdate.class.getCanonicalName())); + + // Add command-line args saved from start-up + String[] savedArgs = Controller.getInstance().getSavedArgs(); + if (savedArgs != null) + javaCmd.addAll(Arrays.asList(savedArgs)); + + LOGGER.info(String.format("Restarting node with: %s", String.join(" ", javaCmd))); + + SysTray.getInstance().showMessage(Translator.INSTANCE.translate("SysTray", "AUTO_UPDATE"), //TODO + Translator.INSTANCE.translate("SysTray", "APPLYING_UPDATE_AND_RESTARTING"), //TODO + MessageType.INFO); + + ProcessBuilder processBuilder = new ProcessBuilder(javaCmd); + + // New process will inherit our stdout and stderr + processBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT); + processBuilder.redirectError(ProcessBuilder.Redirect.INHERIT); + + Process process = processBuilder.start(); + + // Nothing to pipe to new process, so close output stream (process's stdin) + process.getOutputStream().close(); + + return true; // restarting node OK + } catch (Exception e) { + LOGGER.error(String.format("Failed to restart node: %s", e.getMessage())); + + return true; // repo was okay, even if applying update failed + } + } + } From 85980e4cfca3eee1801ba79046900807751fede8 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Thu, 20 Apr 2023 16:41:47 -0400 Subject: [PATCH 144/215] Removed 3rd-party swagger server validation --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index 083901a6..7ecb7b44 100644 --- a/pom.xml +++ b/pom.xml @@ -147,6 +147,7 @@ tagsSorter: "alpha", operationsSorter: "alpha", + validatorUrl: false, From 10f12221c9f661423a290d99ff2baf1b4961ac61 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 09:42:04 +0100 Subject: [PATCH 145/215] Fixed exception in readJson(), and removed some duplicated code. --- .../metadata/ArbitraryDataMetadata.java | 12 ++++++++--- .../metadata/ArbitraryDataMetadataCache.java | 3 ++- .../metadata/ArbitraryDataMetadataPatch.java | 3 ++- .../metadata/ArbitraryDataQortalMetadata.java | 20 +------------------ .../ArbitraryDataTransactionMetadata.java | 3 ++- 5 files changed, 16 insertions(+), 25 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java index 127fefb5..07f6032c 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java @@ -2,6 +2,7 @@ package org.qortal.arbitrary.metadata; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.json.JSONException; import org.qortal.repository.DataException; import java.io.BufferedWriter; @@ -34,7 +35,7 @@ public class ArbitraryDataMetadata { this.filePath = filePath; } - protected void readJson() throws DataException { + protected void readJson() throws DataException, JSONException { // To be overridden } @@ -44,8 +45,13 @@ public class ArbitraryDataMetadata { public void read() throws IOException, DataException { - this.loadJson(); - this.readJson(); + try { + this.loadJson(); + this.readJson(); + + } catch (JSONException e) { + throw new DataException(String.format("Unable to read JSON: %s", e.getMessage())); + } } public void write() throws IOException, DataException { diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataCache.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataCache.java index bd6bb219..e9b49298 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataCache.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataCache.java @@ -1,5 +1,6 @@ package org.qortal.arbitrary.metadata; +import org.json.JSONException; import org.json.JSONObject; import org.qortal.repository.DataException; import org.qortal.utils.Base58; @@ -22,7 +23,7 @@ public class ArbitraryDataMetadataCache extends ArbitraryDataQortalMetadata { } @Override - protected void readJson() throws DataException { + protected void readJson() throws DataException, JSONException { if (this.jsonString == null) { throw new DataException("Patch JSON string is null"); } diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataPatch.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataPatch.java index 954dcb03..46a1f57e 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataPatch.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadataPatch.java @@ -3,6 +3,7 @@ package org.qortal.arbitrary.metadata; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import org.qortal.arbitrary.ArbitraryDataDiff.*; import org.qortal.repository.DataException; @@ -40,7 +41,7 @@ public class ArbitraryDataMetadataPatch extends ArbitraryDataQortalMetadata { } @Override - protected void readJson() throws DataException { + protected void readJson() throws DataException, JSONException { if (this.jsonString == null) { throw new DataException("Patch JSON string is null"); } diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java index 4c188843..df23655c 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java @@ -2,6 +2,7 @@ package org.qortal.arbitrary.metadata; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.json.JSONException; import org.qortal.repository.DataException; import java.io.BufferedWriter; @@ -46,20 +47,6 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata { return null; } - protected void readJson() throws DataException { - // To be overridden - } - - protected void buildJson() { - // To be overridden - } - - - @Override - public void read() throws IOException, DataException { - this.loadJson(); - this.readJson(); - } @Override public void write() throws IOException, DataException { @@ -94,9 +81,4 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata { } } - - public String getJsonString() { - return this.jsonString; - } - } diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java index d3cc5a45..004e0ed3 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java @@ -1,6 +1,7 @@ package org.qortal.arbitrary.metadata; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import org.qortal.arbitrary.misc.Category; import org.qortal.repository.DataException; @@ -33,7 +34,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { } @Override - protected void readJson() throws DataException { + protected void readJson() throws DataException, JSONException { if (this.jsonString == null) { throw new DataException("Transaction metadata JSON string is null"); } From 0993903aa0c97aca58b03c47f39df571624a3a2a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 11:03:24 +0100 Subject: [PATCH 146/215] Added `GET /settings/{setting}` endpoint Based on work by @QuickMythril, but modified to be generic. --- .../qortal/api/resource/AdminResource.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 1f516633..fa10c90d 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -20,6 +20,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -31,10 +32,13 @@ import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; +import org.apache.commons.lang3.reflect.FieldUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.core.LoggerContext; import org.apache.logging.log4j.core.appender.RollingFileAppender; +import org.json.JSONArray; +import org.json.JSONObject; import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.api.*; @@ -170,6 +174,37 @@ public class AdminResource { return nodeSettings; } + @GET + @Path("/settings/{setting}") + @Operation( + summary = "Fetch a single node setting", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + public Object setting(@PathParam("setting") String setting) { + try { + Object settingValue = FieldUtils.readField(Settings.getInstance(), setting, true); + if (settingValue == null) { + return "null"; + } + else if (settingValue instanceof String[]) { + JSONArray array = new JSONArray(settingValue); + return array.toString(4); + } + else if (settingValue instanceof List) { + JSONArray array = new JSONArray((List) settingValue); + return array.toString(4); + } + return settingValue; + + } catch (IllegalAccessException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e); + } + } + @GET @Path("/stop") @Operation( From 9cd6372161ee55627fd80b38a08d57a3e29b1146 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 12:06:16 +0100 Subject: [PATCH 147/215] Improved `GET /admin/settings/{setting}` further, in order to support all settings (fixes ones such as bitcoinNet). --- .../org/qortal/api/restricted/resource/AdminResource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/restricted/resource/AdminResource.java b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java index 20caf3d4..ecb8c6c9 100644 --- a/src/main/java/org/qortal/api/restricted/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java @@ -184,7 +184,7 @@ public class AdminResource { ) } ) - public Object setting(@PathParam("setting") String setting) { + public String setting(@PathParam("setting") String setting) { try { Object settingValue = FieldUtils.readField(Settings.getInstance(), setting, true); if (settingValue == null) { @@ -198,8 +198,8 @@ public class AdminResource { JSONArray array = new JSONArray((List) settingValue); return array.toString(4); } - return settingValue; + return settingValue.toString(); } catch (IllegalAccessException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e); } From 560282dc1dbb47c39f919e9dc04eef678a696567 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 12:55:59 +0100 Subject: [PATCH 148/215] Added "exactMatchNames" parameter to `GET /arbitrary/resources/search` --- Q-Apps.md | 1 + .../java/org/qortal/api/resource/ArbitraryResource.java | 7 +++++++ src/main/resources/q-apps/q-apps.js | 1 + 3 files changed, 9 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 53377145..936ebd30 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -320,6 +320,7 @@ let res = await qortalRequest({ identifier: "search query goes here", // Optional - searches only the "identifier" field name: "search query goes here", // Optional - searches only the "name" field prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters + exactMatchNames: true, // Optional - if true, partial name matches are excluded default: false, // Optional - if true, only resources without identifiers are returned includeStatus: false, // Optional - will take time to respond, so only request if necessary includeMetadata: false, // Optional - will take time to respond, so only request if necessary diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 6d0b10a8..3d1a6a2e 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -175,6 +175,7 @@ public class ArbitraryResource { @Parameter(description = "Identifier (searches identifier field only)") @QueryParam("identifier") String identifier, @Parameter(description = "Name (searches name field only)") @QueryParam("name") List names, @Parameter(description = "Prefix only (if true, only the beginning of fields are matched)") @QueryParam("prefix") Boolean prefixOnly, + @Parameter(description = "Exact match names only (if true, partial name matches are excluded)") @QueryParam("exactmatchnames") Boolean exactMatchNamesOnly, @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource, @Parameter(description = "Filter names by list (exact matches only)") @QueryParam("namefilter") String nameListFilter, @Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly, @@ -202,6 +203,12 @@ public class ArbitraryResource { } } + // Move names to exact match list, if requested + if (exactMatchNamesOnly != null && exactMatchNamesOnly && names != null) { + exactMatchNames.addAll(names); + names = null; + } + List resources = repository.getArbitraryRepository() .searchArbitraryResources(service, query, identifier, names, usePrefixOnly, exactMatchNames, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse); diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index a0bf7923..cef06a89 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -206,6 +206,7 @@ window.addEventListener("message", (event) => { if (data.name != null) url = url.concat("&name=" + data.name); if (data.names != null) data.names.forEach((x, i) => url = url.concat("&name=" + x)); if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString()); + if (data.exactMatchNames != null) url = url.concat("&exactmatchnames=" + new Boolean(data.exactMatchNames).toString()); if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString()); if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString()); From f045e10adabfdd2daf4af10c9189cabad2c56623 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 12:56:15 +0100 Subject: [PATCH 149/215] Removed all case sensitivity when searching names. --- .../repository/hsqldb/HSQLDBArbitraryRepository.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index 0a4e429f..6ee1cad1 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -452,12 +452,12 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { // Handle name exact matches if (exactMatchNames != null && !exactMatchNames.isEmpty()) { - sql.append(" AND name IN (?"); + sql.append(" AND LCASE(name) IN (?"); bindParams.add(exactMatchNames.get(0)); for (int i = 1; i < exactMatchNames.size(); ++i) { sql.append(", ?"); - bindParams.add(exactMatchNames.get(i)); + bindParams.add(exactMatchNames.get(i).toLowerCase()); } sql.append(")"); } @@ -466,12 +466,12 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { if (followedOnly != null && followedOnly) { List followedNames = ListUtils.followedNames(); if (followedNames != null && !followedNames.isEmpty()) { - sql.append(" AND name IN (?"); + sql.append(" AND LCASE(name) IN (?"); bindParams.add(followedNames.get(0)); for (int i = 1; i < followedNames.size(); ++i) { sql.append(", ?"); - bindParams.add(followedNames.get(i)); + bindParams.add(followedNames.get(i).toLowerCase()); } sql.append(")"); } @@ -481,12 +481,12 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { if (excludeBlocked != null && excludeBlocked) { List blockedNames = ListUtils.blockedNames(); if (blockedNames != null && !blockedNames.isEmpty()) { - sql.append(" AND name NOT IN (?"); + sql.append(" AND LCASE(name) NOT IN (?"); bindParams.add(blockedNames.get(0)); for (int i = 1; i < blockedNames.size(); ++i) { sql.append(", ?"); - bindParams.add(blockedNames.get(i)); + bindParams.add(blockedNames.get(i).toLowerCase()); } sql.append(")"); } From 32b9b7e578a04882aa4e8604e049e2c4b7ff055f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 13:59:29 +0100 Subject: [PATCH 150/215] Use a temporary file when reading on-chain data. --- .../qortal/arbitrary/ArbitraryDataFile.java | 19 ++++++++++++++++--- .../arbitrary/ArbitraryDataFileChunk.java | 2 +- .../message/ArbitraryDataFileMessage.java | 2 +- .../message/ArbitraryMetadataMessage.java | 2 +- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java index 051c8831..71378461 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java @@ -79,7 +79,7 @@ public class ArbitraryDataFile { this.signature = signature; } - public ArbitraryDataFile(byte[] fileContent, byte[] signature) throws DataException { + public ArbitraryDataFile(byte[] fileContent, byte[] signature, boolean useTemporaryFile) throws DataException { if (fileContent == null) { LOGGER.error("fileContent is null"); return; @@ -90,7 +90,20 @@ public class ArbitraryDataFile { this.signature = signature; LOGGER.trace(String.format("File digest: %s, size: %d bytes", this.hash58, fileContent.length)); - Path outputFilePath = getOutputFilePath(this.hash58, signature, true); + Path outputFilePath; + if (useTemporaryFile) { + try { + outputFilePath = Files.createTempFile("qortalRawData", null); + outputFilePath.toFile().deleteOnExit(); + } + catch (IOException e) { + throw new DataException(String.format("Unable to write data with hash %s to temporary file: %s", this.hash58, e.getMessage())); + } + } + else { + outputFilePath = getOutputFilePath(this.hash58, signature, true); + } + File outputFile = outputFilePath.toFile(); try (FileOutputStream outputStream = new FileOutputStream(outputFile)) { outputStream.write(fileContent); @@ -116,7 +129,7 @@ public class ArbitraryDataFile { if (data == null) { return null; } - return new ArbitraryDataFile(data, signature); + return new ArbitraryDataFile(data, signature, true); } public static ArbitraryDataFile fromTransactionData(ArbitraryTransactionData transactionData) throws DataException { diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataFileChunk.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataFileChunk.java index 5f6695df..1fd388da 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataFileChunk.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataFileChunk.java @@ -18,7 +18,7 @@ public class ArbitraryDataFileChunk extends ArbitraryDataFile { } public ArbitraryDataFileChunk(byte[] fileContent, byte[] signature) throws DataException { - super(fileContent, signature); + super(fileContent, signature, false); } public static ArbitraryDataFileChunk fromHash58(String hash58, byte[] signature) throws DataException { diff --git a/src/main/java/org/qortal/network/message/ArbitraryDataFileMessage.java b/src/main/java/org/qortal/network/message/ArbitraryDataFileMessage.java index 50991be3..936c9dca 100644 --- a/src/main/java/org/qortal/network/message/ArbitraryDataFileMessage.java +++ b/src/main/java/org/qortal/network/message/ArbitraryDataFileMessage.java @@ -68,7 +68,7 @@ public class ArbitraryDataFileMessage extends Message { byteBuffer.get(data); try { - ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(data, signature); + ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(data, signature, false); return new ArbitraryDataFileMessage(id, signature, arbitraryDataFile); } catch (DataException e) { LOGGER.info("Unable to process received file: {}", e.getMessage()); diff --git a/src/main/java/org/qortal/network/message/ArbitraryMetadataMessage.java b/src/main/java/org/qortal/network/message/ArbitraryMetadataMessage.java index 26601d4b..7d398f51 100644 --- a/src/main/java/org/qortal/network/message/ArbitraryMetadataMessage.java +++ b/src/main/java/org/qortal/network/message/ArbitraryMetadataMessage.java @@ -64,7 +64,7 @@ public class ArbitraryMetadataMessage extends Message { byteBuffer.get(data); try { - ArbitraryDataFile arbitraryMetadataFile = new ArbitraryDataFile(data, signature); + ArbitraryDataFile arbitraryMetadataFile = new ArbitraryDataFile(data, signature, false); return new ArbitraryMetadataMessage(id, signature, arbitraryMetadataFile); } catch (DataException e) { throw new MessageException("Unable to process arbitrary metadata message: " + e.getMessage(), e); From 8ca9423c52e32077fdd22862b591c22250c24dee Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Fri, 21 Apr 2023 10:58:09 -0400 Subject: [PATCH 151/215] Added missing parameter to test --- .../org/qortal/test/arbitrary/ArbitraryDataFileTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataFileTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataFileTests.java index aabbe502..d2ee61c6 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataFileTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataFileTests.java @@ -20,7 +20,7 @@ public class ArbitraryDataFileTests extends Common { @Test public void testSplitAndJoin() throws DataException { String dummyDataString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; - ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(dummyDataString.getBytes(), null); + ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(dummyDataString.getBytes(), null, false); assertTrue(arbitraryDataFile.exists()); assertEquals(62, arbitraryDataFile.size()); assertEquals("3eyjYjturyVe61grRX42bprGr3Cvw6ehTy4iknVnosDj", arbitraryDataFile.digest58()); @@ -50,7 +50,7 @@ public class ArbitraryDataFileTests extends Common { byte[] randomData = new byte[fileSize]; new Random().nextBytes(randomData); // No need for SecureRandom here - ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(randomData, null); + ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(randomData, null, false); assertTrue(arbitraryDataFile.exists()); assertEquals(fileSize, arbitraryDataFile.size()); String originalFileDigest = arbitraryDataFile.digest58(); From 4954a1744b31ae8498f966525c5f15f1b4aad8be Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 17:47:29 +0100 Subject: [PATCH 152/215] Fixed case sensitivity bugs. --- .../qortal/repository/hsqldb/HSQLDBArbitraryRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index 6ee1cad1..87841ca9 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -453,7 +453,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { // Handle name exact matches if (exactMatchNames != null && !exactMatchNames.isEmpty()) { sql.append(" AND LCASE(name) IN (?"); - bindParams.add(exactMatchNames.get(0)); + bindParams.add(exactMatchNames.get(0).toLowerCase()); for (int i = 1; i < exactMatchNames.size(); ++i) { sql.append(", ?"); @@ -467,7 +467,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { List followedNames = ListUtils.followedNames(); if (followedNames != null && !followedNames.isEmpty()) { sql.append(" AND LCASE(name) IN (?"); - bindParams.add(followedNames.get(0)); + bindParams.add(followedNames.get(0).toLowerCase()); for (int i = 1; i < followedNames.size(); ++i) { sql.append(", ?"); @@ -482,7 +482,7 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { List blockedNames = ListUtils.blockedNames(); if (blockedNames != null && !blockedNames.isEmpty()) { sql.append(" AND LCASE(name) NOT IN (?"); - bindParams.add(blockedNames.get(0)); + bindParams.add(blockedNames.get(0).toLowerCase()); for (int i = 1; i < blockedNames.size(); ++i) { sql.append(", ?"); From 3c251c35eac8e595cf3415614e27c1738bbdfbb0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 18:21:41 +0100 Subject: [PATCH 153/215] Fixed divide by zero error in `GET /arbitrary/resource/status/*` --- .../java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java index 01e7084d..54dd2af6 100644 --- a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceStatus.java @@ -46,7 +46,7 @@ public class ArbitraryResourceStatus { this.description = status.description; this.localChunkCount = localChunkCount; this.totalChunkCount = totalChunkCount; - this.percentLoaded = (this.localChunkCount != null && this.totalChunkCount != null) ? this.localChunkCount / (float)this.totalChunkCount * 100.0f : null; + this.percentLoaded = (this.localChunkCount != null && this.totalChunkCount != null && this.totalChunkCount > 0) ? this.localChunkCount / (float)this.totalChunkCount * 100.0f : null; } public ArbitraryResourceStatus(Status status) { From b1ebe1864b6fa499d27ad758309cb41d11d570be Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 19:27:24 +0100 Subject: [PATCH 154/215] Fixed bug in error handling. --- src/main/resources/q-apps/q-apps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index cef06a89..262d19a8 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -23,7 +23,7 @@ function httpGetAsyncWithEvent(event, url) { .catch((error) => { let res = {}; res.error = error; - handleResponse(JSON.stringify(res), responseText); + handleResponse(event, JSON.stringify(res)); }) } From db4a9ee88035ad3e91104e77fc2d12d1329cf756 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 19:50:01 +0100 Subject: [PATCH 155/215] Return "Resource does not exist" error if requesting a non-existent resource via GET_QDN_RESOURCE_URL. --- Q-Apps.md | 3 +++ src/main/resources/q-apps/q-apps.js | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 936ebd30..0f52c086 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -653,6 +653,7 @@ let res = await qortalRequest({ ``` ### Get URL to load a QDN resource +Note: this returns a "Resource does not exist" error if a non-existent resource is requested. ``` let url = await qortalRequest({ action: "GET_QDN_RESOURCE_URL", @@ -664,6 +665,7 @@ let url = await qortalRequest({ ``` ### Get URL to load a QDN website +Note: this returns a "Resource does not exist" error if a non-existent resource is requested. ``` let url = await qortalRequest({ action: "GET_QDN_RESOURCE_URL", @@ -673,6 +675,7 @@ let url = await qortalRequest({ ``` ### Get URL to load a specific file from a QDN website +Note: this returns a "Resource does not exist" error if a non-existent resource is requested. ``` let url = await qortalRequest({ action: "GET_QDN_RESOURCE_URL", diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 262d19a8..ba3ee39b 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -46,6 +46,18 @@ function handleResponse(event, response) { responseObj = response; } + // GET_QDN_RESOURCE_URL has custom handling + const data = event.data; + if (data.action == "GET_QDN_RESOURCE_URL") { + if (responseObj == null || responseObj.status == null || responseObj.status == "NOT_PUBLISHED") { + responseObj = {}; + responseObj.error = "Resource does not exist"; + } + else { + responseObj = buildResourceUrl(data.service, data.name, data.identifier, data.path, false); + } + } + // Respond to app if (responseObj.error != null) { event.ports[0].postMessage({ @@ -173,9 +185,10 @@ window.addEventListener("message", (event) => { return httpGetAsyncWithEvent(event, "/names/" + data.name); case "GET_QDN_RESOURCE_URL": - const response = buildResourceUrl(data.service, data.name, data.identifier, data.path, false); - handleResponse(event, response); - return; + // Check status first; URL is built ant returned automatically after status check + url = "/arbitrary/resource/status/" + data.service + "/" + data.name; + if (data.identifier != null) url = url.concat("/" + data.identifier); + return httpGetAsyncWithEvent(event, url); case "LINK_TO_QDN_RESOURCE": if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE From 111ec3b483f70ca22a9554e314c3a1d51176b215 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 20:05:24 +0100 Subject: [PATCH 156/215] Fixed typo --- src/main/resources/q-apps/q-apps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index ba3ee39b..2274cec0 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -185,7 +185,7 @@ window.addEventListener("message", (event) => { return httpGetAsyncWithEvent(event, "/names/" + data.name); case "GET_QDN_RESOURCE_URL": - // Check status first; URL is built ant returned automatically after status check + // Check status first; URL is built and returned automatically after status check url = "/arbitrary/resource/status/" + data.service + "/" + data.name; if (data.identifier != null) url = url.concat("/" + data.identifier); return httpGetAsyncWithEvent(event, url); From e80494b7847fba098c58fdc4ab6a035ad25064d9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 20:22:18 +0100 Subject: [PATCH 157/215] Fixed unit test. --- .../test/common/transaction/ArbitraryTestTransaction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java b/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java index 1290fd0a..8688ed73 100644 --- a/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java +++ b/src/test/java/org/qortal/test/common/transaction/ArbitraryTestTransaction.java @@ -33,7 +33,7 @@ public class ArbitraryTestTransaction extends TestTransaction { final byte[] metadataHash = new byte[32]; random.nextBytes(metadataHash); - byte[] data = new byte[1024]; + byte[] data = new byte[256]; random.nextBytes(data); DataType dataType = DataType.RAW_DATA; From 16dc23ddc7fd3a5d5552cc6f480df2817b2f0443 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Apr 2023 21:45:16 +0100 Subject: [PATCH 158/215] Added new actions to gateway handler. --- src/main/resources/q-apps/q-apps-gateway.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/resources/q-apps/q-apps-gateway.js b/src/main/resources/q-apps/q-apps-gateway.js index d8d15d06..d5028dca 100644 --- a/src/main/resources/q-apps/q-apps-gateway.js +++ b/src/main/resources/q-apps/q-apps-gateway.js @@ -50,12 +50,16 @@ window.addEventListener("message", (event) => { switch (data.action) { case "GET_USER_ACCOUNT": case "PUBLISH_QDN_RESOURCE": + case "PUBLISH_MULTIPLE_QDN_RESOURCES": case "SEND_CHAT_MESSAGE": case "JOIN_GROUP": case "DEPLOY_AT": case "GET_WALLET_BALANCE": case "SEND_COIN": - const errorString = "Authentication was requested, but this is not yet supported when viewing via a gateway. To use interactive features, please access using the Qortal UI desktop app. More info at: https://qortal.org"; + case "GET_LIST_ITEMS": + case "ADD_LIST_ITEMS": + case "DELETE_LIST_ITEM": + const errorString = "Interactive features were requested, but these are not yet supported when viewing via a gateway. To use interactive features, please access using the Qortal UI desktop app. More info at: https://qortal.org"; response = "{\"error\": \"" + errorString + "\"}" const modalText = "This app is powered by the Qortal blockchain. You are viewing in read-only mode. To use interactive features, please access using the Qortal UI desktop app."; From 33aeec7e87557c310eda4def6056d890b1124e55 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Apr 2023 11:00:21 +0100 Subject: [PATCH 159/215] Added various new service types, in preparation for Q-Apps release. --- Q-Apps.md | 22 ++++++++++++++++- .../org/qortal/arbitrary/misc/Service.java | 24 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 0f52c086..77095c72 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -46,6 +46,8 @@ IMAGE, THUMBNAIL, VIDEO, AUDIO, +PODCAST, +VOICE, ARBITRARY_DATA, JSON, DOCUMENT, @@ -55,7 +57,25 @@ METADATA, BLOG, BLOG_POST, BLOG_COMMENT, -GIF_REPOSITORY +GIF_REPOSITORY, +ATTACHMENT, +FILE, +FILES, +CHAIN_DATA, +STORE, +PRODUCT, +OFFER, +COUPON, +CODE, +PLUGIN, +EXTENSION, +GAME, +ITEM, +NFT, +DATABASE, +SNAPSHOT, +COMMENT, +CHAIN_COMMENT, WEBSITE, APP, QCHAT_ATTACHMENT, diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index fa47f020..3138ccd8 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -47,6 +47,10 @@ public enum Service { return ValidationResult.OK; } }, + ATTACHMENT(130, false, null, true, null), + FILE(140, false, null, true, null), + FILES(150, false, null, false, null), + CHAIN_DATA(160, false, 239L, true, null), WEBSITE(200, true, null, false, null) { @Override public ValidationResult validate(Path path) throws IOException { @@ -75,11 +79,13 @@ public enum Service { QCHAT_IMAGE(420, true, 500*1024L, true, null), VIDEO(500, false, null, true, null), AUDIO(600, false, null, true, null), + PODCAST(610, false, null, true, null), QCHAT_AUDIO(610, true, 10*1024*1024L, true, null), QCHAT_VOICE(620, true, 10*1024*1024L, true, null), + VOICE(630, true, 10*1024*1024L, true, null), BLOG(700, false, null, false, null), BLOG_POST(777, false, null, true, null), - BLOG_COMMENT(778, false, null, true, null), + BLOG_COMMENT(778, false, 500*1024L, true, null), DOCUMENT(800, false, null, true, null), LIST(900, true, null, true, null), PLAYLIST(910, true, null, true, null), @@ -139,7 +145,21 @@ public enum Service { } return ValidationResult.OK; } - }; + }, + STORE(1200, false, null, true, null), + PRODUCT(1210, false, null, true, null), + OFFER(1230, false, null, true, null), + COUPON(1240, false, null, true, null), + CODE(1300, false, null, true, null), + PLUGIN(1310, false, null, true, null), + EXTENSION(1320, false, null, true, null), + GAME(1400, false, null, false, null), + ITEM(1410, false, null, true, null), + NFT(1500, false, null, true, null), + DATABASE(1600, false, null, false, null), + SNAPSHOT(1610, false, null, false, null), + COMMENT(1700, false, 500*1024L, true, null), + CHAIN_COMMENT(1710, false, 239L, true, null); public final int value; private final boolean requiresValidation; From 53508f92983c152f0a767733e288034a5a9462d5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Apr 2023 11:33:59 +0100 Subject: [PATCH 160/215] Fixed problems in last commit. --- .../org/qortal/arbitrary/misc/Service.java | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 3138ccd8..da089675 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -50,7 +50,7 @@ public enum Service { ATTACHMENT(130, false, null, true, null), FILE(140, false, null, true, null), FILES(150, false, null, false, null), - CHAIN_DATA(160, false, 239L, true, null), + CHAIN_DATA(160, true, 239L, true, null), WEBSITE(200, true, null, false, null) { @Override public ValidationResult validate(Path path) throws IOException { @@ -79,13 +79,13 @@ public enum Service { QCHAT_IMAGE(420, true, 500*1024L, true, null), VIDEO(500, false, null, true, null), AUDIO(600, false, null, true, null), - PODCAST(610, false, null, true, null), QCHAT_AUDIO(610, true, 10*1024*1024L, true, null), QCHAT_VOICE(620, true, 10*1024*1024L, true, null), VOICE(630, true, 10*1024*1024L, true, null), + PODCAST(640, false, null, true, null), BLOG(700, false, null, false, null), BLOG_POST(777, false, null, true, null), - BLOG_COMMENT(778, false, 500*1024L, true, null), + BLOG_COMMENT(778, true, 500*1024L, true, null), DOCUMENT(800, false, null, true, null), LIST(900, true, null, true, null), PLAYLIST(910, true, null, true, null), @@ -146,20 +146,20 @@ public enum Service { return ValidationResult.OK; } }, - STORE(1200, false, null, true, null), - PRODUCT(1210, false, null, true, null), - OFFER(1230, false, null, true, null), - COUPON(1240, false, null, true, null), - CODE(1300, false, null, true, null), - PLUGIN(1310, false, null, true, null), - EXTENSION(1320, false, null, true, null), - GAME(1400, false, null, false, null), - ITEM(1410, false, null, true, null), - NFT(1500, false, null, true, null), - DATABASE(1600, false, null, false, null), - SNAPSHOT(1610, false, null, false, null), - COMMENT(1700, false, 500*1024L, true, null), - CHAIN_COMMENT(1710, false, 239L, true, null); + STORE(1300, false, null, true, null), + PRODUCT(1310, false, null, true, null), + OFFER(1330, false, null, true, null), + COUPON(1340, false, null, true, null), + CODE(1400, false, null, true, null), + PLUGIN(1410, false, null, true, null), + EXTENSION(1420, false, null, true, null), + GAME(1500, false, null, false, null), + ITEM(1510, false, null, true, null), + NFT(1600, false, null, true, null), + DATABASE(1700, false, null, false, null), + SNAPSHOT(1710, false, null, false, null), + COMMENT(1800, true, 500*1024L, true, null), + CHAIN_COMMENT(1810, true, 239L, true, null); public final int value; private final boolean requiresValidation; From e48529704c78e091e65d6223622806f34d91113f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Apr 2023 16:08:09 +0100 Subject: [PATCH 161/215] Bump version to 4.0.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 70366ada..bbb7bba3 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.9.1 + 4.0.0 jar true From f27c9193c744d5b32cda3cb8aad1416259fd80d1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Apr 2023 11:30:42 +0100 Subject: [PATCH 162/215] Auto delete any metadata files that are unreadable (e.g. due to being empty, or invalid JSON). --- .../qortal/arbitrary/metadata/ArbitraryDataMetadata.java | 6 +++++- .../controller/arbitrary/ArbitraryMetadataManager.java | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java index 07f6032c..06d02340 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java @@ -50,7 +50,7 @@ public class ArbitraryDataMetadata { this.readJson(); } catch (JSONException e) { - throw new DataException(String.format("Unable to read JSON: %s", e.getMessage())); + throw new DataException(String.format("Unable to read JSON at path %s: %s", this.filePath, e.getMessage())); } } @@ -64,6 +64,10 @@ public class ArbitraryDataMetadata { writer.close(); } + public void delete() throws IOException { + Files.delete(this.filePath); + } + protected void loadJson() throws IOException { File metadataFile = new File(this.filePath.toString()); diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java index 97d659ad..663bc22a 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryMetadataManager.java @@ -102,7 +102,14 @@ public class ArbitraryMetadataManager { if (metadataFile.exists()) { // Use local copy ArbitraryDataTransactionMetadata transactionMetadata = new ArbitraryDataTransactionMetadata(metadataFile.getFilePath()); - transactionMetadata.read(); + try { + transactionMetadata.read(); + } catch (DataException e) { + // Invalid file, so delete it + LOGGER.info("Deleting invalid metadata file due to exception: {}", e.getMessage()); + transactionMetadata.delete(); + return null; + } return transactionMetadata; } } From ed6333f82ee260f36d60f9382b41db9fb95ff4e0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Apr 2023 19:14:28 +0100 Subject: [PATCH 163/215] Allow for faster and more frequent retries when QDN data fails to be retrieved (thanks to suggestions from @xspektrex) --- .../ArbitraryDataFileListManager.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java index 2fd6033e..5ed8df21 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileListManager.java @@ -124,29 +124,29 @@ public class ArbitraryDataFileListManager { } } - // Then allow another 3 attempts, each 5 minutes apart - if (timeSinceLastAttempt > 5 * 60 * 1000L) { - // We haven't tried for at least 5 minutes + // Then allow another 5 attempts, each 1 minute apart + if (timeSinceLastAttempt > 60 * 1000L) { + // We haven't tried for at least 1 minute - if (networkBroadcastCount < 6) { - // We've made less than 6 total attempts + if (networkBroadcastCount < 8) { + // We've made less than 8 total attempts return true; } } - // Then allow another 4 attempts, each 30 minutes apart - if (timeSinceLastAttempt > 30 * 60 * 1000L) { - // We haven't tried for at least 5 minutes + // Then allow another 8 attempts, each 15 minutes apart + if (timeSinceLastAttempt > 15 * 60 * 1000L) { + // We haven't tried for at least 15 minutes - if (networkBroadcastCount < 10) { - // We've made less than 10 total attempts + if (networkBroadcastCount < 16) { + // We've made less than 16 total attempts return true; } } - // From then on, only try once every 24 hours, to reduce network spam - if (timeSinceLastAttempt > 24 * 60 * 60 * 1000L) { - // We haven't tried for at least 24 hours + // From then on, only try once every 6 hours, to reduce network spam + if (timeSinceLastAttempt > 6 * 60 * 60 * 1000L) { + // We haven't tried for at least 6 hours return true; } From 1ce2dcfb2b339b58c643282e7409025ac8157455 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 25 Apr 2023 08:33:33 +0100 Subject: [PATCH 164/215] Fixed bug which prevented qortal:// URLs from working properly in most cases. --- src/main/resources/q-apps/q-apps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 2274cec0..9ae4e478 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -1,4 +1,4 @@ -function httpGet(event, url) { +function httpGet(url) { var request = new XMLHttpRequest(); request.open("GET", url, false); request.send(null); From 5dbacc4db379c9346b7c2d6947a6f70308c82dde Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Apr 2023 10:12:16 +0100 Subject: [PATCH 165/215] Added "Accept-Ranges" header when serving arbitrary data. Allows for video seeking when using URL playback, even though the Range header isn't implemented yet. This could be heavily optimized by adding full support of the Range/Content-Range headers, however this is still a big step forward as it allows for (inefficient) seeking. --- src/main/java/org/qortal/api/resource/ArbitraryResource.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 3d1a6a2e..64ee2a6f 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -1341,6 +1341,7 @@ public class ArbitraryResource { data = Base64.encode(data); } + response.addHeader("Accept-Ranges", "bytes"); response.setContentType(context.getMimeType(path.toString())); response.setContentLength(data.length); response.getOutputStream().write(data); From 0a1ab3d68583b24f9588f376c62416d864b3f87e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Apr 2023 10:57:04 +0100 Subject: [PATCH 166/215] Added GET_QDN_RESOURCE_METADATA action. --- Q-Apps.md | 15 +++++++++++++-- .../qortal/api/resource/ArbitraryResource.java | 9 +++------ src/main/resources/q-apps/q-apps.js | 5 +++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 77095c72..0f5bc7e8 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -240,6 +240,9 @@ Here is a list of currently supported actions: - SEARCH_QDN_RESOURCES - GET_QDN_RESOURCE_STATUS - GET_QDN_RESOURCE_PROPERTIES +- GET_QDN_RESOURCE_METADATA +- GET_QDN_RESOURCE_URL +- LINK_TO_QDN_RESOURCE - FETCH_QDN_RESOURCE - PUBLISH_QDN_RESOURCE - PUBLISH_MULTIPLE_QDN_RESOURCES @@ -258,8 +261,6 @@ Here is a list of currently supported actions: - FETCH_BLOCK_RANGE - SEARCH_TRANSACTIONS - GET_PRICE -- GET_QDN_RESOURCE_URL -- LINK_TO_QDN_RESOURCE - GET_LIST_ITEMS - ADD_LIST_ITEMS - DELETE_LIST_ITEM @@ -420,6 +421,16 @@ let res = await qortalRequest({ // Returns: filename, size, mimeType (where available) ``` +### Get QDN resource metadata +``` +let res = await qortalRequest({ + action: "GET_QDN_RESOURCE_METADATA", + name: "QortalDemo", + service: "THUMBNAIL", + identifier: "qortal_avatar" // Optional +}); +``` + ### Publish a single file to QDN _Requires user approval_.
Note: this publishes a single, base64-encoded file. Multi-file resource publishing (such as a WEBSITE or GIF_REPOSITORY) is not yet supported via a Q-App. It will be added in a future update. diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 64ee2a6f..dddad594 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -721,12 +721,9 @@ public class ArbitraryResource { } ) @SecurityRequirement(name = "apiKey") - public ArbitraryResourceMetadata getMetadata(@HeaderParam(Security.API_KEY_HEADER) String apiKey, - @PathParam("service") Service service, - @PathParam("name") String name, - @PathParam("identifier") String identifier) { - Security.checkApiCallAllowed(request); - + public ArbitraryResourceMetadata getMetadata(@PathParam("service") Service service, + @PathParam("name") String name, + @PathParam("identifier") String identifier) { ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier); try { diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 9ae4e478..9da494c0 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -250,6 +250,11 @@ window.addEventListener("message", (event) => { url = "/arbitrary/resource/properties/" + data.service + "/" + data.name + "/" + identifier; return httpGetAsyncWithEvent(event, url); + case "GET_QDN_RESOURCE_METADATA": + identifier = (data.identifier != null) ? data.identifier : "default"; + url = "/arbitrary/metadata/" + data.service + "/" + data.name + "/" + identifier; + return httpGetAsyncWithEvent(event, url); + case "SEARCH_CHAT_MESSAGES": url = "/chat/messages?"; if (data.before != null) url = url.concat("&before=" + data.before); From a3518d1f059eeffb2a67ba032f4a97f9add862e9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Apr 2023 12:13:31 +0100 Subject: [PATCH 167/215] Revert "Fixed bug with base path." This reverts commit ce52b3949501cccf66240093a077686b9f48c664. --- src/main/java/org/qortal/api/HTMLParser.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index eac813a9..3cba9a62 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -24,7 +24,8 @@ public class HTMLParser { public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data, String qdnContext, Service service, String identifier, String theme) { - this.linkPrefix = usePrefix ? String.format("%s/%s", prefix, resourceId) : ""; + String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : ""; + this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; this.data = data; this.qdnContext = qdnContext; this.resourceId = resourceId; From 46e2e1043d40d2472c735ab5eed1382abe526688 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Apr 2023 12:18:27 +0100 Subject: [PATCH 168/215] Fixed issue with introduced in v4.0.0 --- src/main/java/org/qortal/api/HTMLParser.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 3cba9a62..72a35ed9 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -14,6 +14,7 @@ public class HTMLParser { private static final Logger LOGGER = LogManager.getLogger(HTMLParser.class); private String linkPrefix; + private String qdnBase; private byte[] data; private String qdnContext; private String resourceId; @@ -26,6 +27,7 @@ public class HTMLParser { String qdnContext, Service service, String identifier, String theme) { String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : ""; this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; + this.qdnBase = usePrefix ? String.format("%s/%s", prefix, resourceId) : ""; this.data = data; this.qdnContext = qdnContext; this.resourceId = resourceId; @@ -38,7 +40,6 @@ public class HTMLParser { public void addAdditionalHeaderTags() { String fileContents = new String(data); Document document = Jsoup.parse(fileContents); - String baseUrl = this.linkPrefix; Elements head = document.getElementsByTag("head"); if (!head.isEmpty()) { // Add q-apps script tag @@ -57,11 +58,11 @@ public class HTMLParser { String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : ""; String path = this.path != null ? this.path.replace("\"","\\\"") : ""; String theme = this.theme != null ? this.theme.replace("\"","\\\"") : ""; - String qdnContextVar = String.format("", this.qdnContext, theme, service, name, identifier, path, baseUrl); + String qdnContextVar = String.format("", this.qdnContext, theme, service, name, identifier, path, this.qdnBase); head.get(0).prepend(qdnContextVar); // Add base href tag - String baseElement = String.format("", baseUrl); + String baseElement = String.format("", this.linkPrefix); head.get(0).prepend(baseElement); // Add meta charset tag From 45bc2e46d6c43c7d99b1ce15cd86c1826c4b5b34 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Apr 2023 12:48:38 +0100 Subject: [PATCH 169/215] Improved metadata trimming, to better handle multibyte UTF-8 characters. --- .../ArbitraryDataTransactionMetadata.java | 24 ++++++++++- .../ArbitraryTransactionMetadataTests.java | 41 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java index 004e0ed3..d9dba037 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java @@ -7,6 +7,7 @@ import org.qortal.arbitrary.misc.Category; import org.qortal.repository.DataException; import org.qortal.utils.Base58; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; @@ -217,6 +218,25 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { // Static helper methods + public static String trimUTF8String(String string, int maxLength) { + byte[] inputBytes = string.getBytes(StandardCharsets.UTF_8); + int length = Math.min(inputBytes.length, maxLength); + byte[] outputBytes = new byte[length]; + + System.arraycopy(inputBytes, 0, outputBytes, 0, length); + String result = new String(outputBytes, StandardCharsets.UTF_8); + + // check if last character is truncated + int lastIndex = result.length() - 1; + + if (lastIndex > 0 && result.charAt(lastIndex) != string.charAt(lastIndex)) { + // last character is truncated so remove the last character + return result.substring(0, lastIndex); + } + + return result; + } + public static String limitTitle(String title) { if (title == null) { return null; @@ -225,7 +245,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { return null; } - return title.substring(0, Math.min(title.length(), MAX_TITLE_LENGTH)); + return trimUTF8String(title, MAX_TITLE_LENGTH); } public static String limitDescription(String description) { @@ -236,7 +256,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { return null; } - return description.substring(0, Math.min(description.length(), MAX_DESCRIPTION_LENGTH)); + return trimUTF8String(description, MAX_DESCRIPTION_LENGTH); } public static List limitTags(List tags) { diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java index 37da4e31..47c68b25 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java @@ -248,6 +248,47 @@ public class ArbitraryTransactionMetadataTests extends Common { } } + @Test + public void testUTF8Metadata() throws DataException, IOException, MissingDataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = null; // Not used for this test + Service service = Service.ARBITRARY_DATA; + int chunkSize = 100; + int dataLength = 900; // Actual data length will be longer due to encryption + + // Example (modified) strings from real world content + String title = "Доля юаня в трансграничных Доля юаня в трансграничных"; + String description = "Когда рыночек порешал"; + List tags = Arrays.asList("Доля", "юаня", "трансграничных"); + Category category = Category.OTHER; + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Create PUT transaction + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, + title, description, tags, category); + + // Check the chunk count is correct + assertEquals(10, arbitraryDataFile.chunkCount()); + + // Check the metadata is correct + String expectedTrimmedTitle = "Доля юаня в трансграничных Доля юаня в тран"; + assertEquals(expectedTrimmedTitle, arbitraryDataFile.getMetadata().getTitle()); + assertEquals(description, arbitraryDataFile.getMetadata().getDescription()); + assertEquals(tags, arbitraryDataFile.getMetadata().getTags()); + assertEquals(category, arbitraryDataFile.getMetadata().getCategory()); + assertEquals("text/plain", arbitraryDataFile.getMetadata().getMimeType()); + } + } + @Test public void testMetadataLengths() throws DataException, IOException, MissingDataException { try (final Repository repository = RepositoryManager.getRepository()) { From 6dfaaf0054aaf5847ad3b75b12de368799ac37b4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Apr 2023 13:06:29 +0100 Subject: [PATCH 170/215] Set charset to UTF-8 in various places that bytes are converted to a string. --- .../org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java | 3 ++- .../qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java | 3 ++- src/main/java/org/qortal/arbitrary/misc/Service.java | 2 +- src/main/java/org/qortal/list/ResourceList.java | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java index 06d02340..498f3296 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataMetadata.java @@ -9,6 +9,7 @@ import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -75,7 +76,7 @@ public class ArbitraryDataMetadata { throw new IOException(String.format("Metadata file doesn't exist: %s", this.filePath.toString())); } - this.jsonString = new String(Files.readAllBytes(this.filePath)); + this.jsonString = new String(Files.readAllBytes(this.filePath), StandardCharsets.UTF_8); } diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java index df23655c..eb3d6cc9 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataQortalMetadata.java @@ -9,6 +9,7 @@ import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -69,7 +70,7 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata { throw new IOException(String.format("Patch file doesn't exist: %s", path.toString())); } - this.jsonString = new String(Files.readAllBytes(path)); + this.jsonString = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); } diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index da089675..03c38a56 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -227,7 +227,7 @@ public enum Service { } public static JSONObject toJsonObject(byte[] data) { - String dataString = new String(data); + String dataString = new String(data, StandardCharsets.UTF_8); return new JSONObject(dataString); } diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java index 5c12e0f5..855c9068 100644 --- a/src/main/java/org/qortal/list/ResourceList.java +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -9,6 +9,7 @@ import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -81,7 +82,7 @@ public class ResourceList { } try { - String jsonString = new String(Files.readAllBytes(path)); + String jsonString = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); this.list = ResourceList.listFromJSONString(jsonString); } catch (IOException e) { throw new IOException(String.format("Couldn't read contents from file %s", path.toString())); From aed1823afbd852b5f706d94314b3da41386e3a79 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Apr 2023 20:36:06 +0100 Subject: [PATCH 171/215] Added support of simple Range headers when requesting QDN data. --- .../api/resource/ArbitraryResource.java | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index dddad594..1101e71d 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -65,10 +65,7 @@ import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.ArbitraryTransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer; -import org.qortal.utils.ArbitraryTransactionUtils; -import org.qortal.utils.Base58; -import org.qortal.utils.NTP; -import org.qortal.utils.ZipUtils; +import org.qortal.utils.*; @Path("/arbitrary") @Tag(name = "Arbitrary") @@ -1324,14 +1321,43 @@ public class ArbitraryResource { } } - // TODO: limit file size that can be read into memory java.nio.file.Path path = Paths.get(outputPath.toString(), filepath); if (!Files.exists(path)) { String message = String.format("No file exists at filepath: %s", filepath); throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, message); } - byte[] data = Files.readAllBytes(path); + byte[] data; + int fileSize = (int)path.toFile().length(); + int length = fileSize; + + // Parse "Range" header + Integer rangeStart = null; + Integer rangeEnd = null; + String range = request.getHeader("Range"); + if (range != null) { + range = range.replace("bytes=", ""); + String[] parts = range.split("-"); + rangeStart = (parts != null && parts.length > 0) ? Integer.parseInt(parts[0]) : null; + rangeEnd = (parts != null && parts.length > 1) ? Integer.parseInt(parts[1]) : fileSize; + } + + if (rangeStart != null && rangeEnd != null) { + // We have a range, so update the requested length + length = rangeEnd - rangeStart; + } + + if (length < fileSize && encoding == null) { + // Partial content requested, and not encoding the data + response.setStatus(206); + response.addHeader("Content-Range", String.format("bytes %d-%d/%d", rangeStart, rangeEnd-1, fileSize)); + data = FilesystemUtils.readFromFile(path.toString(), rangeStart, length); + } + else { + // Full content requested (or encoded data) + response.setStatus(200); + data = Files.readAllBytes(path); // TODO: limit file size that can be read into memory + } // Encode the data if requested if (encoding != null && Objects.equals(encoding.toLowerCase(), "base64")) { From f044166b81f248dccf51c855037b596f616e2f51 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 29 Apr 2023 17:13:50 +0100 Subject: [PATCH 172/215] More qdnBase improvements, to hopefully handle all cases correctly. --- src/main/java/org/qortal/api/HTMLParser.java | 14 +++++++++----- .../qortal/arbitrary/ArbitraryDataRenderer.java | 6 ++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 72a35ed9..03cdb066 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -13,8 +13,8 @@ public class HTMLParser { private static final Logger LOGGER = LogManager.getLogger(HTMLParser.class); - private String linkPrefix; private String qdnBase; + private String qdnBaseWithPath; private byte[] data; private String qdnContext; private String resourceId; @@ -22,12 +22,13 @@ public class HTMLParser { private String identifier; private String path; private String theme; + private boolean usingCustomRouting; public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data, - String qdnContext, Service service, String identifier, String theme) { + String qdnContext, Service service, String identifier, String theme, boolean usingCustomRouting) { String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : ""; - this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; this.qdnBase = usePrefix ? String.format("%s/%s", prefix, resourceId) : ""; + this.qdnBaseWithPath = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; this.data = data; this.qdnContext = qdnContext; this.resourceId = resourceId; @@ -35,6 +36,7 @@ public class HTMLParser { this.identifier = identifier; this.path = inPath; this.theme = theme; + this.usingCustomRouting = usingCustomRouting; } public void addAdditionalHeaderTags() { @@ -58,11 +60,13 @@ public class HTMLParser { String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : ""; String path = this.path != null ? this.path.replace("\"","\\\"") : ""; String theme = this.theme != null ? this.theme.replace("\"","\\\"") : ""; - String qdnContextVar = String.format("", this.qdnContext, theme, service, name, identifier, path, this.qdnBase); + String qdnContextVar = String.format("", this.qdnContext, theme, service, name, identifier, path, this.qdnBase, this.qdnBaseWithPath); head.get(0).prepend(qdnContextVar); // Add base href tag - String baseElement = String.format("", this.linkPrefix); + // Exclude the path if this request was routed back to the index automatically + String baseHref = this.usingCustomRouting ? this.qdnBase : this.qdnBaseWithPath; + String baseElement = String.format("", baseHref); head.get(0).prepend(baseElement); // Add meta charset tag diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 66fc7b98..97641f32 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -126,7 +126,8 @@ public class ArbitraryDataRenderer { try { String filename = this.getFilename(unzippedPath, inPath); Path filePath = Paths.get(unzippedPath, filename); - + boolean usingCustomRouting = false; + // If the file doesn't exist, we may need to route the request elsewhere, or cleanup if (!Files.exists(filePath)) { if (inPath.equals("/")) { @@ -148,6 +149,7 @@ public class ArbitraryDataRenderer { // Forward request to index file filePath = indexPath; filename = indexFile; + usingCustomRouting = true; break; } } @@ -157,7 +159,7 @@ public class ArbitraryDataRenderer { if (HTMLParser.isHtmlFile(filename)) { // HTML file - needs to be parsed byte[] data = Files.readAllBytes(filePath); // TODO: limit file size that can be read into memory - HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext, service, identifier, theme); + HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext, service, identifier, theme, usingCustomRouting); htmlParser.addAdditionalHeaderTags(); response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:;"); response.setContentType(context.getMimeType(filename)); From 36e944d7e2fb7b3670d6bb36f549bd2d9b96925f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 29 Apr 2023 17:45:38 +0100 Subject: [PATCH 173/215] Added MAIL and MESSAGE services. --- src/main/java/org/qortal/arbitrary/misc/Service.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 03c38a56..fc35bc6f 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -159,7 +159,9 @@ public enum Service { DATABASE(1700, false, null, false, null), SNAPSHOT(1710, false, null, false, null), COMMENT(1800, true, 500*1024L, true, null), - CHAIN_COMMENT(1810, true, 239L, true, null); + CHAIN_COMMENT(1810, true, 239L, true, null), + MAIL(1900, true, null, true, null), + MESSAGE(1910, true, null, true, null); public final int value; private final boolean requiresValidation; From 95a1c6bf8b0f80f143e73f73347d074389106dbe Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 29 Apr 2023 17:48:58 +0100 Subject: [PATCH 174/215] Added "encoding" parameter to the SEARCH_CHAT_MESSAGES action. --- Q-Apps.md | 1 + src/main/resources/q-apps/q-apps.js | 1 + 2 files changed, 2 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 0f5bc7e8..94f7414f 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -539,6 +539,7 @@ let res = await qortalRequest({ // reference: "reference", // Optional // chatReference: "chatreference", // Optional // hasChatReference: true, // Optional + encoding: "BASE64", // Optional (defaults to BASE58 if omitted) limit: 100, offset: 0, reverse: true diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 9da494c0..f6075e8e 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -264,6 +264,7 @@ window.addEventListener("message", (event) => { if (data.reference != null) url = url.concat("&reference=" + data.reference); if (data.chatReference != null) url = url.concat("&chatreference=" + data.chatReference); if (data.hasChatReference != null) url = url.concat("&haschatreference=" + new Boolean(data.hasChatReference).toString()); + if (data.encoding != null) url = url.concat("&encoding=" + data.encoding); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); From 34c3adf280da2ab263af48bae7bb957979ea2b95 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 29 Apr 2023 19:04:17 +0100 Subject: [PATCH 175/215] Limit MAIL and MESSAGE to 1MB. --- src/main/java/org/qortal/arbitrary/misc/Service.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index fc35bc6f..27557045 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -160,8 +160,8 @@ public enum Service { SNAPSHOT(1710, false, null, false, null), COMMENT(1800, true, 500*1024L, true, null), CHAIN_COMMENT(1810, true, 239L, true, null), - MAIL(1900, true, null, true, null), - MESSAGE(1910, true, null, true, null); + MAIL(1900, true, 1024*1024L, true, null), + MESSAGE(1910, true, 1024*1024L, true, null); public final int value; private final boolean requiresValidation; From c71dce92b5a43a8759362836f5cefe9bc2354153 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 1 May 2023 19:34:01 +0100 Subject: [PATCH 176/215] Bump version to 4.0.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index bbb7bba3..ff9c9db1 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.0.0 + 4.0.1 jar true From 611240650ed031b3fcf26f8d4b93fcacc8bf7a15 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 3 May 2023 19:27:59 +0100 Subject: [PATCH 177/215] Added GET /chat/messages/count endpoint, which is identical to /chat/messages but returns a count of the messages rather than the messages themselves. --- .../org/qortal/api/resource/ChatResource.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index 986bb03d..22e90a43 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -119,6 +119,75 @@ public class ChatResource { } } + @GET + @Path("/messages/count") + @Operation( + summary = "Count chat messages", + description = "Returns count of CHAT messages that match criteria. Must provide EITHER 'txGroupId' OR two 'involving' addresses.", + responses = { + @ApiResponse( + description = "count of messages", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "integer" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public int countChatMessages(@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("sender") String sender, + @QueryParam("encoding") Encoding encoding, + @Parameter(ref = "limit") @QueryParam("limit") Integer limit, + @Parameter(ref = "offset") @QueryParam("offset") Integer offset, + @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { + // Check args meet expectations + if ((txGroupId == null && involvingAddresses.size() != 2) + || (txGroupId != null && !involvingAddresses.isEmpty())) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Check any provided addresses are valid + if (involvingAddresses.stream().anyMatch(address -> !Crypto.isValidAddress(address))) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (before != null && before < 1500000000000L) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (after != null && after < 1500000000000L) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + 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, + sender, + encoding, + limit, offset, reverse).size(); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @GET @Path("/message/{signature}") @Operation( From e014a207efd2d639797688b5f0e48a90aad92bba Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 3 May 2023 19:28:26 +0100 Subject: [PATCH 178/215] Escape all vars added by HTML parser --- src/main/java/org/qortal/api/HTMLParser.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 03cdb066..2bf8947d 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -55,12 +55,15 @@ public class HTMLParser { } // Escape and add vars + String qdnContext = this.qdnContext != null ? this.qdnContext.replace("\"","\\\"") : ""; String service = this.service.toString().replace("\"","\\\""); String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"") : ""; String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : ""; String path = this.path != null ? this.path.replace("\"","\\\"") : ""; String theme = this.theme != null ? this.theme.replace("\"","\\\"") : ""; - String qdnContextVar = String.format("", this.qdnContext, theme, service, name, identifier, path, this.qdnBase, this.qdnBaseWithPath); + String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\"","\\\"") : ""; + String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\"","\\\"") : ""; + String qdnContextVar = String.format("", qdnContext, theme, service, name, identifier, path, qdnBase, qdnBaseWithPath); head.get(0).prepend(qdnContextVar); // Add base href tag From 9547a087b25401183c0ca7d87c3a757858bdd862 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 3 May 2023 19:38:31 +0100 Subject: [PATCH 179/215] Remove all backslashes from vars in HTML parser. --- src/main/java/org/qortal/api/HTMLParser.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 2bf8947d..4e97e2bd 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -55,14 +55,14 @@ public class HTMLParser { } // Escape and add vars - String qdnContext = this.qdnContext != null ? this.qdnContext.replace("\"","\\\"") : ""; - String service = this.service.toString().replace("\"","\\\""); - String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"") : ""; - String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : ""; - String path = this.path != null ? this.path.replace("\"","\\\"") : ""; - String theme = this.theme != null ? this.theme.replace("\"","\\\"") : ""; - String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\"","\\\"") : ""; - String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\"","\\\"") : ""; + String qdnContext = this.qdnContext != null ? this.qdnContext.replace("\"","\\\"").replace("\\", "") : ""; + String service = this.service.toString().replace("\"","\\\"").replace("\\", ""); + String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"").replace("\\", "") : ""; + String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"").replace("\\", "") : ""; + String path = this.path != null ? this.path.replace("\"","\\\"").replace("\\", "") : ""; + String theme = this.theme != null ? this.theme.replace("\"","\\\"").replace("\\", "") : ""; + String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\"","\\\"").replace("\\", "") : ""; + String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\"","\\\"").replace("\\", "") : ""; String qdnContextVar = String.format("", qdnContext, theme, service, name, identifier, path, qdnBase, qdnBaseWithPath); head.get(0).prepend(qdnContextVar); From b9d81645f89a6f31f63405eb5bbc722abf281441 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 3 May 2023 19:40:17 +0100 Subject: [PATCH 180/215] Revert "Remove all backslashes from vars in HTML parser." This reverts commit 9547a087b25401183c0ca7d87c3a757858bdd862. --- src/main/java/org/qortal/api/HTMLParser.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 4e97e2bd..2bf8947d 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -55,14 +55,14 @@ public class HTMLParser { } // Escape and add vars - String qdnContext = this.qdnContext != null ? this.qdnContext.replace("\"","\\\"").replace("\\", "") : ""; - String service = this.service.toString().replace("\"","\\\"").replace("\\", ""); - String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"").replace("\\", "") : ""; - String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"").replace("\\", "") : ""; - String path = this.path != null ? this.path.replace("\"","\\\"").replace("\\", "") : ""; - String theme = this.theme != null ? this.theme.replace("\"","\\\"").replace("\\", "") : ""; - String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\"","\\\"").replace("\\", "") : ""; - String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\"","\\\"").replace("\\", "") : ""; + String qdnContext = this.qdnContext != null ? this.qdnContext.replace("\"","\\\"") : ""; + String service = this.service.toString().replace("\"","\\\""); + String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"") : ""; + String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : ""; + String path = this.path != null ? this.path.replace("\"","\\\"") : ""; + String theme = this.theme != null ? this.theme.replace("\"","\\\"") : ""; + String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\"","\\\"") : ""; + String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\"","\\\"") : ""; String qdnContextVar = String.format("", qdnContext, theme, service, name, identifier, path, qdnBase, qdnBaseWithPath); head.get(0).prepend(qdnContextVar); From 2dfee13d86b4f984b8ba8bccf7017568fdc8cb3b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 3 May 2023 19:44:54 +0100 Subject: [PATCH 181/215] Remove all backslashes from vars in HTML parser (correct order this time) --- src/main/java/org/qortal/api/HTMLParser.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index 2bf8947d..cc3102e8 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -55,14 +55,14 @@ public class HTMLParser { } // Escape and add vars - String qdnContext = this.qdnContext != null ? this.qdnContext.replace("\"","\\\"") : ""; - String service = this.service.toString().replace("\"","\\\""); - String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"") : ""; - String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : ""; - String path = this.path != null ? this.path.replace("\"","\\\"") : ""; - String theme = this.theme != null ? this.theme.replace("\"","\\\"") : ""; - String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\"","\\\"") : ""; - String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\"","\\\"") : ""; + String qdnContext = this.qdnContext != null ? this.qdnContext.replace("\\", "").replace("\"","\\\"") : ""; + String service = this.service.toString().replace("\\", "").replace("\"","\\\""); + String name = this.resourceId != null ? this.resourceId.replace("\\", "").replace("\"","\\\"") : ""; + String identifier = this.identifier != null ? this.identifier.replace("\\", "").replace("\"","\\\"") : ""; + String path = this.path != null ? this.path.replace("\\", "").replace("\"","\\\"") : ""; + String theme = this.theme != null ? this.theme.replace("\\", "").replace("\"","\\\"") : ""; + String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\\", "").replace("\"","\\\"") : ""; + String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\\", "").replace("\"","\\\"") : ""; String qdnContextVar = String.format("", qdnContext, theme, service, name, identifier, path, qdnBase, qdnBaseWithPath); head.get(0).prepend(qdnContextVar); From f39b6a15da6e418b40b2a0102019da6ed2d84e14 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 5 May 2023 11:03:13 +0100 Subject: [PATCH 182/215] Fixed refresh bug on Windows. --- src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 97641f32..089a99ca 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -67,8 +67,8 @@ public class ArbitraryDataRenderer { } public HttpServletResponse render() { - if (!inPath.startsWith(File.separator)) { - inPath = File.separator + inPath; + if (!inPath.startsWith("/")) { + inPath = "/" + inPath; } // Don't render data if QDN is disabled From 1a5e3b4fb1012c37530b23c2f5596fa33cd3ec17 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 5 May 2023 11:24:52 +0100 Subject: [PATCH 183/215] Added `GET /names/search` endpoint, to search names via case insensitive, partial name matching. --- .../qortal/api/resource/NamesResource.java | 32 ++++++++++++ .../org/qortal/repository/NameRepository.java | 2 + .../hsqldb/HSQLDBNameRepository.java | 51 +++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/NamesResource.java b/src/main/java/org/qortal/api/resource/NamesResource.java index a900d6bf..03dffc08 100644 --- a/src/main/java/org/qortal/api/resource/NamesResource.java +++ b/src/main/java/org/qortal/api/resource/NamesResource.java @@ -155,6 +155,38 @@ public class NamesResource { } } + @GET + @Path("/search") + @Operation( + summary = "Search registered names", + responses = { + @ApiResponse( + description = "registered name info", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + array = @ArraySchema(schema = @Schema(implementation = NameData.class)) + ) + ) + } + ) + @ApiErrors({ApiError.NAME_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public List searchNames(@QueryParam("query") String query, + @Parameter(ref = "limit") @QueryParam("limit") Integer limit, + @Parameter(ref = "offset") @QueryParam("offset") Integer offset, + @Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) { + try (final Repository repository = RepositoryManager.getRepository()) { + if (query == null) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Missing query"); + } + + return repository.getNameRepository().searchNames(query, limit, offset, reverse); + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @POST @Path("/register") diff --git a/src/main/java/org/qortal/repository/NameRepository.java b/src/main/java/org/qortal/repository/NameRepository.java index d6c0f33e..a8b2a3db 100644 --- a/src/main/java/org/qortal/repository/NameRepository.java +++ b/src/main/java/org/qortal/repository/NameRepository.java @@ -14,6 +14,8 @@ public interface NameRepository { public boolean reducedNameExists(String reducedName) throws DataException; + public List searchNames(String query, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException; public default List getAllNames() throws DataException { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java index 3a3574ef..3e4a8e11 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java @@ -103,6 +103,57 @@ public class HSQLDBNameRepository implements NameRepository { } } + public List searchNames(String query, Integer limit, Integer offset, Boolean reverse) throws DataException { + StringBuilder sql = new StringBuilder(512); + List bindParams = new ArrayList<>(); + + sql.append("SELECT name, reduced_name, owner, data, registered_when, updated_when, " + + "is_for_sale, sale_price, reference, creation_group_id FROM Names " + + "WHERE LCASE(name) LIKE ? ORDER BY name"); + + bindParams.add(String.format("%%%s%%", query.toLowerCase())); + + if (reverse != null && reverse) + sql.append(" DESC"); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List names = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return names; + + do { + String name = resultSet.getString(1); + String reducedName = resultSet.getString(2); + String owner = resultSet.getString(3); + String data = resultSet.getString(4); + long registered = resultSet.getLong(5); + + // Special handling for possibly-NULL "updated" column + Long updated = resultSet.getLong(6); + if (updated == 0 && resultSet.wasNull()) + updated = null; + + boolean isForSale = resultSet.getBoolean(7); + + Long salePrice = resultSet.getLong(8); + if (salePrice == 0 && resultSet.wasNull()) + salePrice = null; + + byte[] reference = resultSet.getBytes(9); + int creationGroupId = resultSet.getInt(10); + + names.add(new NameData(name, reducedName, owner, data, registered, updated, isForSale, salePrice, reference, creationGroupId)); + } while (resultSet.next()); + + return names; + } catch (SQLException e) { + throw new DataException("Unable to search names in repository", e); + } + } + @Override public List getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(256); From c172a5764b353cf6d80c1d22d30067dbc5cfbc85 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 5 May 2023 12:26:18 +0100 Subject: [PATCH 184/215] Added `_PRIVATE` services, to allow for publishing/validation of encrypted data. New additions: QCHAT_ATTACHMENT_PRIVATE ATTACHMENT_PRIVATE FILE_PRIVATE IMAGE_PRIVATE VIDEO_PRIVATE AUDIO_PRIVATE VOICE_PRIVATE DOCUMENT_PRIVATE MAIL_PRIVATE MESSAGE_PRIVATE --- .../org/qortal/arbitrary/misc/Service.java | 123 +++++++++++------- .../arbitrary/ArbitraryDataManager.java | 1 - .../test/arbitrary/ArbitraryServiceTests.java | 67 ++++++++++ 3 files changed, 142 insertions(+), 49 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 27557045..e0caa2a5 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -9,7 +9,6 @@ import org.qortal.utils.FilesystemUtils; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; @@ -20,9 +19,9 @@ import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; public enum Service { - AUTO_UPDATE(1, false, null, false, null), - ARBITRARY_DATA(100, false, null, false, null), - QCHAT_ATTACHMENT(120, true, 1024*1024L, true, null) { + AUTO_UPDATE(1, false, null, false, false, null), + ARBITRARY_DATA(100, false, null, false, false, null), + QCHAT_ATTACHMENT(120, true, 1024*1024L, true, false, null) { @Override public ValidationResult validate(Path path) throws IOException { ValidationResult superclassResult = super.validate(path); @@ -47,11 +46,14 @@ public enum Service { return ValidationResult.OK; } }, - ATTACHMENT(130, false, null, true, null), - FILE(140, false, null, true, null), - FILES(150, false, null, false, null), - CHAIN_DATA(160, true, 239L, true, null), - WEBSITE(200, true, null, false, null) { + QCHAT_ATTACHMENT_PRIVATE(121, true, 1024*1024L, true, true, null), + ATTACHMENT(130, false, 50*1024*1024L, true, false, null), + ATTACHMENT_PRIVATE(131, true, 50*1024*1024L, true, true, null), + FILE(140, false, null, true, false, null), + FILE_PRIVATE(141, true, null, true, true, null), + FILES(150, false, null, false, false, null), + CHAIN_DATA(160, true, 239L, true, false, null), + WEBSITE(200, true, null, false, false, null) { @Override public ValidationResult validate(Path path) throws IOException { ValidationResult superclassResult = super.validate(path); @@ -73,25 +75,30 @@ public enum Service { return ValidationResult.MISSING_INDEX_FILE; } }, - GIT_REPOSITORY(300, false, null, false, null), - IMAGE(400, true, 10*1024*1024L, true, null), - THUMBNAIL(410, true, 500*1024L, true, null), - QCHAT_IMAGE(420, true, 500*1024L, true, null), - VIDEO(500, false, null, true, null), - AUDIO(600, false, null, true, null), - QCHAT_AUDIO(610, true, 10*1024*1024L, true, null), - QCHAT_VOICE(620, true, 10*1024*1024L, true, null), - VOICE(630, true, 10*1024*1024L, true, null), - PODCAST(640, false, null, true, null), - BLOG(700, false, null, false, null), - BLOG_POST(777, false, null, true, null), - BLOG_COMMENT(778, true, 500*1024L, true, null), - DOCUMENT(800, false, null, true, null), - LIST(900, true, null, true, null), - PLAYLIST(910, true, null, true, null), - APP(1000, true, 50*1024*1024L, false, null), - METADATA(1100, false, null, true, null), - JSON(1110, true, 25*1024L, true, null) { + GIT_REPOSITORY(300, false, null, false, false, null), + IMAGE(400, true, 10*1024*1024L, true, false, null), + IMAGE_PRIVATE(401, true, 10*1024*1024L, true, true, null), + THUMBNAIL(410, true, 500*1024L, true, false, null), + QCHAT_IMAGE(420, true, 500*1024L, true, false, null), + VIDEO(500, false, null, true, false, null), + VIDEO_PRIVATE(501, true, null, true, true, null), + AUDIO(600, false, null, true, false, null), + AUDIO_PRIVATE(601, true, null, true, true, null), + QCHAT_AUDIO(610, true, 10*1024*1024L, true, false, null), + QCHAT_VOICE(620, true, 10*1024*1024L, true, false, null), + VOICE(630, true, 10*1024*1024L, true, false, null), + VOICE_PRIVATE(631, true, 10*1024*1024L, true, true, null), + PODCAST(640, false, null, true, false, null), + BLOG(700, false, null, false, false, null), + BLOG_POST(777, false, null, true, false, null), + BLOG_COMMENT(778, true, 500*1024L, true, false, null), + DOCUMENT(800, false, null, true, false, null), + DOCUMENT_PRIVATE(801, true, null, true, true, null), + LIST(900, true, null, true, false, null), + PLAYLIST(910, true, null, true, false, null), + APP(1000, true, 50*1024*1024L, false, false, null), + METADATA(1100, false, null, true, false, null), + JSON(1110, true, 25*1024L, true, false, null) { @Override public ValidationResult validate(Path path) throws IOException { ValidationResult superclassResult = super.validate(path); @@ -110,7 +117,7 @@ public enum Service { } } }, - GIF_REPOSITORY(1200, true, 25*1024*1024L, false, null) { + GIF_REPOSITORY(1200, true, 25*1024*1024L, false, false, null) { @Override public ValidationResult validate(Path path) throws IOException { ValidationResult superclassResult = super.validate(path); @@ -146,27 +153,30 @@ public enum Service { return ValidationResult.OK; } }, - STORE(1300, false, null, true, null), - PRODUCT(1310, false, null, true, null), - OFFER(1330, false, null, true, null), - COUPON(1340, false, null, true, null), - CODE(1400, false, null, true, null), - PLUGIN(1410, false, null, true, null), - EXTENSION(1420, false, null, true, null), - GAME(1500, false, null, false, null), - ITEM(1510, false, null, true, null), - NFT(1600, false, null, true, null), - DATABASE(1700, false, null, false, null), - SNAPSHOT(1710, false, null, false, null), - COMMENT(1800, true, 500*1024L, true, null), - CHAIN_COMMENT(1810, true, 239L, true, null), - MAIL(1900, true, 1024*1024L, true, null), - MESSAGE(1910, true, 1024*1024L, true, null); + STORE(1300, false, null, true, false, null), + PRODUCT(1310, false, null, true, false, null), + OFFER(1330, false, null, true, false, null), + COUPON(1340, false, null, true, false, null), + CODE(1400, false, null, true, false, null), + PLUGIN(1410, false, null, true, false, null), + EXTENSION(1420, false, null, true, false, null), + GAME(1500, false, null, false, false, null), + ITEM(1510, false, null, true, false, null), + NFT(1600, false, null, true, false, null), + DATABASE(1700, false, null, false, false, null), + SNAPSHOT(1710, false, null, false, false, null), + COMMENT(1800, true, 500*1024L, true, false, null), + CHAIN_COMMENT(1810, true, 239L, true, false, null), + MAIL(1900, true, 1024*1024L, true, false, null), + MAIL_PRIVATE(1901, true, 1024*1024L, true, true, null), + MESSAGE(1910, true, 1024*1024L, true, false, null), + MESSAGE_PRIVATE(1911, true, 1024*1024L, true, true, null); public final int value; private final boolean requiresValidation; private final Long maxSize; private final boolean single; + private final boolean isPrivate; private final List requiredKeys; private static final Map map = stream(Service.values()) @@ -175,11 +185,14 @@ public enum Service { // For JSON validation private static final ObjectMapper objectMapper = new ObjectMapper(); - Service(int value, boolean requiresValidation, Long maxSize, boolean single, List requiredKeys) { + private static final String encryptedDataPrefix = "qortalEncryptedData"; + + Service(int value, boolean requiresValidation, Long maxSize, boolean single, boolean isPrivate, List requiredKeys) { this.value = value; this.requiresValidation = requiresValidation; this.maxSize = maxSize; this.single = single; + this.isPrivate = isPrivate; this.requiredKeys = requiredKeys; } @@ -203,6 +216,17 @@ public enum Service { return ValidationResult.INVALID_FILE_COUNT; } + // Validate private data for single file resources + if (this.single) { + String dataString = new String(data, StandardCharsets.UTF_8); + if (this.isPrivate && !dataString.startsWith(encryptedDataPrefix)) { + return ValidationResult.DATA_NOT_ENCRYPTED; + } + if (!this.isPrivate && dataString.startsWith(encryptedDataPrefix)) { + return ValidationResult.DATA_ENCRYPTED; + } + } + // Validate required keys if needed if (this.requiredKeys != null) { if (data == null) { @@ -221,7 +245,8 @@ public enum Service { } public boolean isValidationRequired() { - return this.requiresValidation; + // We must always validate single file resources, to ensure they are actually a single file + return this.requiresValidation || this.single; } public static Service valueOf(int value) { @@ -242,7 +267,9 @@ public enum Service { INVALID_FILE_EXTENSION(6), MISSING_DATA(7), INVALID_FILE_COUNT(8), - INVALID_CONTENT(9); + INVALID_CONTENT(9), + DATA_NOT_ENCRYPTED(10), + DATA_ENCRYPTED(10); public final int value; diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java index 567dcdd3..9284e672 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java @@ -16,7 +16,6 @@ import org.qortal.arbitrary.misc.Service; import org.qortal.controller.Controller; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.TransactionData; -import org.qortal.list.ResourceListManager; import org.qortal.network.Network; import org.qortal.network.Peer; import org.qortal.repository.DataException; diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index 940b33a9..45960a25 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -436,4 +436,71 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); } + @Test + public void testValidPrivateData() throws IOException { + String dataString = "qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1rfla5UBoEcoxbtjgZ9G7FLPb8V/Qfr0bfKWfvMmN06U/pgUdLuv2mGL2V0D3qYd1011MUzGdNG1qERjaCDz8GAi63+KnHHjfMtPgYt6bcqjs4CNV+ZZ4dIt3xxHYyVEBNc="; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidPrivateData"); + Path filePath = Paths.get(path.toString(), "test"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(dataString); + writer.close(); + + Service service = Service.FILE_PRIVATE; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + + @Test + public void testEncryptedData() throws IOException { + String dataString = "qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1rfla5UBoEcoxbtjgZ9G7FLPb8V/Qfr0bfKWfvMmN06U/pgUdLuv2mGL2V0D3qYd1011MUzGdNG1qERjaCDz8GAi63+KnHHjfMtPgYt6bcqjs4CNV+ZZ4dIt3xxHYyVEBNc="; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidPrivateData"); + Path filePath = Paths.get(path.toString(), "test"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(dataString); + writer.close(); + + // Validate a private service + Service service = Service.FILE_PRIVATE; + assertTrue(service.isValidationRequired()); + assertEquals(ValidationResult.OK, service.validate(filePath)); + + // Validate a regular service + service = Service.FILE; + assertTrue(service.isValidationRequired()); + assertEquals(ValidationResult.DATA_ENCRYPTED, service.validate(filePath)); + } + + @Test + public void testPlainTextData() throws IOException { + String dataString = "plaintext"; + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testInvalidPrivateData"); + Path filePath = Paths.get(path.toString(), "test"); + filePath.toFile().deleteOnExit(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile())); + writer.write(dataString); + writer.close(); + + // Validate a private service + Service service = Service.FILE_PRIVATE; + assertTrue(service.isValidationRequired()); + assertEquals(ValidationResult.DATA_NOT_ENCRYPTED, service.validate(filePath)); + + // Validate a regular service + service = Service.FILE; + assertTrue(service.isValidationRequired()); + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + } \ No newline at end of file From 3775135e0cfadc53dd24c891386da896d6127488 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 5 May 2023 12:39:11 +0100 Subject: [PATCH 185/215] Added helper methods to fetch lists of private or public service objects. These can ultimately be used to help inform the cleanup manager on the best order to delete files when the node runs out of space. Public data should be given priority over private data (unless the node is part of a data market contract for that data - this isn't developed yet). --- .../org/qortal/arbitrary/misc/Service.java | 35 +++++++++++++++++++ .../ArbitraryDataCleanupManager.java | 4 +++ .../test/arbitrary/ArbitraryServiceTests.java | 17 +++++++++ 3 files changed, 56 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index e0caa2a5..b53ab7ca 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -249,6 +249,10 @@ public enum Service { return this.requiresValidation || this.single; } + public boolean isPrivate() { + return this.isPrivate; + } + public static Service valueOf(int value) { return map.get(value); } @@ -258,6 +262,37 @@ public enum Service { return new JSONObject(dataString); } + public static List publicServices() { + List privateServices = new ArrayList<>(); + for (Service service : Service.values()) { + if (!service.isPrivate) { + privateServices.add(service); + } + } + return privateServices; + } + + /** + * Fetch a list of Service objects that require encrypted data. + * + * These can ultimately be used to help inform the cleanup manager + * on the best order to delete files when the node runs out of space. + * Public data should be given priority over private data (unless + * this node is part of a data market contract for that data - this + * isn't developed yet). + * + * @return a list of Service objects that require encrypted data. + */ + public static List privateServices() { + List privateServices = new ArrayList<>(); + for (Service service : Service.values()) { + if (service.isPrivate) { + privateServices.add(service); + } + } + return privateServices; + } + public enum ValidationResult { OK(1), MISSING_KEYS(2), diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java index 9d57ce8a..e0c62acb 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java @@ -346,6 +346,10 @@ public class ArbitraryDataCleanupManager extends Thread { /** * Iteratively walk through given directory and delete a single random file * + * TODO: public data should be prioritized over private data + * (unless this node is part of a data market contract for that data). + * See: Service.privateServices() for a list of services containing private data. + * * @param directory - the base directory * @return boolean - whether a file was deleted */ diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index 45960a25..33632b4a 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -29,6 +29,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.util.List; import java.util.Random; import static org.junit.Assert.*; @@ -503,4 +504,20 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.OK, service.validate(filePath)); } + @Test + public void testGetPrivateServices() { + List privateServices = Service.privateServices(); + for (Service service : privateServices) { + assertTrue(service.isPrivate()); + } + } + + @Test + public void testGetPublicServices() { + List publicServices = Service.publicServices(); + for (Service service : publicServices) { + assertFalse(service.isPrivate()); + } + } + } \ No newline at end of file From 86b5bae320f5f1d9fe1e731071698982bb320bf0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 5 May 2023 13:22:14 +0100 Subject: [PATCH 186/215] Set timeout of PUBLISH_MULTIPLE_QDN_RESOURCES to 60 mins. --- src/main/resources/q-apps/q-apps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index f6075e8e..ab82b6b8 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -440,8 +440,8 @@ function getDefaultTimeout(action) { return 60 * 1000; case "PUBLISH_QDN_RESOURCE": + case "PUBLISH_MULTIPLE_QDN_RESOURCES": // Publishing could take a very long time on slow system, due to the proof-of-work computation - // It's best not to timeout return 60 * 60 * 1000; case "SEND_CHAT_MESSAGE": From 3f71a63512f6876eea2486c9cf7a514419c9184c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 5 May 2023 18:30:14 +0100 Subject: [PATCH 187/215] Increased timeout for other new actions. --- src/main/resources/q-apps/q-apps.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index ab82b6b8..5233c7d5 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -432,6 +432,8 @@ function getDefaultTimeout(action) { // Some actions need longer default timeouts, especially those that create transactions switch (action) { case "GET_USER_ACCOUNT": + case "SAVE_FILE": + case "DECRYPT_DATA": // User may take a long time to accept/deny the popup return 60 * 60 * 1000; From 92b983a16e68f4db93e3ab82258e24166229462c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 5 May 2023 22:25:12 +0100 Subject: [PATCH 188/215] Q-Apps documentation updates. --- Q-Apps.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index 94f7414f..c7579f1d 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -42,6 +42,9 @@ A "default" resource refers to one without an identifier. For example, when a we Here is a list of currently available services that can be used in Q-Apps: +### Public services ### +The services below are intended to be used for publicly accessible data. + IMAGE, THUMBNAIL, VIDEO, @@ -83,6 +86,20 @@ QCHAT_IMAGE, QCHAT_AUDIO, QCHAT_VOICE +### Private services ### +For the services below, data is encrypted for a single recipient, and can only be decrypted using the private key of the recipient's wallet. + +QCHAT_ATTACHMENT_PRIVATE +ATTACHMENT_PRIVATE +FILE_PRIVATE +IMAGE_PRIVATE +VIDEO_PRIVATE +AUDIO_PRIVATE +VOICE_PRIVATE +DOCUMENT_PRIVATE +MAIL_PRIVATE +MESSAGE_PRIVATE + ## Single vs multi-file resources @@ -246,6 +263,8 @@ Here is a list of currently supported actions: - FETCH_QDN_RESOURCE - PUBLISH_QDN_RESOURCE - PUBLISH_MULTIPLE_QDN_RESOURCES +- DECRYPT_DATA +- SAVE_FILE - GET_WALLET_BALANCE - GET_BALANCE - SEND_COIN @@ -435,7 +454,7 @@ let res = await qortalRequest({ _Requires user approval_.
Note: this publishes a single, base64-encoded file. Multi-file resource publishing (such as a WEBSITE or GIF_REPOSITORY) is not yet supported via a Q-App. It will be added in a future update. ``` -await qortalRequest({ +let res = await qortalRequest({ action: "PUBLISH_QDN_RESOURCE", name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list service: "IMAGE", @@ -449,7 +468,9 @@ await qortalRequest({ // tag2: "strings", // Optional // tag3: "can", // Optional // tag4: "go", // Optional - // tag5: "here" // Optional + // tag5: "here", // Optional + // encrypt: true, // Optional - to be used with a private service + // recipientPublicKey: "publickeygoeshere" // Only required if `encrypt` is set to true }); ``` @@ -457,7 +478,7 @@ await qortalRequest({ _Requires user approval_.
Note: each resource being published consists of a single, base64-encoded file, each in its own transaction. Useful for publishing two or more related things, such as a video and a video thumbnail. ``` -await qortalRequest({ +let res = await qortalRequest({ action: "PUBLISH_MULTIPLE_QDN_RESOURCES", resources: [ name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list @@ -472,7 +493,9 @@ await qortalRequest({ // tag2: "strings", // Optional // tag3: "can", // Optional // tag4: "go", // Optional - // tag5: "here" // Optional + // tag5: "here", // Optional + // encrypt: true, // Optional - to be used with a private service + // recipientPublicKey: "publickeygoeshere" // Only required if `encrypt` is set to true ], [ ... more resources here if needed ... @@ -480,10 +503,32 @@ await qortalRequest({ }); ``` +### Decrypt encrypted/private data +``` +let res = await qortalRequest({ + action: "DECRYPT_DATA", + encryptedData: 'qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1r', + publicKey: 'publickeygoeshere' +}); +// Returns base64 encoded string of plaintext data +``` + +### Prompt user to save a file to disk +Note: mimeType not required but recommended. If not specified, saving will fail if the mimeType is unable to be derived from the Blob. +``` +let res = await qortalRequest({ + action: "SAVE_FILE", + blob: dataBlob, + filename: "myfile.pdf", + mimeType: "application/pdf" // Optional but recommended +}); +``` + + ### Get wallet balance (QORT) _Requires user approval_ ``` -await qortalRequest({ +let res = await qortalRequest({ action: "GET_WALLET_BALANCE", coin: "QORT" }); @@ -508,7 +553,7 @@ let res = await qortalRequest({ ### Send QORT to address _Requires user approval_ ``` -await qortalRequest({ +let res = await qortalRequest({ action: "SEND_COIN", coin: "QORT", destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", @@ -519,7 +564,7 @@ await qortalRequest({ ### Send foreign coin to address _Requires user approval_ ``` -await qortalRequest({ +let res = await qortalRequest({ action: "SEND_COIN", coin: "LTC", destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y", @@ -549,7 +594,7 @@ let res = await qortalRequest({ ### Send a group chat message _Requires user approval_ ``` -await qortalRequest({ +let res = await qortalRequest({ action: "SEND_CHAT_MESSAGE", groupId: 0, message: "Test" @@ -559,7 +604,7 @@ await qortalRequest({ ### Send a private chat message _Requires user approval_ ``` -await qortalRequest({ +let res = await qortalRequest({ action: "SEND_CHAT_MESSAGE", destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", message: "Test" @@ -579,7 +624,7 @@ let res = await qortalRequest({ ### Join a group _Requires user approval_ ``` -await qortalRequest({ +let res = await qortalRequest({ action: "JOIN_GROUP", groupId: 100 }); From b5719311275e8ba780ceb3eed301f612029f2a9b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 5 May 2023 22:35:19 +0100 Subject: [PATCH 189/215] Fixed formatting of services list --- Q-Apps.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index c7579f1d..bad9abe0 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -89,15 +89,15 @@ QCHAT_VOICE ### Private services ### For the services below, data is encrypted for a single recipient, and can only be decrypted using the private key of the recipient's wallet. -QCHAT_ATTACHMENT_PRIVATE -ATTACHMENT_PRIVATE -FILE_PRIVATE -IMAGE_PRIVATE -VIDEO_PRIVATE -AUDIO_PRIVATE -VOICE_PRIVATE -DOCUMENT_PRIVATE -MAIL_PRIVATE +QCHAT_ATTACHMENT_PRIVATE, +ATTACHMENT_PRIVATE, +FILE_PRIVATE, +IMAGE_PRIVATE, +VIDEO_PRIVATE, +AUDIO_PRIVATE, +VOICE_PRIVATE, +DOCUMENT_PRIVATE, +MAIL_PRIVATE, MESSAGE_PRIVATE From b693a514fd53a850b1cbbf7ed7edf2c632075e6f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 6 May 2023 12:13:41 +0100 Subject: [PATCH 190/215] Fixed warnings, and other improvements. --- .../api/resource/ArbitraryResource.java | 8 +- .../arbitrary/ArbitraryDataBuilder.java | 8 - .../qortal/arbitrary/ArbitraryDataReader.java | 10 +- .../arbitrary/ArbitraryDataResource.java | 3 + .../controller/OnlineAccountsManager.java | 186 +++++++++--------- 5 files changed, 110 insertions(+), 105 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 1101e71d..dee27413 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -1173,7 +1173,11 @@ public class ArbitraryResource { throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, error); } - final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L); + final Long now = NTP.getTime(); + if (now == null) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NO_TIME_SYNC); + } + final Long minLatestBlockTimestamp = now - (60 * 60 * 1000L); if (!Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC); } @@ -1231,7 +1235,7 @@ public class ArbitraryResource { // The actual data will be in a randomly-named subfolder of tempDirectory // Remove hidden folders, i.e. starting with "_", as some systems can add them, e.g. "__MACOSX" String[] files = tempDirectory.toFile().list((parent, child) -> !child.startsWith("_")); - if (files.length == 1) { // Single directory or file only + if (files != null && files.length == 1) { // Single directory or file only path = Paths.get(tempDirectory.toString(), files[0]).toString(); } } diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java index b6b17ea5..fba6a32b 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java @@ -54,10 +54,6 @@ public class ArbitraryDataBuilder { /** * Process transactions, but do not build anything * This is useful for checking the status of a given resource - * - * @throws DataException - * @throws IOException - * @throws MissingDataException */ public void process() throws DataException, IOException, MissingDataException { this.fetchTransactions(); @@ -69,10 +65,6 @@ public class ArbitraryDataBuilder { /** * Build the latest state of a given resource - * - * @throws DataException - * @throws IOException - * @throws MissingDataException */ public void build() throws DataException, IOException, MissingDataException { this.process(); diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index 779e4024..a7876236 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -9,7 +9,6 @@ import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataBuildManager; import org.qortal.controller.arbitrary.ArbitraryDataManager; -import org.qortal.controller.arbitrary.ArbitraryDataStorageManager; import org.qortal.crypto.AES; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.ArbitraryTransactionData.*; @@ -154,9 +153,6 @@ public class ArbitraryDataReader { * If no exception is thrown, you can then use getFilePath() to access the data immediately after returning * * @param overwrite - set to true to force rebuild an existing cache - * @throws IOException - * @throws DataException - * @throws MissingDataException */ public void loadSynchronously(boolean overwrite) throws DataException, IOException, MissingDataException { try { @@ -223,7 +219,6 @@ public class ArbitraryDataReader { /** * Working directory should only be deleted on failure, since it is currently used to * serve a cached version of the resource for subsequent requests. - * @throws IOException */ private void deleteWorkingDirectory() { try { @@ -303,7 +298,7 @@ public class ArbitraryDataReader { break; default: - throw new DataException(String.format("Unknown resource ID type specified: %s", resourceIdType.toString())); + throw new DataException(String.format("Unknown resource ID type specified: %s", resourceIdType)); } } @@ -368,6 +363,9 @@ public class ArbitraryDataReader { // Load data file(s) ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); ArbitraryTransactionUtils.checkAndRelocateMiscFiles(transactionData); + if (arbitraryDataFile == null) { + throw new DataException(String.format("arbitraryDataFile is null")); + } if (!arbitraryDataFile.allFilesExist()) { if (ListUtils.isNameBlocked(transactionData.getName())) { diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java index 79bb882b..a4650dfc 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java @@ -150,6 +150,9 @@ public class ArbitraryDataResource { for (ArbitraryTransactionData transactionData : transactionDataList) { ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); + if (arbitraryDataFile == null) { + continue; + } // Delete any chunks or complete files from each transaction arbitraryDataFile.deleteAll(deleteMetadata); diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index fd2c38df..224228b8 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -504,110 +504,118 @@ public class OnlineAccountsManager { computeOurAccountsForTimestamp(onlineAccountsTimestamp); } - private boolean computeOurAccountsForTimestamp(long onlineAccountsTimestamp) { - List mintingAccounts; - try (final Repository repository = RepositoryManager.getRepository()) { - mintingAccounts = repository.getAccountRepository().getMintingAccounts(); + private boolean computeOurAccountsForTimestamp(Long onlineAccountsTimestamp) { + if (onlineAccountsTimestamp != null) { + List mintingAccounts; + try (final Repository repository = RepositoryManager.getRepository()) { + mintingAccounts = repository.getAccountRepository().getMintingAccounts(); - // We have no accounts to send - if (mintingAccounts.isEmpty()) + // We have no accounts to send + if (mintingAccounts.isEmpty()) + return false; + + // Only active reward-shares allowed + Iterator iterator = mintingAccounts.iterator(); + int i = 0; + while (iterator.hasNext()) { + MintingAccountData mintingAccountData = iterator.next(); + + RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey()); + if (rewardShareData == null) { + // Reward-share doesn't even exist - probably not a good sign + iterator.remove(); + continue; + } + + Account mintingAccount = new Account(repository, rewardShareData.getMinter()); + if (!mintingAccount.canMint()) { + // Minting-account component of reward-share can no longer mint - disregard + iterator.remove(); + continue; + } + + if (++i > 1 + 1) { + iterator.remove(); + continue; + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to fetch minting accounts: %s", e.getMessage())); return false; + } - // Only active reward-shares allowed - Iterator iterator = mintingAccounts.iterator(); - while (iterator.hasNext()) { - MintingAccountData mintingAccountData = iterator.next(); + byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); + List ourOnlineAccounts = new ArrayList<>(); - RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey()); - if (rewardShareData == null) { - // Reward-share doesn't even exist - probably not a good sign - iterator.remove(); + int remaining = mintingAccounts.size(); + for (MintingAccountData mintingAccountData : mintingAccounts) { + remaining--; + byte[] privateKey = mintingAccountData.getPrivateKey(); + byte[] publicKey = Crypto.toPublicKey(privateKey); + + // We don't want to compute the online account nonce and signature again if it already exists + Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountsTimestamp, k -> ConcurrentHashMap.newKeySet()); + boolean alreadyExists = onlineAccounts.stream().anyMatch(a -> Arrays.equals(a.getPublicKey(), publicKey)); + if (alreadyExists) { + this.hasOurOnlineAccounts = true; + + if (remaining > 0) { + // Move on to next account + continue; + } else { + // Everything exists, so return true + return true; + } + } + + // Generate bytes for mempow + byte[] mempowBytes; + try { + mempowBytes = this.getMemoryPoWBytes(publicKey, onlineAccountsTimestamp); + } catch (IOException e) { + LOGGER.info("Unable to create bytes for MemoryPoW. Moving on to next account..."); continue; } - Account mintingAccount = new Account(repository, rewardShareData.getMinter()); - if (!mintingAccount.canMint()) { - // Minting-account component of reward-share can no longer mint - disregard - iterator.remove(); - continue; - } - } - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue trying to fetch minting accounts: %s", e.getMessage())); - return false; - } - - byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); - List ourOnlineAccounts = new ArrayList<>(); - - int remaining = mintingAccounts.size(); - for (MintingAccountData mintingAccountData : mintingAccounts) { - remaining--; - byte[] privateKey = mintingAccountData.getPrivateKey(); - byte[] publicKey = Crypto.toPublicKey(privateKey); - - // We don't want to compute the online account nonce and signature again if it already exists - Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountsTimestamp, k -> ConcurrentHashMap.newKeySet()); - boolean alreadyExists = onlineAccounts.stream().anyMatch(a -> Arrays.equals(a.getPublicKey(), publicKey)); - if (alreadyExists) { - this.hasOurOnlineAccounts = true; - - if (remaining > 0) { - // Move on to next account - continue; - } - else { - // Everything exists, so return true - return true; - } - } - - // Generate bytes for mempow - byte[] mempowBytes; - try { - mempowBytes = this.getMemoryPoWBytes(publicKey, onlineAccountsTimestamp); - } - catch (IOException e) { - LOGGER.info("Unable to create bytes for MemoryPoW. Moving on to next account..."); - continue; - } - - // Compute nonce - Integer nonce; - try { - nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp); - if (nonce == null) { - // A nonce is required + // Compute nonce + Integer nonce; + try { + nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp); + if (nonce == null) { + // A nonce is required + return false; + } + } catch (TimeoutException e) { + LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey))); return false; } - } catch (TimeoutException e) { - LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey))); + + byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes); + + // Our account is online + OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); + + // Make sure to verify before adding + if (verifyMemoryPoW(ourOnlineAccountData, null)) { + ourOnlineAccounts.add(ourOnlineAccountData); + } + } + + this.hasOurOnlineAccounts = !ourOnlineAccounts.isEmpty(); + + boolean hasInfoChanged = addAccounts(ourOnlineAccounts); + + if (!hasInfoChanged) return false; - } - byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes); + Network.getInstance().broadcast(peer -> new OnlineAccountsV3Message(ourOnlineAccounts)); - // Our account is online - OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); + LOGGER.debug("Broadcasted {} online account{} with timestamp {}", ourOnlineAccounts.size(), (ourOnlineAccounts.size() != 1 ? "s" : ""), onlineAccountsTimestamp); - // Make sure to verify before adding - if (verifyMemoryPoW(ourOnlineAccountData, null)) { - ourOnlineAccounts.add(ourOnlineAccountData); - } + return true; } - this.hasOurOnlineAccounts = !ourOnlineAccounts.isEmpty(); - - boolean hasInfoChanged = addAccounts(ourOnlineAccounts); - - if (!hasInfoChanged) - return false; - - Network.getInstance().broadcast(peer -> new OnlineAccountsV3Message(ourOnlineAccounts)); - - LOGGER.debug("Broadcasted {} online account{} with timestamp {}", ourOnlineAccounts.size(), (ourOnlineAccounts.size() != 1 ? "s" : ""), onlineAccountsTimestamp); - - return true; + return false; } From 1f77ee535f99bb98fb5ace444471099d7b4526a0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 6 May 2023 12:16:59 +0100 Subject: [PATCH 191/215] Added link to example Q-App projects. --- Q-Apps.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index bad9abe0..ea880874 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -816,6 +816,9 @@ let res = await qortalRequest({ # Section 4: Examples +Some example projects can be found [here](https://github.com/Qortal/Q-Apps). These can be cloned and modified, or used as a reference when creating a new app. + + ## Sample App Here is a sample application to display the logged-in user's avatar: From 0acf0729e9ead3f12c7344ea7f78e01f461f0eb1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 6 May 2023 15:10:46 +0100 Subject: [PATCH 192/215] Bump version to 4.0.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ff9c9db1..78df68a7 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.0.1 + 4.0.2 jar true From c941bc6024a8b44e39984ff08279bc85505d56c7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 7 May 2023 11:19:42 +0100 Subject: [PATCH 193/215] Catch and log all exceptions when publishing data. --- src/main/java/org/qortal/api/resource/ArbitraryResource.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index dee27413..89008eb2 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -1267,7 +1267,8 @@ public class ArbitraryResource { throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage()); } - } catch (DataException | IOException e) { + } catch (Exception e) { + LOGGER.info("Exception when publishing data: ", e); throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); } } From 9490c622421398b11e871181c43302890db55965 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 8 May 2023 12:07:02 +0100 Subject: [PATCH 194/215] Improved tx.pl that supports local signing via openssl and "deploy_at" transaction type + other minor fixes --- tools/tx.pl | 180 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 161 insertions(+), 19 deletions(-) diff --git a/tools/tx.pl b/tools/tx.pl index fe3cd872..1cb3dd5b 100755 --- a/tools/tx.pl +++ b/tools/tx.pl @@ -1,16 +1,23 @@ #!/usr/bin/env perl +# v4.0.2 + use JSON; use warnings; use strict; use Getopt::Std; use File::Basename; +use Digest::SHA qw( sha256 sha256_hex ); +use Crypt::RIPEMD160; our %opt; getopts('dpst', \%opt); my $proc = basename($0); +my $dirname = dirname($0); +my $OPENSSL_SIGN = "${dirname}/openssl-sign.sh"; +my $OPENSSL_PRIV_TO_PUB = index(`$ENV{SHELL} -i -c 'openssl version;exit' 2>/dev/null`, 'OpenSSL 3.') != -1; if (@ARGV < 1) { print STDERR "usage: $proc [-d] [-p] [-s] [-t] []\n"; @@ -24,7 +31,15 @@ if (@ARGV < 1) { exit 2; } -our $BASE_URL = $ENV{BASE_URL} || $opt{t} ? 'http://localhost:62391' : 'http://localhost:12391'; +our @b58 = qw{ + 1 2 3 4 5 6 7 8 9 + A B C D E F G H J K L M N P Q R S T U V W X Y Z + a b c d e f g h i j k m n o p q r s t u v w x y z +}; +our %b58 = map { $b58[$_] => $_ } 0 .. 57; +our %reverseb58 = reverse %b58; + +our $BASE_URL = $ENV{BASE_URL} || ($opt{t} ? 'http://localhost:62391' : 'http://localhost:12391'); our $DEFAULT_FEE = 0.001; our %TRANSACTION_TYPES = ( @@ -42,6 +57,7 @@ our %TRANSACTION_TYPES = ( create_group => { url => 'groups/create', required => [qw(groupName description isOpen approvalThreshold)], + defaults => { minimumBlockDelay => 10, maximumBlockDelay => 30 }, key_name => 'creatorPublicKey', }, update_group => { @@ -75,10 +91,10 @@ our %TRANSACTION_TYPES = ( key_name => 'ownerPublicKey', }, remove_group_admin => { - url => 'groups/removeadmin', - required => [qw(groupId txGroupId admin)], - key_name => 'ownerPublicKey', - }, + url => 'groups/removeadmin', + required => [qw(groupId txGroupId member)], + key_name => 'ownerPublicKey', + }, group_approval => { url => 'groups/approval', required => [qw(pendingSignature approval)], @@ -113,7 +129,7 @@ our %TRANSACTION_TYPES = ( }, update_name => { url => 'names/update', - required => [qw(newName newData)], + required => [qw(name newName newData)], key_name => 'ownerPublicKey', }, # reward-shares @@ -144,13 +160,21 @@ our %TRANSACTION_TYPES = ( key_name => 'senderPublicKey', pow_url => 'addresses/publicize/compute', }, - # Cross-chain trading - build_trade => { - url => 'crosschain/build', - required => [qw(initialQortAmount finalQortAmount fundingQortAmount secretHash bitcoinAmount)], - optional => [qw(tradeTimeout)], + # AT + deploy_at => { + url => 'at', + required => [qw(name description aTType tags creationBytes amount)], + optional => [qw(assetId)], key_name => 'creatorPublicKey', - defaults => { tradeTimeout => 10800 }, + defaults => { assetId => 0 }, + }, + # Cross-chain trading + create_trade => { + url => 'crosschain/tradebot/create', + required => [qw(qortAmount fundingQortAmount foreignAmount receivingAddress)], + optional => [qw(tradeTimeout foreignBlockchain)], + key_name => 'creatorPublicKey', + defaults => { tradeTimeout => 1440, foreignBlockchain => 'LITECOIN' }, }, trade_recipient => { url => 'crosschain/tradeoffer/recipient', @@ -196,7 +220,7 @@ if (@ARGV < @required + 1) { my $priv_key = shift @ARGV; -my $account = account($priv_key); +my $account; my $raw; if ($tx_type ne 'sign') { @@ -215,6 +239,8 @@ if ($tx_type ne 'sign') { %extras = (%extras, @ARGV); + $account = account($priv_key, %extras); + $raw = build_raw($tx_type, $account, %extras); printf "Raw: %s\n", $raw if $opt{d} || (!$opt{s} && !$opt{p}); @@ -229,7 +255,7 @@ if ($tx_type ne 'sign') { } if ($opt{s}) { - my $signed = sign($account->{private}, $raw); + my $signed = sign($priv_key, $raw); printf "Signed: %s\n", $signed if $opt{d} || $tx_type eq 'sign'; if ($opt{p}) { @@ -246,15 +272,25 @@ if ($opt{s}) { } sub account { - my ($creator) = @_; + my ($privkey, %extras) = @_; - my $account = { private => $creator }; - $account->{public} = api('utils/publickey', $creator); - $account->{address} = api('addresses/convert/{publickey}', '', '{publickey}', $account->{public}); + my $account = { private => $privkey }; + $account->{public} = $extras{publickey} || priv_to_pub($privkey); + $account->{address} = $extras{address} || pubkey_to_address($account->{public}); # api('addresses/convert/{publickey}', '', '{publickey}', $account->{public}); return $account; } +sub priv_to_pub { + my ($privkey) = @_; + + if ($OPENSSL_PRIV_TO_PUB) { + return openssl_priv_to_pub($privkey); + } else { + return api('utils/publickey', $privkey); + } +} + sub build_raw { my ($type, $account, %extras) = @_; @@ -306,6 +342,21 @@ sub build_raw { sub sign { my ($private, $raw) = @_; + if (-x "$OPENSSL_SIGN") { + my $private_hex = decode_base58($private); + chomp $private_hex; + + my $raw_hex = decode_base58($raw); + chomp $raw_hex; + + my $sig = `${OPENSSL_SIGN} ${private_hex} ${raw_hex}`; + chomp $sig; + + my $sig58 = encode_base58(${raw_hex} . ${sig}); + chomp $sig58; + return $sig58; + } + my $json = <<" __JSON__"; { "privateKey": "$private", @@ -344,7 +395,14 @@ sub api { my $curl = "curl --silent --output - --url '$BASE_URL/$url'"; if (defined $postdata && $postdata ne '') { $postdata =~ tr|\n| |s; - $curl .= " --header 'Content-Type: application/json' --data-binary '$postdata'"; + + if ($postdata =~ /^\s*\{/so) { + $curl .= " --header 'Content-Type: application/json'"; + } else { + $curl .= " --header 'Content-Type: text/plain'"; + } + + $curl .= " --data-binary '$postdata'"; $method = 'POST'; } my $response = `$curl 2>/dev/null`; @@ -356,3 +414,87 @@ sub api { return $response; } + +sub encode_base58 { + use integer; + my @in = map { hex($_) } ($_[0] =~ /(..)/g); + my $bzeros = length($1) if join('', @in) =~ /^(0*)/; + my @out; + my $size = 2 * scalar @in; + for my $c (@in) { + for (my $j = $size; $j--; ) { + $c += 256 * ($out[$j] // 0); + $out[$j] = $c % 58; + $c /= 58; + } + } + my $out = join('', map { $reverseb58{$_} } @out); + return $1 if $out =~ /(1{$bzeros}[^1].*)/; + return $1 if $out =~ /(1{$bzeros})/; + die "Invalid base58!\n"; +} + + +sub decode_base58 { + use integer; + my @out; + my $azeros = length($1) if $_[0] =~ /^(1*)/; + for my $c ( map { $b58{$_} } $_[0] =~ /./g ) { + die("Invalid character!\n") unless defined $c; + for (my $j = length($_[0]); $j--; ) { + $c += 58 * ($out[$j] // 0); + $out[$j] = $c % 256; + $c /= 256; + } + } + shift @out while @out && $out[0] == 0; + unshift(@out, (0) x $azeros); + return sprintf('%02x' x @out, @out); +} + +sub openssl_priv_to_pub { + my ($privkey) = @_; + + my $privkey_hex = decode_base58($privkey); + + my $key_type = "04"; # hex + my $length = "20"; # hex + + my $asn1 = <<"__ASN1__"; +asn1=SEQUENCE:private_key + +[private_key] +version=INTEGER:0 +included=SEQUENCE:key_info +raw=FORMAT:HEX,OCTETSTRING:${key_type}${length}${privkey_hex} + +[key_info] +type=OBJECT:ED25519 + +__ASN1__ + + my $output = `echo "${asn1}" | openssl asn1parse -i -genconf - -out - | openssl pkey -in - -inform der -noout -text_pub`; + + # remove colons + my $pubkey = ''; + $pubkey .= $1 while $output =~ m/([0-9a-f]{2})(?::|$)/g; + + return encode_base58($pubkey); +} + +sub pubkey_to_address { + my ($pubkey) = @_; + + my $pubkey_hex = decode_base58($pubkey); + my $pubkey_raw = pack('H*', $pubkey_hex); + + my $pkh_hex = Crypt::RIPEMD160->hexhash(sha256($pubkey_raw)); + $pkh_hex =~ tr/ //ds; + + my $version = '3a'; # hex + + my $raw = pack('H*', $version . $pkh_hex); + my $chksum = substr(sha256_hex(sha256($raw)), 0, 8); + + return encode_base58($version . $pkh_hex . $chksum); +} From 923e90ebedf0538f11f1a4cc38f5702f5cd21420 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 8 May 2023 12:12:40 +0100 Subject: [PATCH 195/215] Fixed occasional NPE --- src/main/java/org/qortal/api/resource/ArbitraryResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 89008eb2..c617b517 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -1316,7 +1316,7 @@ public class ArbitraryResource { 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) { + if (files != null && files.length == 1) { // This is a single file resource filepath = files[0]; } From 21d1750779f2c56bbfd1a615cde09ae976204009 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 8 May 2023 12:13:12 +0100 Subject: [PATCH 196/215] Added more debug logging when building resources. --- src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index a7876236..c1d07054 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -441,6 +441,7 @@ public class ArbitraryDataReader { Path unencryptedPath = Paths.get(this.workingPath.toString(), "zipped.zip"); SecretKey aesKey = new SecretKeySpec(secret, 0, secret.length, "AES"); AES.decryptFile(algorithm, aesKey, this.filePath.toString(), unencryptedPath.toString()); + LOGGER.debug("Finished decrypting {} using algorithm {}", this.arbitraryDataResource, algorithm); // Replace filePath pointer with the encrypted file path // Don't delete the original ArbitraryDataFile, as this is handled in the cleanup phase @@ -475,7 +476,9 @@ public class ArbitraryDataReader { // Handle each type of compression if (compression == Compression.ZIP) { + LOGGER.debug("Unzipping {}...", this.arbitraryDataResource); ZipUtils.unzip(this.filePath.toString(), this.uncompressedPath.getParent().toString()); + LOGGER.debug("Finished unzipping {}", this.arbitraryDataResource); } else if (compression == Compression.NONE) { Files.createDirectories(this.uncompressedPath); @@ -511,10 +514,12 @@ public class ArbitraryDataReader { private void validate() throws IOException, DataException { if (this.service.isValidationRequired()) { + LOGGER.debug("Validating {}...", this.arbitraryDataResource); Service.ValidationResult result = this.service.validate(this.filePath); if (result != Service.ValidationResult.OK) { throw new DataException(String.format("Validation of %s failed: %s", this.service, result.toString())); } + LOGGER.debug("Finished validating {}", this.arbitraryDataResource); } } From c682fa89fd537b672e5a375f24dcf88d29759722 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 8 May 2023 12:14:00 +0100 Subject: [PATCH 197/215] Avoid duplicate concurrent QDN builds. --- .../qortal/arbitrary/ArbitraryDataReader.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index c1d07054..b9e62e56 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -34,6 +34,9 @@ import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; public class ArbitraryDataReader { @@ -59,6 +62,10 @@ public class ArbitraryDataReader { // The resource being read ArbitraryDataResource arbitraryDataResource = null; + // Track resources that are currently being loaded, to avoid duplicate concurrent builds + // TODO: all builds could be handled by the build queue (even synchronous ones), to avoid the need for this + private static Map inProgress = Collections.synchronizedMap(new HashMap<>()); + public ArbitraryDataReader(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) { // Ensure names are always lowercase if (resourceIdType == ResourceIdType.NAME) { @@ -166,6 +173,12 @@ public class ArbitraryDataReader { this.arbitraryDataResource = this.createArbitraryDataResource(); + // Don't allow duplicate loads + if (!this.canStartLoading()) { + LOGGER.debug("Skipping duplicate load of {}", this.arbitraryDataResource); + return; + } + this.preExecute(); this.deleteExistingFiles(); this.fetch(); @@ -193,6 +206,7 @@ public class ArbitraryDataReader { private void preExecute() throws DataException { ArbitraryDataBuildManager.getInstance().setBuildInProgress(true); + this.checkEnabled(); this.createWorkingDirectory(); this.createUncompressedDirectory(); @@ -200,6 +214,9 @@ public class ArbitraryDataReader { private void postExecute() { ArbitraryDataBuildManager.getInstance().setBuildInProgress(false); + + this.arbitraryDataResource = this.createArbitraryDataResource(); + ArbitraryDataReader.inProgress.remove(this.arbitraryDataResource.getUniqueKey()); } private void checkEnabled() throws DataException { @@ -208,6 +225,17 @@ public class ArbitraryDataReader { } } + private boolean canStartLoading() { + // Avoid duplicate builds if we're already loading this resource + String uniqueKey = this.arbitraryDataResource.getUniqueKey(); + if (ArbitraryDataReader.inProgress.containsKey(uniqueKey)) { + return false; + } + ArbitraryDataReader.inProgress.put(uniqueKey, NTP.getTime()); + + return true; + } + private void createWorkingDirectory() throws DataException { try { Files.createDirectories(this.workingPath); From aba589c0e0e9b65432e2078923af54b12e4c1798 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 8 May 2023 12:15:53 +0100 Subject: [PATCH 198/215] Added optional "build" parameter to GET_QDN_RESOURCE_STATUS. This triggers an async build when checking the status. --- src/main/resources/q-apps/q-apps.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 5233c7d5..86493b48 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -236,13 +236,15 @@ window.addEventListener("message", (event) => { if (data.identifier != null) url = url.concat("/" + data.identifier); url = url.concat("?"); if (data.filepath != null) url = url.concat("&filepath=" + data.filepath); - if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString()) + if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString()); if (data.encoding != null) url = url.concat("&encoding=" + data.encoding); return httpGetAsyncWithEvent(event, url); case "GET_QDN_RESOURCE_STATUS": url = "/arbitrary/resource/status/" + data.service + "/" + data.name; if (data.identifier != null) url = url.concat("/" + data.identifier); + url = url.concat("?"); + if (data.build != null) url = url.concat("&build=" + new Boolean(data.build).toString()); return httpGetAsyncWithEvent(event, url); case "GET_QDN_RESOURCE_PROPERTIES": From 05b4ecd4edf3e559a0d372a8e2647fca1a446a96 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 8 May 2023 12:16:17 +0100 Subject: [PATCH 199/215] Updated documentation. --- Q-Apps.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Q-Apps.md b/Q-Apps.md index ea880874..177fee2d 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -425,7 +425,8 @@ let res = await qortalRequest({ action: "GET_QDN_RESOURCE_STATUS", name: "QortalDemo", service: "THUMBNAIL", - identifier: "qortal_avatar" // Optional + identifier: "qortal_avatar", // Optional + build: true // Optional - request that the resource is fetched & built in the background }); ``` From fc10b611933c27a109a9d5377ba0324de7a71454 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 8 May 2023 12:17:44 +0100 Subject: [PATCH 200/215] Fixed slow validation issue caused by loading the entire resource into memory. --- src/main/java/org/qortal/arbitrary/misc/Service.java | 6 ++++-- src/main/java/org/qortal/utils/FilesystemUtils.java | 12 ++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index b53ab7ca..94ca9252 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -107,7 +107,7 @@ public enum Service { } // Require valid JSON - byte[] data = FilesystemUtils.getSingleFileContents(path); + byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024); String json = new String(data, StandardCharsets.UTF_8); try { objectMapper.readTree(json); @@ -201,7 +201,9 @@ public enum Service { return ValidationResult.OK; } - byte[] data = FilesystemUtils.getSingleFileContents(path); + // Load the first 25KB of data. This only needs to be long enough to check the prefix + // and also to allow for possible additional future validation of smaller files. + byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024); long size = FilesystemUtils.getDirectorySize(path); // Validate max size if needed diff --git a/src/main/java/org/qortal/utils/FilesystemUtils.java b/src/main/java/org/qortal/utils/FilesystemUtils.java index 76651000..e9921561 100644 --- a/src/main/java/org/qortal/utils/FilesystemUtils.java +++ b/src/main/java/org/qortal/utils/FilesystemUtils.java @@ -228,12 +228,18 @@ public class FilesystemUtils { * @throws IOException */ public static byte[] getSingleFileContents(Path path) throws IOException { + return getSingleFileContents(path, null); + } + + public static byte[] getSingleFileContents(Path path, Integer maxLength) throws IOException { byte[] data = null; // TODO: limit the file size that can be loaded into memory // If the path is a file, read the contents directly if (path.toFile().isFile()) { - data = Files.readAllBytes(path); + int fileSize = (int)path.toFile().length(); + maxLength = maxLength != null ? Math.min(maxLength, fileSize) : fileSize; + data = FilesystemUtils.readFromFile(path.toString(), 0, maxLength); } // Or if it's a directory, only load file contents if there is a single file inside it @@ -242,7 +248,9 @@ public class FilesystemUtils { if (files.length == 1) { Path filePath = Paths.get(path.toString(), files[0]); if (filePath.toFile().isFile()) { - data = Files.readAllBytes(filePath); + int fileSize = (int)filePath.toFile().length(); + maxLength = maxLength != null ? Math.min(maxLength, fileSize) : fileSize; + data = FilesystemUtils.readFromFile(filePath.toString(), 0, maxLength); } } } From df3c68679f20ea3c959a04a3a990bae670ec3f5f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 8 May 2023 14:43:00 +0100 Subject: [PATCH 201/215] Log the action to the console, instead of the entire event. --- src/main/resources/q-apps/q-apps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index 86493b48..a505c1b0 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -169,7 +169,7 @@ window.addEventListener("message", (event) => { return; } - console.log("Core received event: " + JSON.stringify(event.data)); + console.log("Core received action: " + JSON.stringify(event.data.action)); let url; let data = event.data; From 49063e54ece7d94a60a3ff90ddbc28b15d34899b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 8 May 2023 19:18:38 +0100 Subject: [PATCH 202/215] Bump version to 4.0.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 78df68a7..0dfa0cf4 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.0.2 + 4.0.3 jar true From cda32a47f182aca1f2563fc5d10f7fbd01e66294 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Mon, 8 May 2023 20:23:54 -0400 Subject: [PATCH 203/215] Added API call to get votes --- .../qortal/api/resource/PollsResource.java | 30 +++++++++++++++++++ .../data/transaction/TransactionData.java | 3 +- .../qortal/data/voting/VoteOnPollData.java | 17 +++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/PollsResource.java b/src/main/java/org/qortal/api/resource/PollsResource.java index 952cbdc5..ab163342 100644 --- a/src/main/java/org/qortal/api/resource/PollsResource.java +++ b/src/main/java/org/qortal/api/resource/PollsResource.java @@ -37,6 +37,7 @@ import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import org.qortal.api.ApiException; import org.qortal.data.voting.PollData; +import org.qortal.data.voting.VoteOnPollData; @Path("/polls") @Tag(name = "Polls") @@ -102,6 +103,35 @@ public class PollsResource { } } + @GET + @Path("/votes/{pollName}") + @Operation( + summary = "Votes on poll", + responses = { + @ApiResponse( + description = "poll votes", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = VoteOnPollData.class) + ) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public List getVoteOnPollData(@PathParam("pollName") String pollName) { + try (final Repository repository = RepositoryManager.getRepository()) { + if (repository.getVotingRepository().fromPollName(pollName) == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.POLL_NO_EXISTS); + + List voteOnPollData = repository.getVotingRepository().getVotes(pollName); + return voteOnPollData; + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @POST @Path("/create") @Operation( diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index 838cffd3..4bf3152c 100644 --- a/src/main/java/org/qortal/data/transaction/TransactionData.java +++ b/src/main/java/org/qortal/data/transaction/TransactionData.java @@ -13,6 +13,7 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorNode; import org.qortal.crypto.Crypto; import org.qortal.data.voting.PollData; +import org.qortal.data.voting.VoteOnPollData; import org.qortal.transaction.Transaction.ApprovalStatus; import org.qortal.transaction.Transaction.TransactionType; @@ -30,7 +31,7 @@ import io.swagger.v3.oas.annotations.media.Schema.AccessMode; @XmlSeeAlso({GenesisTransactionData.class, PaymentTransactionData.class, RegisterNameTransactionData.class, UpdateNameTransactionData.class, SellNameTransactionData.class, CancelSellNameTransactionData.class, BuyNameTransactionData.class, CreatePollTransactionData.class, VoteOnPollTransactionData.class, ArbitraryTransactionData.class, - PollData.class, + PollData.class, VoteOnPollData.class, IssueAssetTransactionData.class, TransferAssetTransactionData.class, CreateAssetOrderTransactionData.class, CancelAssetOrderTransactionData.class, MultiPaymentTransactionData.class, DeployAtTransactionData.class, MessageTransactionData.class, ATTransactionData.class, diff --git a/src/main/java/org/qortal/data/voting/VoteOnPollData.java b/src/main/java/org/qortal/data/voting/VoteOnPollData.java index 47c06a54..531ed286 100644 --- a/src/main/java/org/qortal/data/voting/VoteOnPollData.java +++ b/src/main/java/org/qortal/data/voting/VoteOnPollData.java @@ -9,6 +9,11 @@ public class VoteOnPollData { // Constructors + // For JAXB + protected VoteOnPollData() { + super(); + } + public VoteOnPollData(String pollName, byte[] voterPublicKey, int optionIndex) { this.pollName = pollName; this.voterPublicKey = voterPublicKey; @@ -21,12 +26,24 @@ public class VoteOnPollData { return this.pollName; } + public void setPollName(String pollName) { + this.pollName = pollName; + } + public byte[] getVoterPublicKey() { return this.voterPublicKey; } + public void setVoterPublicKey(byte[] voterPublicKey) { + this.voterPublicKey = voterPublicKey; + } + public int getOptionIndex() { return this.optionIndex; } + public void setOptionIndex(int optionIndex) { + this.optionIndex = optionIndex; + } + } From 49c0d45bc6ec11766bf89844e0f4b320ffa0be23 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Mon, 8 May 2023 23:26:23 -0400 Subject: [PATCH 204/215] Added count to get votes API call --- .../java/org/qortal/api/model/PollVotes.java | 56 +++++++++++++++++++ .../qortal/api/resource/PollsResource.java | 37 ++++++++++-- 2 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/PollVotes.java diff --git a/src/main/java/org/qortal/api/model/PollVotes.java b/src/main/java/org/qortal/api/model/PollVotes.java new file mode 100644 index 00000000..c57ebc37 --- /dev/null +++ b/src/main/java/org/qortal/api/model/PollVotes.java @@ -0,0 +1,56 @@ +package org.qortal.api.model; + +import java.util.List; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.qortal.data.voting.VoteOnPollData; + +@Schema(description = "Poll vote info, including voters") +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) +public class PollVotes { + + @Schema(description = "List of individual votes") + @XmlElement(name = "votes") + public List votes; + + @Schema(description = "Total number of votes") + public Integer totalVotes; + + @Schema(description = "List of vote counts for each option") + public List voteCounts; + + // For JAX-RS + protected PollVotes() { + } + + public PollVotes(List votes, Integer totalVotes, List voteCounts) { + this.votes = votes; + this.totalVotes = totalVotes; + this.voteCounts = voteCounts; + } + + @Schema(description = "Vote info") + // All properties to be converted to JSON via JAX-RS + @XmlAccessorType(XmlAccessType.FIELD) + public static class OptionCount { + @Schema(description = "Option name") + public String optionName; + + @Schema(description = "Vote count") + public Integer voteCount; + + // For JAX-RS + protected OptionCount() { + } + + public OptionCount(String optionName, Integer voteCount) { + this.optionName = optionName; + this.voteCount = voteCount; + } + } +} diff --git a/src/main/java/org/qortal/api/resource/PollsResource.java b/src/main/java/org/qortal/api/resource/PollsResource.java index ab163342..999fa2fd 100644 --- a/src/main/java/org/qortal/api/resource/PollsResource.java +++ b/src/main/java/org/qortal/api/resource/PollsResource.java @@ -31,12 +31,17 @@ import javax.ws.rs.core.MediaType; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import javax.ws.rs.GET; import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import org.qortal.api.ApiException; +import org.qortal.api.model.PollVotes; import org.qortal.data.voting.PollData; +import org.qortal.data.voting.PollOptionData; import org.qortal.data.voting.VoteOnPollData; @Path("/polls") @@ -112,19 +117,41 @@ public class PollsResource { description = "poll votes", content = @Content( mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = VoteOnPollData.class) + schema = @Schema(implementation = PollVotes.class) ) ) } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) - public List getVoteOnPollData(@PathParam("pollName") String pollName) { + public PollVotes getPollVotes(@PathParam("pollName") String pollName) { try (final Repository repository = RepositoryManager.getRepository()) { - if (repository.getVotingRepository().fromPollName(pollName) == null) + PollData pollData = repository.getVotingRepository().fromPollName(pollName); + if (pollData == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.POLL_NO_EXISTS); - List voteOnPollData = repository.getVotingRepository().getVotes(pollName); - return voteOnPollData; + List votes = repository.getVotingRepository().getVotes(pollName); + + // Initialize map for counting votes + Map voteCountMap = new HashMap<>(); + for (PollOptionData optionData : pollData.getPollOptions()) { + voteCountMap.put(optionData.getOptionName(), 0); + } + + int totalVotes = 0; + for (VoteOnPollData vote : votes) { + String selectedOption = pollData.getPollOptions().get(vote.getOptionIndex()).getOptionName(); + if (voteCountMap.containsKey(selectedOption)) { + voteCountMap.put(selectedOption, voteCountMap.get(selectedOption) + 1); + totalVotes++; + } + } + + // Convert map to list of VoteInfo + List voteCounts = voteCountMap.entrySet().stream() + .map(entry -> new PollVotes.OptionCount(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + + return new PollVotes(votes, totalVotes, voteCounts); } catch (ApiException e) { throw e; } catch (DataException e) { From 3e45948646c3e9fa22108c898c7334c51605314c Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Mon, 8 May 2023 23:41:31 -0400 Subject: [PATCH 205/215] Added get votes option to return only counts --- src/main/java/org/qortal/api/resource/PollsResource.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/PollsResource.java b/src/main/java/org/qortal/api/resource/PollsResource.java index 999fa2fd..c64a8caf 100644 --- a/src/main/java/org/qortal/api/resource/PollsResource.java +++ b/src/main/java/org/qortal/api/resource/PollsResource.java @@ -123,7 +123,7 @@ public class PollsResource { } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) - public PollVotes getPollVotes(@PathParam("pollName") String pollName) { + public PollVotes getPollVotes(@PathParam("pollName") String pollName, @QueryParam("onlyCounts") Boolean onlyCounts) { try (final Repository repository = RepositoryManager.getRepository()) { PollData pollData = repository.getVotingRepository().fromPollName(pollName); if (pollData == null) @@ -151,7 +151,11 @@ public class PollsResource { .map(entry -> new PollVotes.OptionCount(entry.getKey(), entry.getValue())) .collect(Collectors.toList()); - return new PollVotes(votes, totalVotes, voteCounts); + if (onlyCounts != null && onlyCounts) { + return new PollVotes(null, totalVotes, voteCounts); + } else { + return new PollVotes(votes, totalVotes, voteCounts); + } } catch (ApiException e) { throw e; } catch (DataException e) { From e3be43a1e6456c3866aeaf82a3acf770a4b60ef2 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Thu, 11 May 2023 12:31:00 -0400 Subject: [PATCH 206/215] Changed get name API call to use reduced name --- src/main/java/org/qortal/api/resource/NamesResource.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/NamesResource.java b/src/main/java/org/qortal/api/resource/NamesResource.java index 03dffc08..30f04b70 100644 --- a/src/main/java/org/qortal/api/resource/NamesResource.java +++ b/src/main/java/org/qortal/api/resource/NamesResource.java @@ -47,6 +47,7 @@ import org.qortal.transform.transaction.RegisterNameTransactionTransformer; import org.qortal.transform.transaction.SellNameTransactionTransformer; import org.qortal.transform.transaction.UpdateNameTransactionTransformer; import org.qortal.utils.Base58; +import org.qortal.utils.Unicode; @Path("/names") @Tag(name = "Names") @@ -135,12 +136,13 @@ public class NamesResource { public NameData getName(@PathParam("name") String name) { try (final Repository repository = RepositoryManager.getRepository()) { NameData nameData; + String reducedName = Unicode.sanitize(name); if (Settings.getInstance().isLite()) { nameData = LiteNode.getInstance().fetchNameData(name); } else { - nameData = repository.getNameRepository().fromName(name); + nameData = repository.getNameRepository().fromReducedName(reducedName); } if (nameData == null) { @@ -442,4 +444,4 @@ public class NamesResource { } } -} \ No newline at end of file +} From 2cbc5aabd53fcc323ec54cc9e811db1aaef525cf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 12 May 2023 09:59:30 +0100 Subject: [PATCH 207/215] Added maxTradeOfferAttempts setting (default 3). Offers with more than 3 failures will be hidden from the API and websocket, to prevent unbuyable offers from staying in the order books and continuously failing. maxTradeOfferAttempts can be optionally increased on a node to show more trades that would otherwise be hidden. --- .../api/resource/CrossChainResource.java | 3 + .../api/websocket/TradeOffersWebSocket.java | 20 +++-- .../qortal/controller/tradebot/TradeBot.java | 78 +++++++++++++++++++ .../java/org/qortal/settings/Settings.java | 7 ++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index bb7c70a5..2a494db7 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -115,6 +115,9 @@ public class CrossChainResource { crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp)); } + // Remove any trades that have had too many failures + crossChainTrades = TradeBot.getInstance().removeFailedTrades(repository, crossChainTrades); + if (limit != null && limit > 0) { // Make sure to not return more than the limit int upperLimit = Math.min(limit, crossChainTrades.size()); diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index 78c53dc3..9c48b018 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -24,6 +24,7 @@ import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; import org.qortal.api.model.CrossChainOfferSummary; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; +import org.qortal.controller.tradebot.TradeBot; import org.qortal.crosschain.SupportedBlockchain; import org.qortal.crosschain.ACCT; import org.qortal.crosschain.AcctMode; @@ -315,7 +316,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { throw new DataException("Couldn't fetch historic trades from repository"); for (ATStateData historicAtState : historicAtStates) { - CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null); + CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null, null); if (!isHistoric.test(historicOfferSummary)) continue; @@ -330,8 +331,10 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { } } - private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, Long timestamp) throws DataException { - CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, CrossChainTradeData crossChainTradeData, Long timestamp) throws DataException { + if (crossChainTradeData == null) { + crossChainTradeData = acct.populateTradeData(repository, atState); + } long atStateTimestamp; @@ -346,9 +349,16 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { private static List produceSummaries(Repository repository, ACCT acct, List atStates, Long timestamp) throws DataException { List offerSummaries = new ArrayList<>(); + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); - for (ATStateData atState : atStates) - offerSummaries.add(produceSummary(repository, acct, atState, timestamp)); + // Ignore trade if it has failed + if (TradeBot.getInstance().isFailedTrade(repository, crossChainTradeData)) { + continue; + } + + offerSummaries.add(produceSummary(repository, acct, atState, crossChainTradeData, timestamp)); + } return offerSummaries; } diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 5880f561..96eeaf36 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -10,6 +10,7 @@ import org.apache.logging.log4j.Logger; import org.bitcoinj.core.ECKey; import org.qortal.account.PrivateKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.TransactionsResource; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult; @@ -19,6 +20,7 @@ import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.network.TradePresenceData; +import org.qortal.data.transaction.TransactionData; import org.qortal.event.Event; import org.qortal.event.EventBus; import org.qortal.event.Listener; @@ -33,6 +35,7 @@ import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.repository.hsqldb.HSQLDBImportExport; import org.qortal.settings.Settings; +import org.qortal.transaction.Transaction; import org.qortal.utils.ByteArray; import org.qortal.utils.NTP; @@ -113,6 +116,9 @@ public class TradeBot implements Listener { private Map safeAllTradePresencesByPubkey = Collections.emptyMap(); private long nextTradePresenceBroadcastTimestamp = 0L; + private Map failedTrades = new HashMap<>(); + private Map validTrades = new HashMap<>(); + private TradeBot() { EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event)); } @@ -674,6 +680,78 @@ public class TradeBot implements Listener { }); } + /** Removes any trades that have had multiple failures */ + public List removeFailedTrades(Repository repository, List crossChainTrades) { + Long now = NTP.getTime(); + if (now == null) { + return crossChainTrades; + } + + List updatedCrossChainTrades = new ArrayList<>(crossChainTrades); + int getMaxTradeOfferAttempts = Settings.getInstance().getMaxTradeOfferAttempts(); + + for (CrossChainTradeData crossChainTradeData : crossChainTrades) { + // We only care about trades in the OFFERING state + if (crossChainTradeData.mode != AcctMode.OFFERING) { + failedTrades.remove(crossChainTradeData.qortalAtAddress); + validTrades.remove(crossChainTradeData.qortalAtAddress); + continue; + } + + // Return recently cached values if they exist + Long failedTimestamp = failedTrades.get(crossChainTradeData.qortalAtAddress); + if (failedTimestamp != null && now - failedTimestamp < 60 * 60 * 1000L) { + updatedCrossChainTrades.remove(crossChainTradeData); + //LOGGER.info("Removing cached failed trade AT {}", crossChainTradeData.qortalAtAddress); + continue; + } + Long validTimestamp = validTrades.get(crossChainTradeData.qortalAtAddress); + if (validTimestamp != null && now - validTimestamp < 60 * 60 * 1000L) { + //LOGGER.info("NOT removing cached valid trade AT {}", crossChainTradeData.qortalAtAddress); + continue; + } + + try { + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, crossChainTradeData.qortalCreatorTradeAddress, TransactionsResource.ConfirmationStatus.CONFIRMED, null, null, null); + if (signatures.size() < getMaxTradeOfferAttempts) { + // Less than 3 (or user-specified number of) MESSAGE transactions relate to this trade, so assume it is ok + validTrades.put(crossChainTradeData.qortalAtAddress, now); + continue; + } + + List transactions = new ArrayList<>(signatures.size()); + for (byte[] signature : signatures) { + transactions.add(repository.getTransactionRepository().fromSignature(signature)); + } + transactions.sort(Transaction.getDataComparator()); + + // Get timestamp of the first MESSAGE transaction + long firstMessageTimestamp = transactions.get(0).getTimestamp(); + + // Treat as failed if first buy attempt was more than 60 mins ago (as it's still in the OFFERING state) + boolean isFailed = (now - firstMessageTimestamp > 60*60*1000L); + if (isFailed) { + failedTrades.put(crossChainTradeData.qortalAtAddress, now); + updatedCrossChainTrades.remove(crossChainTradeData); + } + else { + validTrades.put(crossChainTradeData.qortalAtAddress, now); + } + + } catch (DataException e) { + LOGGER.info("Unable to determine failed state of AT {}", crossChainTradeData.qortalAtAddress); + continue; + } + } + + return updatedCrossChainTrades; + } + + public boolean isFailedTrade(Repository repository, CrossChainTradeData crossChainTradeData) { + List results = removeFailedTrades(repository, Arrays.asList(crossChainTradeData)); + return results.isEmpty(); + } + private long generateExpiry(long timestamp) { return ((timestamp - 1) / EXPIRY_ROUNDING) * EXPIRY_ROUNDING + PRESENCE_LIFETIME; } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 6b703bea..a87a72f4 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -253,6 +253,9 @@ public class Settings { /** Whether to show SysTray pop-up notifications when trade-bot entries change state */ private boolean tradebotSystrayEnabled = false; + /** Maximum buy attempts for each trade offer before it is considered failed, and hidden from the list */ + private int maxTradeOfferAttempts = 3; + /** Wallets path - used for storing encrypted wallet caches for coins that require them */ private String walletsPath = "wallets"; @@ -771,6 +774,10 @@ public class Settings { return this.pirateChainNet; } + public int getMaxTradeOfferAttempts() { + return this.maxTradeOfferAttempts; + } + public String getWalletsPath() { return this.walletsPath; } From ba4866a2e65c08f0dcb7aa0c206a77ce4abd019a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 12 May 2023 10:01:38 +0100 Subject: [PATCH 208/215] Added `GET /crosschain/tradeoffers/hidden` endpoint, to show offers that are currently being hidden. This uses the maxTradeOfferAttempts setting, so modifying this setting will affect the number of offers that are returned. --- .../api/resource/CrossChainResource.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 2a494db7..44ef62ad 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -132,6 +132,64 @@ public class CrossChainResource { } } + @GET + @Path("/tradeoffers/hidden") + @Operation( + summary = "Find cross-chain trade offers that have been hidden due to too many failures", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = CrossChainTradeData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public List getHiddenTradeOffers( + @Parameter( + description = "Limit to specific blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain) { + + final boolean isExecutable = true; + List crossChainTrades = new ArrayList<>(); + + try (final Repository repository = RepositoryManager.getRepository()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, null, null, null); + + for (ATData atData : atsData) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData.mode == AcctMode.OFFERING) { + crossChainTrades.add(crossChainTradeData); + } + } + } + + // Sort the trades by timestamp + crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp)); + + // Remove trades that haven't failed + crossChainTrades.removeIf(t -> !TradeBot.getInstance().isFailedTrade(repository, t)); + + crossChainTrades.stream().forEach(CrossChainResource::decorateTradeDataWithPresence); + + return crossChainTrades; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @GET @Path("/trade/{ataddress}") @Operation( From dc1289787db0f7398f9eb0f44225c8da946dac7c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 12 May 2023 10:12:38 +0100 Subject: [PATCH 209/215] Ignore per-name limits when using storagePolicy ALL. --- .../controller/arbitrary/ArbitraryDataStorageManager.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java index 8b7d1a69..d3aadc43 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java @@ -488,6 +488,11 @@ public class ArbitraryDataStorageManager extends Thread { return false; } + if (Settings.getInstance().getStoragePolicy() == StoragePolicy.ALL) { + // Using storage policy ALL, so don't limit anything per name + return true; + } + if (name == null) { // This transaction doesn't have a name, so fall back to total space limitations return true; From 5a873f946509d0524d30ce01a54abecb981c4676 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 12 May 2023 11:11:34 +0100 Subject: [PATCH 210/215] Added `prefix` parameter to `GET /names/search`. --- src/main/java/org/qortal/api/resource/NamesResource.java | 5 ++++- src/main/java/org/qortal/repository/NameRepository.java | 2 +- .../org/qortal/repository/hsqldb/HSQLDBNameRepository.java | 7 +++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/NamesResource.java b/src/main/java/org/qortal/api/resource/NamesResource.java index 30f04b70..7627c413 100644 --- a/src/main/java/org/qortal/api/resource/NamesResource.java +++ b/src/main/java/org/qortal/api/resource/NamesResource.java @@ -173,6 +173,7 @@ public class NamesResource { ) @ApiErrors({ApiError.NAME_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public List searchNames(@QueryParam("query") String query, + @Parameter(description = "Prefix only (if true, only the beginning of the name is matched)") @QueryParam("prefix") Boolean prefixOnly, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) { @@ -181,7 +182,9 @@ public class NamesResource { throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Missing query"); } - return repository.getNameRepository().searchNames(query, limit, offset, reverse); + boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly); + + return repository.getNameRepository().searchNames(query, usePrefixOnly, limit, offset, reverse); } catch (ApiException e) { throw e; } catch (DataException e) { diff --git a/src/main/java/org/qortal/repository/NameRepository.java b/src/main/java/org/qortal/repository/NameRepository.java index a8b2a3db..32097ca4 100644 --- a/src/main/java/org/qortal/repository/NameRepository.java +++ b/src/main/java/org/qortal/repository/NameRepository.java @@ -14,7 +14,7 @@ public interface NameRepository { public boolean reducedNameExists(String reducedName) throws DataException; - public List searchNames(String query, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List searchNames(String query, boolean prefixOnly, Integer limit, Integer offset, Boolean reverse) throws DataException; public List getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java index 3e4a8e11..40f123d1 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java @@ -103,7 +103,7 @@ public class HSQLDBNameRepository implements NameRepository { } } - public List searchNames(String query, Integer limit, Integer offset, Boolean reverse) throws DataException { + public List searchNames(String query, boolean prefixOnly, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); List bindParams = new ArrayList<>(); @@ -111,7 +111,10 @@ public class HSQLDBNameRepository implements NameRepository { + "is_for_sale, sale_price, reference, creation_group_id FROM Names " + "WHERE LCASE(name) LIKE ? ORDER BY name"); - bindParams.add(String.format("%%%s%%", query.toLowerCase())); + // Search anywhere in the name, unless "prefixOnly" has been requested + // Note that without prefixOnly it will bypass any indexes + String queryWildcard = prefixOnly ? String.format("%s%%", query.toLowerCase()) : String.format("%%%s%%", query.toLowerCase()); + bindParams.add(queryWildcard); if (reverse != null && reverse) sql.append(" DESC"); From 29480e56649c87899cd0070ac67ea4c0b4dcb71e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 12 May 2023 11:17:09 +0100 Subject: [PATCH 211/215] Added SEARCH_NAMES Q-App action. --- Q-Apps.md | 13 +++++++++++++ src/main/resources/q-apps/q-apps.js | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/Q-Apps.md b/Q-Apps.md index 177fee2d..ca750e7d 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -252,6 +252,7 @@ Here is a list of currently supported actions: - GET_USER_ACCOUNT - GET_ACCOUNT_DATA - GET_ACCOUNT_NAMES +- SEARCH_NAMES - GET_NAME_DATA - LIST_QDN_RESOURCES - SEARCH_QDN_RESOURCES @@ -324,6 +325,18 @@ let res = await qortalRequest({ }); ``` +### Search names +``` +let res = await qortalRequest({ + action: "SEARCH_NAMES", + query: "search query goes here", + prefix: false, // Optional - if true, only the beginning of the name is matched + limit: 100, + offset: 0, + reverse: false +}); +``` + ### Get name data ``` let res = await qortalRequest({ diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index a505c1b0..dae20e5d 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -181,6 +181,15 @@ window.addEventListener("message", (event) => { case "GET_ACCOUNT_NAMES": return httpGetAsyncWithEvent(event, "/names/address/" + data.address); + case "SEARCH_NAMES": + url = "/names/search?"; + if (data.query != null) url = url.concat("&query=" + data.query); + if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString()); + if (data.limit != null) url = url.concat("&limit=" + data.limit); + if (data.offset != null) url = url.concat("&offset=" + data.offset); + if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); + return httpGetAsyncWithEvent(event, url); + case "GET_NAME_DATA": return httpGetAsyncWithEvent(event, "/names/" + data.name); From f8233bd05b9d0d040a67d6b4b844d6d20779701d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 12 May 2023 11:41:00 +0100 Subject: [PATCH 212/215] Added optional `after` parameter to `GET /names`. --- .../org/qortal/api/resource/NamesResource.java | 8 +++++--- .../org/qortal/repository/NameRepository.java | 4 ++-- .../repository/hsqldb/HSQLDBNameRepository.java | 15 ++++++++++++--- .../java/org/qortal/test/api/NamesApiTests.java | 4 ++-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/NamesResource.java b/src/main/java/org/qortal/api/resource/NamesResource.java index 7627c413..4173b85b 100644 --- a/src/main/java/org/qortal/api/resource/NamesResource.java +++ b/src/main/java/org/qortal/api/resource/NamesResource.java @@ -70,10 +70,12 @@ public class NamesResource { } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) - public List getAllNames(@Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, - @Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) { + public List getAllNames(@Parameter(ref = "after") @QueryParam("after") Long after, + @Parameter(ref = "limit") @QueryParam("limit") Integer limit, + @Parameter(ref = "offset") @QueryParam("offset") Integer offset, + @Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) { try (final Repository repository = RepositoryManager.getRepository()) { - List names = repository.getNameRepository().getAllNames(limit, offset, reverse); + List names = repository.getNameRepository().getAllNames(after, limit, offset, reverse); // Convert to summary return names.stream().map(NameSummary::new).collect(Collectors.toList()); diff --git a/src/main/java/org/qortal/repository/NameRepository.java b/src/main/java/org/qortal/repository/NameRepository.java index 32097ca4..52a43a18 100644 --- a/src/main/java/org/qortal/repository/NameRepository.java +++ b/src/main/java/org/qortal/repository/NameRepository.java @@ -16,10 +16,10 @@ public interface NameRepository { public List searchNames(String query, boolean prefixOnly, Integer limit, Integer offset, Boolean reverse) throws DataException; - public List getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException; + public List getAllNames(Long after, Integer limit, Integer offset, Boolean reverse) throws DataException; public default List getAllNames() throws DataException { - return getAllNames(null, null, null); + return getAllNames(null, null, null, null); } public List getNamesForSale(Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java index 40f123d1..2fefcf8b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBNameRepository.java @@ -158,11 +158,20 @@ public class HSQLDBNameRepository implements NameRepository { } @Override - public List getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException { + public List getAllNames(Long after, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(256); + List bindParams = new ArrayList<>(); sql.append("SELECT name, reduced_name, owner, data, registered_when, updated_when, " - + "is_for_sale, sale_price, reference, creation_group_id FROM Names ORDER BY name"); + + "is_for_sale, sale_price, reference, creation_group_id FROM Names"); + + if (after != null) { + sql.append(" WHERE registered_when > ? OR updated_when > ?"); + bindParams.add(after); + bindParams.add(after); + } + + sql.append(" ORDER BY name"); if (reverse != null && reverse) sql.append(" DESC"); @@ -171,7 +180,7 @@ public class HSQLDBNameRepository implements NameRepository { List names = new ArrayList<>(); - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) { + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { if (resultSet == null) return names; diff --git a/src/test/java/org/qortal/test/api/NamesApiTests.java b/src/test/java/org/qortal/test/api/NamesApiTests.java index 0e03b6a6..effdfea4 100644 --- a/src/test/java/org/qortal/test/api/NamesApiTests.java +++ b/src/test/java/org/qortal/test/api/NamesApiTests.java @@ -37,8 +37,8 @@ public class NamesApiTests extends ApiCommon { @Test public void testGetAllNames() { - assertNotNull(this.namesResource.getAllNames(null, null, null)); - assertNotNull(this.namesResource.getAllNames(1, 1, true)); + assertNotNull(this.namesResource.getAllNames(null, null, null, null)); + assertNotNull(this.namesResource.getAllNames(1L, 1, 1, true)); } @Test From 8a1bf8b5ecbb35f8026862d496d172a11ec7212f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 12 May 2023 11:41:15 +0100 Subject: [PATCH 213/215] Return full name data in `GET /names`. --- src/main/java/org/qortal/api/resource/NamesResource.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/NamesResource.java b/src/main/java/org/qortal/api/resource/NamesResource.java index 4173b85b..6cde26b3 100644 --- a/src/main/java/org/qortal/api/resource/NamesResource.java +++ b/src/main/java/org/qortal/api/resource/NamesResource.java @@ -64,21 +64,19 @@ public class NamesResource { description = "registered name info", content = @Content( mediaType = MediaType.APPLICATION_JSON, - array = @ArraySchema(schema = @Schema(implementation = NameSummary.class)) + array = @ArraySchema(schema = @Schema(implementation = NameData.class)) ) ) } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) - public List getAllNames(@Parameter(ref = "after") @QueryParam("after") Long after, + public List getAllNames(@Parameter(description = "Return only names registered or updated after timestamp") @QueryParam("after") Long after, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) { try (final Repository repository = RepositoryManager.getRepository()) { - List names = repository.getNameRepository().getAllNames(after, limit, offset, reverse); - // Convert to summary - return names.stream().map(NameSummary::new).collect(Collectors.toList()); + return repository.getNameRepository().getAllNames(after, limit, offset, reverse); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } From 92119b5558cefffbe8d77214b15ea3ca093fcafb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 12 May 2023 20:14:14 +0100 Subject: [PATCH 214/215] Increased per-name limit for followed names by 4x. --- .../arbitrary/ArbitraryDataStorageManager.java | 6 +++++- .../arbitrary/ArbitraryDataStorageCapacityTests.java | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java index d3aadc43..f6b2dc0a 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java @@ -57,6 +57,8 @@ public class ArbitraryDataStorageManager extends Thread { * This must be higher than STORAGE_FULL_THRESHOLD in order to avoid a fetch/delete loop. */ public static final double DELETION_THRESHOLD = 0.98f; // 98% + private static final long PER_NAME_STORAGE_MULTIPLIER = 4L; + public ArbitraryDataStorageManager() { } @@ -535,7 +537,9 @@ public class ArbitraryDataStorageManager extends Thread { } double maxStorageCapacity = (double)this.storageCapacity * threshold; - long maxStoragePerName = (long)(maxStorageCapacity / (double)followedNamesCount); + + // Some names won't need/use much space, so give all names a 4x multiplier to compensate + long maxStoragePerName = (long)(maxStorageCapacity / (double)followedNamesCount) * PER_NAME_STORAGE_MULTIPLIER; return maxStoragePerName; } diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java index 028c054d..c05ceabf 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java @@ -113,13 +113,16 @@ public class ArbitraryDataStorageCapacityTests extends Common { assertTrue(resourceListManager.addToList("followedNames", "Test2", false)); assertTrue(resourceListManager.addToList("followedNames", "Test3", false)); assertTrue(resourceListManager.addToList("followedNames", "Test4", false)); + assertTrue(resourceListManager.addToList("followedNames", "Test5", false)); + assertTrue(resourceListManager.addToList("followedNames", "Test6", false)); // Ensure the followed name count is correct - assertEquals(4, resourceListManager.getItemCountForList("followedNames")); - assertEquals(4, ListUtils.followedNamesCount()); + assertEquals(6, resourceListManager.getItemCountForList("followedNames")); + assertEquals(6, ListUtils.followedNamesCount()); // Storage space per name should be the total storage capacity divided by the number of names - long expectedStorageCapacityPerName = (long)(totalStorageCapacity / 4.0f); + // then multiplied by 4, to allow for names that don't use much space + long expectedStorageCapacityPerName = (long)(totalStorageCapacity / 6.0f) * 4L; assertEquals(expectedStorageCapacityPerName, storageManager.storageCapacityPerName(storageFullThreshold)); } From 4cb755a2f1052e6eb1117375cdf7780f87b0e229 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 13 May 2023 13:48:27 +0100 Subject: [PATCH 215/215] Added `GET /stats/supply/circulating` API endpoint, to fetch total QORT minted so far. --- .../qortal/api/resource/StatsResource.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/main/java/org/qortal/api/resource/StatsResource.java diff --git a/src/main/java/org/qortal/api/resource/StatsResource.java b/src/main/java/org/qortal/api/resource/StatsResource.java new file mode 100644 index 00000000..c1588490 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/StatsResource.java @@ -0,0 +1,70 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +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 org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.api.*; +import org.qortal.block.BlockChain; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.Amounts; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.*; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.util.List; + +@Path("/stats") +@Tag(name = "Stats") +public class StatsResource { + + private static final Logger LOGGER = LogManager.getLogger(StatsResource.class); + + + @Context + HttpServletRequest request; + + @GET + @Path("/supply/circulating") + @Operation( + summary = "Fetch circulating QORT supply", + responses = { + @ApiResponse( + description = "circulating supply of QORT", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", format = "number")) + ) + } + ) + public BigDecimal circulatingSupply() { + long total = 0L; + + try (final Repository repository = RepositoryManager.getRepository()) { + int currentHeight = repository.getBlockRepository().getBlockchainHeight(); + + List rewardsByHeight = BlockChain.getInstance().getBlockRewardsByHeight(); + int rewardIndex = rewardsByHeight.size() - 1; + BlockChain.RewardByHeight rewardInfo = rewardsByHeight.get(rewardIndex); + + for (int height = currentHeight; height > 1; --height) { + if (height < rewardInfo.height) { + --rewardIndex; + rewardInfo = rewardsByHeight.get(rewardIndex); + } + + total += rewardInfo.reward; + } + + return Amounts.toBigDecimal(total); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + +}