From 237b39a524d121701479fed9f60e55ef8269a23f Mon Sep 17 00:00:00 2001 From: lexandr0s Date: Sat, 4 Jun 2022 23:50:03 +0400 Subject: [PATCH 001/496] Update SysTray_ru.properties --- src/main/resources/i18n/SysTray_ru.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/i18n/SysTray_ru.properties b/src/main/resources/i18n/SysTray_ru.properties index fc3d8648..4f575c90 100644 --- a/src/main/resources/i18n/SysTray_ru.properties +++ b/src/main/resources/i18n/SysTray_ru.properties @@ -1,7 +1,7 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -APPLYING_UPDATE_AND_RESTARTING = Применение автоматического обновления и перезапуска... +APPLYING_UPDATE_AND_RESTARTING = Применение автоматического обновления и перезапуск... AUTO_UPDATE = Автоматическое обновление From c03344caaea5ffcc8e735eed4d855c04c8d37a98 Mon Sep 17 00:00:00 2001 From: lexandr0s Date: Sat, 4 Jun 2022 23:57:25 +0400 Subject: [PATCH 002/496] Update ApiError_ru.properties --- src/main/resources/i18n/ApiError_ru.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/i18n/ApiError_ru.properties b/src/main/resources/i18n/ApiError_ru.properties index 52580ac8..1367f29b 100644 --- a/src/main/resources/i18n/ApiError_ru.properties +++ b/src/main/resources/i18n/ApiError_ru.properties @@ -16,7 +16,7 @@ NON_PRODUCTION = этот вызов API не разрешен для произ BLOCKCHAIN_NEEDS_SYNC = блокчейн должен сначала синхронизироваться -NO_TIME_SYNC = пока нет синхронизации часов +NO_TIME_SYNC = время не синхронизировано ### Validation ### INVALID_SIGNATURE = недействительная подпись @@ -72,7 +72,7 @@ FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = проблема с внешним блокч FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = недостаточный баланс на внешнем блокчейне -FOREIGN_BLOCKCHAIN_TOO_SOON = слишком рано для трансляции транзакции во внений блокчей (время блокировки/среднее время блока) +FOREIGN_BLOCKCHAIN_TOO_SOON = слишком рано для трансляции транзакции во внешний блокчей (время блокировки/среднее время блока) ### Trade Portal ### ORDER_SIZE_TOO_SMALL = слишком маленькая сумма ордера @@ -80,4 +80,4 @@ ORDER_SIZE_TOO_SMALL = слишком маленькая сумма ордера ### Data ### FILE_NOT_FOUND = файл не найден -NO_REPLY = узел не ответил данными +NO_REPLY = нет ответа From ff40b8f8ab8801c44b6045c066ededf056e119e0 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Thu, 23 Jun 2022 01:43:33 -0400 Subject: [PATCH 003/496] Updated German translations --- src/main/resources/i18n/SysTray_de.properties | 16 +- .../i18n/TransactionValidity_de.properties | 195 ++++++++++++++++++ 2 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/i18n/TransactionValidity_de.properties diff --git a/src/main/resources/i18n/SysTray_de.properties b/src/main/resources/i18n/SysTray_de.properties index b949ca8c..d4abf2a3 100644 --- a/src/main/resources/i18n/SysTray_de.properties +++ b/src/main/resources/i18n/SysTray_de.properties @@ -5,9 +5,9 @@ APPLYING_UPDATE_AND_RESTARTING = Automatisches Update anwenden und neu starten AUTO_UPDATE = Automatisches Update -BLOCK_HEIGHT = height +BLOCK_HEIGHT = Blockhöhe -BUILD_VERSION = Build-Version +BUILD_VERSION = Entwicklungs-Version CHECK_TIME_ACCURACY = Prüfe Zeitgenauigkeit @@ -21,7 +21,7 @@ CREATING_BACKUP_OF_DB_FILES = Erstellen Backup von Datenbank Dateien … DB_BACKUP = Datenbank Backup -DB_CHECKPOINT = Datenbank Kontrollpunkt +DB_CHECKPOINT = Datenbank Check DB_MAINTENANCE = Datenbank Instandhaltung @@ -29,18 +29,18 @@ EXIT = Verlassen LITE_NODE = Lite node -MINTING_DISABLED = NOT minting +MINTING_DISABLED = Kein minting -MINTING_ENABLED = \u2714 Minting +MINTING_ENABLED = \u2714 Minting aktiviert OPEN_UI = Öffne UI -PERFORMING_DB_CHECKPOINT = Speichern nicht übergebener Datenbank Änderungen … +PERFORMING_DB_CHECKPOINT = Speichern von unbestätigten Datenbankänderungen... PERFORMING_DB_MAINTENANCE = Planmäßige Wartung durchführen... SYNCHRONIZE_CLOCK = Synchronisiere Uhr -SYNCHRONIZING_BLOCKCHAIN = Synchronisierung +SYNCHRONIZING_BLOCKCHAIN = Synchronisierung der Blockchain -SYNCHRONIZING_CLOCK = Synchronisierung Uhr +SYNCHRONIZING_CLOCK = Synchronisierung der Uhr diff --git a/src/main/resources/i18n/TransactionValidity_de.properties b/src/main/resources/i18n/TransactionValidity_de.properties new file mode 100644 index 00000000..1827482b --- /dev/null +++ b/src/main/resources/i18n/TransactionValidity_de.properties @@ -0,0 +1,195 @@ +# + +ACCOUNT_ALREADY_EXISTS = Account existiert bereits + +ACCOUNT_CANNOT_REWARD_SHARE = Account kann keine Belohnung teilen + +ADDRESS_ABOVE_RATE_LIMIT = address hat das angegebene Geschwindigkeitlimit erreicht + +ADDRESS_BLOCKED = Addresse ist geblockt + +ALREADY_GROUP_ADMIN = bereits Gruppen Admin + +ALREADY_GROUP_MEMBER = bereits Gruppen Mitglied + +ALREADY_VOTED_FOR_THAT_OPTION = bereits für diese Option gestimmt + +ASSET_ALREADY_EXISTS = asset existiert bereits + +ASSET_DOES_NOT_EXIST = asset nicht gefunden + +ASSET_DOES_NOT_MATCH_AT = asset passt nicht mit AT's asset + +ASSET_NOT_SPENDABLE = asset ist nicht ausgabefähig + +AT_ALREADY_EXISTS = AT existiert bereits + +AT_IS_FINISHED = AT ist fertig + +AT_UNKNOWN = AT unbekannt + +BAN_EXISTS = ban besteht bereits + +BAN_UNKNOWN = ban unbekannt + +BANNED_FROM_GROUP = von der gruppe gebannt + +BUYER_ALREADY_OWNER = Käufer ist bereits Besitzer + +CLOCK_NOT_SYNCED = Uhr nicht synchronisiert + +DUPLICATE_MESSAGE = Adresse sendete doppelte Nachricht + +DUPLICATE_OPTION = Duplizierungsmöglichkeit + +GROUP_ALREADY_EXISTS = Gruppe besteht bereits + +GROUP_APPROVAL_DECIDED = Gruppenfreigabe bereits beschlossen + +GROUP_APPROVAL_NOT_REQUIRED = Gruppenfreigabe nicht erforderlich + +GROUP_DOES_NOT_EXIST = Gruppe nicht vorhanden + +GROUP_ID_MISMATCH = Gruppen-ID stimmt nicht überein + +GROUP_OWNER_CANNOT_LEAVE = Gruppenbesitzer kann Gruppe nicht verlassen + +HAVE_EQUALS_WANT = das bessesene-asset ist das selbe wie das gesuchte-asset + +INCORRECT_NONCE = falsche PoW-Nonce + +INSUFFICIENT_FEE = unzureichende Gebühr + +INVALID_ADDRESS = ungültige Adresse + +INVALID_AMOUNT = ungültiger Betrag + +INVALID_ASSET_OWNER = Ungültiger Eigentümer + +INVALID_AT_TRANSACTION = ungültige AT-Transaktion + +INVALID_AT_TYPE_LENGTH = ungültige AT 'Typ' Länge + +INVALID_BUT_OK = ungültig aber OK + +INVALID_CREATION_BYTES = ungültige Erstellungs der bytes + +INVALID_DATA_LENGTH = ungültige Datenlänge + +INVALID_DESCRIPTION_LENGTH = ungültige Länge der Beschreibung + +INVALID_GROUP_APPROVAL_THRESHOLD = ungültiger Schwellenwert für die Gruppenzulassung + +INVALID_GROUP_BLOCK_DELAY = Ungültige Blockverzögerung der Gruppenfreigabe + +INVALID_GROUP_ID = ungültige Gruppen-ID + +INVALID_GROUP_OWNER = ungültiger Gruppenbesitzer + +INVALID_LIFETIME = unzulässige Lebensdauer + +INVALID_NAME_LENGTH = ungültige Namenslänge + +INVALID_NAME_OWNER = ungültiger Besitzername + +INVALID_OPTION_LENGTH = ungültige Länge der Optionen + +INVALID_OPTIONS_COUNT = Anzahl ungültiger Optionen + +INVALID_ORDER_CREATOR = ungültiger Auftragsersteller + +INVALID_PAYMENTS_COUNT = Anzahl ungültiger Zahlungen + +INVALID_PUBLIC_KEY = ungültiger öffentlicher Schlüssel + +INVALID_QUANTITY = unzulässige Menge + +INVALID_REFERENCE = ungültige Referenz + +INVALID_RETURN = ungültige Rückgabe + +INVALID_REWARD_SHARE_PERCENT = ungültig Prozent der Belohnunganteile + +INVALID_SELLER = unzulässiger Verkäufer + +INVALID_TAGS_LENGTH = ungültige 'tags'-Länge + +INVALID_TIMESTAMP_SIGNATURE = Ungültige Zeitstempel-Signatur + +INVALID_TX_GROUP_ID = Ungültige Transaktionsgruppen-ID + +INVALID_VALUE_LENGTH = ungültige 'Wert'-Länge + +INVITE_UNKNOWN = Gruppeneinladung unbekannt + +JOIN_REQUEST_EXISTS = Gruppeneinladung existiert bereits + +MAXIMUM_REWARD_SHARES = die maximale Anzahl von Reward-Shares für dieses Konto erreicht + +MISSING_CREATOR = fehlender Ersteller + +MULTIPLE_NAMES_FORBIDDEN = mehrere registrierte Namen pro Konto sind untersagt + +NAME_ALREADY_FOR_SALE = Name bereits zum Verkauf + +NAME_ALREADY_REGISTERED = Name bereits registriert + +NAME_BLOCKED = Name geblockt + +NAME_DOES_NOT_EXIST = Name nicht vorhanden + +NAME_NOT_FOR_SALE = Name ist unverkäuflich + +NAME_NOT_NORMALIZED = Name nicht in Unicode-'normalisierter' Form + +NEGATIVE_AMOUNT = ungültiger/negativer Betrag + +NEGATIVE_FEE = ungültige/negative Gebühr + +NEGATIVE_PRICE = ungültiger/negativer Preis + +NO_BALANCE = unzureichendes Guthaben + +NO_BLOCKCHAIN_LOCK = die Blockchain des Knotens ist beschäftigt + +NO_FLAG_PERMISSION = Konto hat diese Berechtigung nicht + +NOT_GROUP_ADMIN = Account ist kein Gruppenadmin + +NOT_GROUP_MEMBER = Account kein Gruppenmitglied + +NOT_MINTING_ACCOUNT = Account kann nicht minten + +NOT_YET_RELEASED = Funktion noch nicht freigegeben + +OK = OK + +ORDER_ALREADY_CLOSED = Asset Trade Order ist bereits geschlossen + +ORDER_DOES_NOT_EXIST = asset trade order existiert nicht + +POLL_ALREADY_EXISTS = Umfrage bereits vorhanden + +POLL_DOES_NOT_EXIST = Umfrage nicht vorhanden + +POLL_OPTION_DOES_NOT_EXIST = Umfrageoption existiert nicht + +PUBLIC_KEY_UNKNOWN = öffentlicher Schlüssel unbekannt + +REWARD_SHARE_UNKNOWN = Geteilte Belohnungen unbekant + +SELF_SHARE_EXISTS = Selbstbeteiligung (Geteilte Belohnungen) sind breits vorhanden + +TIMESTAMP_TOO_NEW = Zeitstempel zu neu + +TIMESTAMP_TOO_OLD = Zeitstempel zu alt + +TOO_MANY_UNCONFIRMED = Account hat zu viele unbestätigte Transaktionen am laufen + +TRANSACTION_ALREADY_CONFIRMED = Transaktionen sind bereits bestätigt + +TRANSACTION_ALREADY_EXISTS = Transaktionen existiert bereits + +TRANSACTION_UNKNOWN = Unbekante Transaktion + +TX_GROUP_ID_MISMATCH = Transaktion Gruppen ID stimmt nicht überein From 4c463f65b77fcc25295142140ba4084910940fdb Mon Sep 17 00:00:00 2001 From: DrewMPeacock Date: Mon, 8 Aug 2022 15:58:46 -0600 Subject: [PATCH 004/496] 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 73396490ba74d94e938863287bdcd6a92791a512 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 27 Aug 2022 19:44:31 +0100 Subject: [PATCH 005/496] Set walletsPath and listsPath to AppData folder for new Windows installs. --- WindowsInstaller/Qortal.aip | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 74acc012..c90dda3d 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -1173,7 +1173,7 @@ - + From 1abceada209d391f9b466a74b1ba9758e6bc4bf6 Mon Sep 17 00:00:00 2001 From: DrewMPeacock Date: Fri, 9 Sep 2022 11:20:46 -0600 Subject: [PATCH 006/496] 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 5581b83c577f3406f593685a50e7f4a32d151883 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 19 Sep 2022 11:03:06 +0100 Subject: [PATCH 007/496] Added initial admin approval features for groups owned by the null account. * The dev group (ID 1) is owned by the null account with public key 11111111111111111111111111111111 * To regain access to otherwise blocked owner-based rules, it has different validation logic * which applies to groups with this same null owner. * * The main difference is that approval is required for certain transaction types relating to * null-owned groups. This allows existing admins to approve updates to the group (using group's * approval threshold) instead of these actions being performed by the owner. * * Since these apply to all null-owned groups, this allows anyone to update their group to * the null owner if they want to take advantage of this decentralized approval system. * * Currently, the affected transaction types are: * - AddGroupAdminTransaction * - RemoveGroupAdminTransaction * * This same approach could ultimately be applied to other group transactions too. --- .../data/transaction/TransactionData.java | 4 + src/main/java/org/qortal/group/Group.java | 3 + .../transaction/AddGroupAdminTransaction.java | 10 +- .../RemoveGroupAdminTransaction.java | 11 +- .../org/qortal/transaction/Transaction.java | 19 +- .../qortal/test/group/DevGroupAdminTests.java | 388 ++++++++++++++++++ src/test/resources/test-chain-v2.json | 2 + 7 files changed, 423 insertions(+), 14 deletions(-) create mode 100644 src/test/java/org/qortal/test/group/DevGroupAdminTests.java diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index 060901f2..ec1139f4 100644 --- a/src/main/java/org/qortal/data/transaction/TransactionData.java +++ b/src/main/java/org/qortal/data/transaction/TransactionData.java @@ -128,6 +128,10 @@ public abstract class TransactionData { return this.txGroupId; } + public void setTxGroupId(int txGroupId) { + this.txGroupId = txGroupId; + } + public byte[] getReference() { return this.reference; } diff --git a/src/main/java/org/qortal/group/Group.java b/src/main/java/org/qortal/group/Group.java index 1dbb18b0..465743a9 100644 --- a/src/main/java/org/qortal/group/Group.java +++ b/src/main/java/org/qortal/group/Group.java @@ -80,6 +80,9 @@ public class Group { // Useful constants public static final int NO_GROUP = 0; + // Null owner address corresponds with public key "11111111111111111111111111111111" + public static String NULL_OWNER_ADDRESS = "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG"; + public static final int MIN_NAME_SIZE = 3; public static final int MAX_NAME_SIZE = 32; public static final int MAX_DESCRIPTION_SIZE = 128; diff --git a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java index 15dc51bf..3cd9845d 100644 --- a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.qortal.account.Account; import org.qortal.asset.Asset; @@ -64,9 +65,14 @@ public class AddGroupAdminTransaction extends Transaction { Account owner = getOwner(); String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); - // Check transaction's public key matches group's current owner - if (!owner.getAddress().equals(groupOwner)) + // Require approval if transaction relates to a group owned by the null account + if (groupOwnedByNullAccount && !this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + + // Check transaction's public key matches group's current owner (except for groups owned by the null account) + if (!groupOwnedByNullAccount && !owner.getAddress().equals(groupOwner)) return ValidationResult.INVALID_GROUP_OWNER; // Check address is a group member diff --git a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java index 3e5f1e6d..8d538143 100644 --- a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.qortal.account.Account; import org.qortal.asset.Asset; @@ -65,9 +66,15 @@ public class RemoveGroupAdminTransaction extends Transaction { return ValidationResult.GROUP_DOES_NOT_EXIST; Account owner = getOwner(); + String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); - // Check transaction's public key matches group's current owner - if (!owner.getAddress().equals(groupData.getOwner())) + // Require approval if transaction relates to a group owned by the null account + if (groupOwnedByNullAccount && !this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + + // Check transaction's public key matches group's current owner (except for groups owned by the null account) + if (!groupOwnedByNullAccount && !owner.getAddress().equals(groupOwner)) return ValidationResult.INVALID_GROUP_OWNER; Account admin = getAdmin(); diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index b56d48cf..203cc342 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -1,13 +1,7 @@ package org.qortal.transaction; import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.EnumSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; @@ -69,8 +63,8 @@ public abstract class Transaction { AT(21, false), CREATE_GROUP(22, true), UPDATE_GROUP(23, true), - ADD_GROUP_ADMIN(24, false), - REMOVE_GROUP_ADMIN(25, false), + ADD_GROUP_ADMIN(24, true), + REMOVE_GROUP_ADMIN(25, true), GROUP_BAN(26, false), CANCEL_GROUP_BAN(27, false), GROUP_KICK(28, false), @@ -250,6 +244,7 @@ public abstract class Transaction { INVALID_TIMESTAMP_SIGNATURE(95), ADDRESS_BLOCKED(96), NAME_BLOCKED(97), + GROUP_APPROVAL_REQUIRED(98), INVALID_BUT_OK(999), NOT_YET_RELEASED(1000); @@ -760,9 +755,13 @@ public abstract class Transaction { // Group no longer exists? Possibly due to blockchain orphaning undoing group creation? return true; // stops tx being included in block but it will eventually expire + String groupOwner = this.repository.getGroupRepository().getOwner(txGroupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); + // If transaction's creator is group admin (of group with ID txGroupId) then auto-approve + // This is disabled for null-owned groups, since these require approval from other admins PublicKeyAccount creator = this.getCreator(); - if (groupRepository.adminExists(txGroupId, creator.getAddress())) + if (!groupOwnedByNullAccount && groupRepository.adminExists(txGroupId, creator.getAddress())) return false; return true; diff --git a/src/test/java/org/qortal/test/group/DevGroupAdminTests.java b/src/test/java/org/qortal/test/group/DevGroupAdminTests.java new file mode 100644 index 00000000..131359c6 --- /dev/null +++ b/src/test/java/org/qortal/test/group/DevGroupAdminTests.java @@ -0,0 +1,388 @@ +package org.qortal.test.group; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.transaction.*; +import org.qortal.group.Group; +import org.qortal.group.Group.ApprovalThreshold; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.GroupUtils; +import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.utils.Base58; + +import static org.junit.Assert.*; + +/** + * Dev group admin tests + * + * The dev group (ID 1) is owned by the null account with public key 11111111111111111111111111111111 + * To regain access to otherwise blocked owner-based rules, it has different validation logic + * which applies to groups with this same null owner. + * + * The main difference is that approval is required for certain transaction types relating to + * null-owned groups. This allows existing admins to approve updates to the group (using group's + * approval threshold) instead of these actions being performed by the owner. + * + * Since these apply to all null-owned groups, this allows anyone to update their group to + * the null owner if they want to take advantage of this decentralized approval system. + * + * Currently, the affected transaction types are: + * - AddGroupAdminTransaction + * - RemoveGroupAdminTransaction + * + * This same approach could ultimately be applied to other group transactions too. + */ +public class DevGroupAdminTests extends Common { + + private static final int DEV_GROUP_ID = 1; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @After + public void afterTest() throws DataException { + Common.orphanCheck(); + } + + @Test + public void testGroupKickMember() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to kick Bob + ValidationResult result = groupKick(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + joinGroup(repository, bob, groupId); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to kick Bob + result = groupKick(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + } + } + + @Test + public void testGroupKickAdmin() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + joinGroup(repository, bob, groupId); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Promote Bob to admin + TransactionData addGroupAdminTransactionData = addGroupAdmin(repository, alice, groupId, bob.getAddress()); + + // Confirm transaction needs approval, and hasn't been approved + Transaction.ApprovalStatus approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.PENDING, approvalStatus); + + // Have Alice approve Bob's approval-needed transaction + GroupUtils.approveTransaction(repository, "alice", addGroupAdminTransactionData.getSignature(), true); + + // Mint a block so that the transaction becomes approved + BlockUtils.mintBlock(repository); + + // Confirm transaction is approved + approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.APPROVED, approvalStatus); + + // Confirm Bob is now admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Attempt to kick Bob + ValidationResult result = groupKick(repository, alice, groupId, bob.getAddress()); + // Shouldn't be allowed + assertEquals(ValidationResult.INVALID_GROUP_OWNER, result); + + // Confirm Bob is still a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Confirm Bob still an admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Confirm Bob no longer an admin (ADD_GROUP_ADMIN no longer approved) + assertFalse(isAdmin(repository, bob.getAddress(), groupId)); + + // Have Alice try to kick herself! + result = groupKick(repository, alice, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Have Bob try to kick Alice + result = groupKick(repository, bob, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + } + } + + @Test + public void testGroupBanMember() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to cancel non-existent Bob ban + ValidationResult result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Attempt to ban Bob + result = groupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to ban Bob + result = groupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Cancel Bob's ban + result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Orphan last block (Bob join) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed join-group transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Orphan last block (Cancel Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed cancel-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + } + } + + @Test + public void testGroupBanAdmin() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + ValidationResult result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Promote Bob to admin + TransactionData addGroupAdminTransactionData = addGroupAdmin(repository, alice, groupId, bob.getAddress()); + + // Confirm transaction needs approval, and hasn't been approved + Transaction.ApprovalStatus approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.PENDING, approvalStatus); + + // Have Alice approve Bob's approval-needed transaction + GroupUtils.approveTransaction(repository, "alice", addGroupAdminTransactionData.getSignature(), true); + + // Mint a block so that the transaction becomes approved + BlockUtils.mintBlock(repository); + + // Confirm transaction is approved + approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.APPROVED, approvalStatus); + + // Confirm Bob is now admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Attempt to ban Bob + result = groupBan(repository, alice, groupId, bob.getAddress()); + // .. but we can't, because Bob is an admin and the group has no owner + assertEquals(ValidationResult.INVALID_GROUP_OWNER, result); + + // Confirm Bob still a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // ... and still an admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Have Alice try to ban herself! + result = groupBan(repository, alice, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Have Bob try to ban Alice + result = groupBan(repository, bob, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + } + } + + + private ValidationResult joinGroup(Repository repository, PrivateKeyAccount joiner, int groupId) throws DataException { + JoinGroupTransactionData transactionData = new JoinGroupTransactionData(TestTransaction.generateBase(joiner), groupId); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, joiner); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private void groupInvite(Repository repository, PrivateKeyAccount admin, int groupId, String invitee, int timeToLive) throws DataException { + GroupInviteTransactionData transactionData = new GroupInviteTransactionData(TestTransaction.generateBase(admin), groupId, invitee, timeToLive); + TransactionUtils.signAndMint(repository, transactionData, admin); + } + + private ValidationResult groupKick(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { + GroupKickTransactionData transactionData = new GroupKickTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing"); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private ValidationResult groupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { + GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing", 0); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private ValidationResult cancelGroupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { + CancelGroupBanTransactionData transactionData = new CancelGroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private TransactionData addGroupAdmin(Repository repository, PrivateKeyAccount owner, int groupId, String member) throws DataException { + AddGroupAdminTransactionData transactionData = new AddGroupAdminTransactionData(TestTransaction.generateBase(owner), groupId, member); + transactionData.setTxGroupId(groupId); + TransactionUtils.signAndMint(repository, transactionData, owner); + return transactionData; + } + + private boolean isMember(Repository repository, String address, int groupId) throws DataException { + return repository.getGroupRepository().memberExists(groupId, address); + } + + private boolean isAdmin(Repository repository, String address, int groupId) throws DataException { + return repository.getGroupRepository().adminExists(groupId, address); + } + +} diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 5f439602..e3a2f4f2 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -90,6 +90,8 @@ { "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 }, + { "type": "UPDATE_GROUP", "ownerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupId": 1, "newOwner": "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG", "newDescription": "developer group", "newIsOpen": false, "newApprovalThreshold": "PCT40", "minimumBlockDelay": 10, "maximumBlockDelay": 1440 }, + { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "TEST", "description": "test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, { "type": "ISSUE_ASSET", "issuerPublicKey": "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry", "assetName": "OTHER", "description": "other test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "GOLD", "description": "gold test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, From 93fd80e289b9923e348c87bcd4e089d340215cb2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 19 Sep 2022 16:34:31 +0100 Subject: [PATCH 008/496] Require that add/remove admin transactions can only be created by group members. For regular groups, we require that the owner adds/removes the admins, so group membership is adequately checked. However for null-owned groups this check is skipped. So we need an additional condition to prevent non-group members from issuing a transaction for approval by the group admins. --- .../java/org/qortal/transaction/AddGroupAdminTransaction.java | 4 ++++ .../org/qortal/transaction/RemoveGroupAdminTransaction.java | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java index 3cd9845d..f38638c5 100644 --- a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java @@ -79,6 +79,10 @@ public class AddGroupAdminTransaction extends Transaction { if (!this.repository.getGroupRepository().memberExists(groupId, memberAddress)) return ValidationResult.NOT_GROUP_MEMBER; + // Check transaction creator is a group member + if (!this.repository.getGroupRepository().memberExists(groupId, this.getCreator().getAddress())) + return ValidationResult.NOT_GROUP_MEMBER; + // Check group member is not already an admin if (this.repository.getGroupRepository().adminExists(groupId, memberAddress)) return ValidationResult.ALREADY_GROUP_ADMIN; diff --git a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java index 8d538143..043b5423 100644 --- a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java @@ -77,6 +77,10 @@ public class RemoveGroupAdminTransaction extends Transaction { if (!groupOwnedByNullAccount && !owner.getAddress().equals(groupOwner)) return ValidationResult.INVALID_GROUP_OWNER; + // Check transaction creator is a group member + if (!this.repository.getGroupRepository().memberExists(groupId, this.getCreator().getAddress())) + return ValidationResult.NOT_GROUP_MEMBER; + Account admin = getAdmin(); // Check member is an admin From 77d60fc33f8171363d58d037044a7bac4ae4152d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Oct 2022 14:11:28 +0100 Subject: [PATCH 009/496] Revert "Skip GET_BLOCK_SUMMARIES requests if it can already be fulfilled entirely from the peer's chain tip block summaries cache." This reverts commit 8cedf618f45a5d1ab24633e9b32972e663c3dcdd. --- .../org/qortal/controller/Synchronizer.java | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index ccb3dfdd..e4419249 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -8,7 +8,6 @@ import java.util.*; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; -import java.util.stream.IntStream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -1557,41 +1556,7 @@ public class Synchronizer extends Thread { return SynchronizationResult.OK; } - private List getBlockSummariesFromCache(Peer peer, byte[] parentSignature, int numberRequested) { - List peerSummaries = peer.getChainTipSummaries(); - if (peerSummaries == null) - return null; - - // Check if the requested parent block exists in peer's summaries cache - int parentIndex = IntStream.range(0, peerSummaries.size()).filter(i -> Arrays.equals(peerSummaries.get(i).getSignature(), parentSignature)).findFirst().orElse(-1); - if (parentIndex < 0) - return null; - - // Peer's summaries contains the requested parent, so return summaries after that - // Make sure we have at least one block after the parent block - int summariesAvailable = peerSummaries.size() - parentIndex - 1; - if (summariesAvailable <= 0) - return null; - - // Don't try and return more summaries than we have, or more than were requested - int summariesToReturn = Math.min(numberRequested, summariesAvailable); - int startIndex = parentIndex + 1; - int endIndex = startIndex + summariesToReturn - 1; - if (endIndex > peerSummaries.size() - 1) - return null; - - LOGGER.trace("Serving {} block summaries from cache", summariesToReturn); - return peerSummaries.subList(startIndex, endIndex); - } - private List getBlockSummaries(Peer peer, byte[] parentSignature, int numberRequested) throws InterruptedException { - // We might be able to shortcut the response if we already have the summaries in the peer's chain tip data - List cachedSummaries = this.getBlockSummariesFromCache(peer, parentSignature, numberRequested); - if (cachedSummaries != null && !cachedSummaries.isEmpty()) - return cachedSummaries; - - LOGGER.trace("Requesting {} block summaries from peer {}", numberRequested, peer); - Message getBlockSummariesMessage = new GetBlockSummariesMessage(parentSignature, numberRequested); Message message = peer.getResponse(getBlockSummariesMessage); From 8ddf4c9f9f6b14db7676261da95c10d4f26cbcbe Mon Sep 17 00:00:00 2001 From: Nuc1eoN <2538022+Nuc1eoN@users.noreply.github.com> Date: Sun, 9 Oct 2022 15:35:19 +0200 Subject: [PATCH 010/496] Add polish translation --- .../resources/i18n/ApiError_pl.properties | 83 ++++++++ src/main/resources/i18n/SysTray_pl.properties | 46 ++++ .../i18n/TransactionValidity_pl.properties | 196 ++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 src/main/resources/i18n/ApiError_pl.properties create mode 100644 src/main/resources/i18n/SysTray_pl.properties create mode 100644 src/main/resources/i18n/TransactionValidity_pl.properties diff --git a/src/main/resources/i18n/ApiError_pl.properties b/src/main/resources/i18n/ApiError_pl.properties new file mode 100644 index 00000000..fcb6191c --- /dev/null +++ b/src/main/resources/i18n/ApiError_pl.properties @@ -0,0 +1,83 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# Keys are from api.ApiError enum + +# "localeLang": "pl", + +### Common ### +JSON = nie udało się przetworzyć wiadomości JSON + +INSUFFICIENT_BALANCE = niedostateczne środki + +UNAUTHORIZED = nieautoryzowane połączenie API + +REPOSITORY_ISSUE = błąd repozytorium + +NON_PRODUCTION = to wywołanie API nie jest dozwolone dla systemów produkcyjnych + +BLOCKCHAIN_NEEDS_SYNC = blockchain musi się najpierw zsynchronizować + +NO_TIME_SYNC = zegar się jeszcze nie zsynchronizował + +### Validation ### +INVALID_SIGNATURE = nieprawidłowa sygnatura + +INVALID_ADDRESS = nieprawidłowy adres + +INVALID_PUBLIC_KEY = nieprawidłowy klucz publiczny + +INVALID_DATA = nieprawidłowe dane + +INVALID_NETWORK_ADDRESS = nieprawidłowy adres sieci + +ADDRESS_UNKNOWN = nieznany adres konta + +INVALID_CRITERIA = nieprawidłowe kryteria wyszukiwania + +INVALID_REFERENCE = nieprawidłowe skierowanie + +TRANSFORMATION_ERROR = nie udało się przekształcić JSON w transakcję + +INVALID_PRIVATE_KEY = klucz prywatny jest niepoprawny + +INVALID_HEIGHT = nieprawidłowa wysokość bloku + +CANNOT_MINT = konto nie możne bić monet + +### Blocks ### +BLOCK_UNKNOWN = blok nieznany + +### Transactions ### +TRANSACTION_UNKNOWN = nieznana transakcja + +PUBLIC_KEY_NOT_FOUND = nie znaleziono klucza publicznego + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = transakcja nieważna: %s (%s) + +### Naming ### +NAME_UNKNOWN = nazwa nieznana + +### Asset ### +INVALID_ASSET_ID = nieprawidłowy identyfikator aktywy + +INVALID_ORDER_ID = nieprawidłowy identyfikator zlecenia aktywy + +ORDER_UNKNOWN = nieznany identyfikator zlecenia aktywy + +### Groups ### +GROUP_UNKNOWN = nieznana grupa + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = obcy blockchain lub problem z siecią ElectrumX + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = niewystarczające środki na obcym blockchainie + +FOREIGN_BLOCKCHAIN_TOO_SOON = zbyt wczesne nadawanie transakcji na obcym blockchainie (okres karencji/średni czas bloku) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = zbyt niska kwota zlecenia + +### Data ### +FILE_NOT_FOUND = plik nie został znaleziony + +NO_REPLY = peer nie odpowiedział w wyznaczonym czasie diff --git a/src/main/resources/i18n/SysTray_pl.properties b/src/main/resources/i18n/SysTray_pl.properties new file mode 100644 index 00000000..84740da0 --- /dev/null +++ b/src/main/resources/i18n/SysTray_pl.properties @@ -0,0 +1,46 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# SysTray pop-up menu + +APPLYING_UPDATE_AND_RESTARTING = Zastosowanie automatycznej aktualizacji i ponowne uruchomienie... + +AUTO_UPDATE = Automatyczna aktualizacja + +BLOCK_HEIGHT = wysokość + +BUILD_VERSION = Wersja kompilacji + +CHECK_TIME_ACCURACY = Sprawdz dokładność czasu + +CONNECTING = Łączenie + +CONNECTION = połączenie + +CONNECTIONS = połączenia + +CREATING_BACKUP_OF_DB_FILES = Tworzenie kopii zapasowej plików bazy danych... + +DB_BACKUP = Kopia zapasowa bazy danych + +DB_CHECKPOINT = Punkt kontrolny bazy danych... + +DB_MAINTENANCE = Konserwacja bazy danych + +EXIT = Zakończ + +LITE_NODE = Lite node + +MINTING_DISABLED = Mennica zamknięta + +MINTING_ENABLED = \u2714 Mennica aktywna + +OPEN_UI = Otwórz interfejs użytkownika + +PERFORMING_DB_CHECKPOINT = Zapisywanie niezaksięgowanych zmian w bazie danych... + +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + +SYNCHRONIZE_CLOCK = Synchronizuj zegar + +SYNCHRONIZING_BLOCKCHAIN = Synchronizacja + +SYNCHRONIZING_CLOCK = Synchronizacja zegara diff --git a/src/main/resources/i18n/TransactionValidity_pl.properties b/src/main/resources/i18n/TransactionValidity_pl.properties new file mode 100644 index 00000000..bcdceb6e --- /dev/null +++ b/src/main/resources/i18n/TransactionValidity_pl.properties @@ -0,0 +1,196 @@ +# + +ACCOUNT_ALREADY_EXISTS = konto już istnieje + +ACCOUNT_CANNOT_REWARD_SHARE = konto nie może udostępniać nagród + +ADDRESS_ABOVE_RATE_LIMIT = adres osiągnął określony limit stawki + +ADDRESS_BLOCKED = ten adres jest zablokowany + +ALREADY_GROUP_ADMIN = już adminem grupy + +ALREADY_GROUP_MEMBER = już członkiem grupy + +ALREADY_VOTED_FOR_THAT_OPTION = już zagłosowano na ta opcje + +ASSET_ALREADY_EXISTS = aktywa już istnieje + +ASSET_DOES_NOT_EXIST = aktywa nie istnieje + +ASSET_DOES_NOT_MATCH_AT = aktywa nie pasuje do aktywy AT + +ASSET_NOT_SPENDABLE = aktywa nie jest rozporządzalna + +AT_ALREADY_EXISTS = AT już istnieje + +AT_IS_FINISHED = AT zakończył + +AT_UNKNOWN = AT nieznany + +BAN_EXISTS = ban już istnieje + +BAN_UNKNOWN = ban nieznany + +BANNED_FROM_GROUP = zbanowany z grupy + +BUYER_ALREADY_OWNER = kupca jest już właścicielem + +CLOCK_NOT_SYNCED = zegar nie zsynchronizowany + +DUPLICATE_MESSAGE = adres wysłał duplikat wiadomości + +DUPLICATE_OPTION = duplikat opcji + +GROUP_ALREADY_EXISTS = grupa już istnieje + +GROUP_APPROVAL_DECIDED = zatwierdzenie grupy już zdecydowano + +GROUP_APPROVAL_NOT_REQUIRED = zatwierdzenie grupy nie jest wymagane + +GROUP_DOES_NOT_EXIST = grupa nie istnieje + +GROUP_ID_MISMATCH = niedopasowanie identyfikatora grupy + +GROUP_OWNER_CANNOT_LEAVE = właściciel grupy nie może opuścić grupy + +HAVE_EQUALS_WANT = posiadana aktywa równa się chcianej aktywie + +INCORRECT_NONCE = nieprawidłowy nonce PoW + +INSUFFICIENT_FEE = niewystarczająca opłata + +INVALID_ADDRESS = nieprawidłowy adres + +INVALID_AMOUNT = nieprawidłowa kwota + +INVALID_ASSET_OWNER = nieprawidłowy właściciel aktywów + +INVALID_AT_TRANSACTION = nieważna transakcja AT + +INVALID_AT_TYPE_LENGTH = nieprawidłowa długość typu AT + +INVALID_BUT_OK = nieważne, ale OK + +INVALID_CREATION_BYTES = nieprawidłowe bajty tworzenia + +INVALID_DATA_LENGTH = nieprawidłowa długość danych + +INVALID_DESCRIPTION_LENGTH = nieprawidłowa długość opisu + +INVALID_GROUP_APPROVAL_THRESHOLD = nieprawidłowy próg zatwierdzenia grupy + +INVALID_GROUP_BLOCK_DELAY = nieprawidłowe opóźnienie bloku zatwierdzenia grupy + +INVALID_GROUP_ID = nieprawidłowy identyfikator grupy + +INVALID_GROUP_OWNER = nieprawidłowy właściciel grupy + +INVALID_LIFETIME = nieprawidłowy czas istnienia + +INVALID_NAME_LENGTH = nieprawidłowa długość nazwy + +INVALID_NAME_OWNER = nieprawidłowy właściciel nazwy + +INVALID_OPTION_LENGTH = nieprawidłowa długość opcji + +INVALID_OPTIONS_COUNT = nieprawidłowa liczba opcji + +INVALID_ORDER_CREATOR = nieprawidłowy twórca zlecenia + +INVALID_PAYMENTS_COUNT = nieprawidłowa liczba płatności + +INVALID_PUBLIC_KEY = nieprawidłowy klucz publiczny + +INVALID_QUANTITY = nieprawidłowa ilość + +INVALID_REFERENCE = nieprawidłowe skierowanie + +INVALID_RETURN = nieprawidłowy zwrot + +INVALID_REWARD_SHARE_PERCENT = nieprawidłowy procent udziału w nagrodzie + +INVALID_SELLER = nieprawidłowy sprzedawca + +INVALID_TAGS_LENGTH = nieprawidłowa długość tagów + +INVALID_TIMESTAMP_SIGNATURE = nieprawidłowa sygnatura znacznika czasu + +INVALID_TX_GROUP_ID = nieprawidłowy identyfikator grupy transakcji + +INVALID_VALUE_LENGTH = nieprawidłowa długość wartości + +INVITE_UNKNOWN = zaproszenie do grupy nieznane + +JOIN_REQUEST_EXISTS = wniosek o dołączenie do grupy już istnieje + +MAXIMUM_REWARD_SHARES = osiągnięto już maksymalną liczbę udziałów w nagrodzie dla tego konta + +MISSING_CREATOR = brak twórcy + +MULTIPLE_NAMES_FORBIDDEN = zabronione jest używanie wielu nazw na jednym koncie + +NAME_ALREADY_FOR_SALE = nazwa już wystawiona na sprzedaż + +NAME_ALREADY_REGISTERED = nazwa już zarejestrowana + +NAME_BLOCKED = ta nazwa jest zablokowana + +NAME_DOES_NOT_EXIST = nazwa nie istnieje + +NAME_NOT_FOR_SALE = nazwa nie jest przeznaczona do sprzedaży + +NAME_NOT_NORMALIZED = nazwa nie jest w formie 'znormalizowanej' Unicode + +NEGATIVE_AMOUNT = nieprawidłowa/ujemna kwota + +NEGATIVE_FEE = nieprawidłowa/ujemna opłata + +NEGATIVE_PRICE = nieprawidłowa/ujemna cena + +NO_BALANCE = niewystarczające środki + +NO_BLOCKCHAIN_LOCK = węzeł blockchain jest obecnie zajęty + +NO_FLAG_PERMISSION = konto nie ma tego uprawnienia + +NOT_GROUP_ADMIN = konto nie jest adminem grupy + +NOT_GROUP_MEMBER = konto nie jest członkiem grupy + +NOT_MINTING_ACCOUNT = konto nie może bić monet + +NOT_YET_RELEASED = funkcja nie została jeszcze udostępniona + +OK = OK + +ORDER_ALREADY_CLOSED = zlecenie handlu aktywami jest już zakończone + +ORDER_DOES_NOT_EXIST = zlecenie sprzedaży aktywów nie istnieje + +POLL_ALREADY_EXISTS = ankieta już istnieje + +POLL_DOES_NOT_EXIST = ankieta nie istnieje + +POLL_OPTION_DOES_NOT_EXIST = opcja ankiety nie istnieje + +PUBLIC_KEY_UNKNOWN = klucz publiczny nieznany + +REWARD_SHARE_UNKNOWN = nieznany udział w nagrodzie + +SELF_SHARE_EXISTS = samoudział (udział w nagrodzie) już istnieje + +TIMESTAMP_TOO_NEW = zbyt nowy znacznik czasu + +TIMESTAMP_TOO_OLD = zbyt stary znacznik czasu + +TOO_MANY_UNCONFIRMED = rachunek ma zbyt wiele niepotwierdzonych transakcji w toku + +TRANSACTION_ALREADY_CONFIRMED = transakcja została już potwierdzona + +TRANSACTION_ALREADY_EXISTS = transakcja już istnieje + +TRANSACTION_UNKNOWN = transakcja nieznana + +TX_GROUP_ID_MISMATCH = niezgodność ID grupy transakcji + From e6bb0b81cff21d2e713185c95b8962d4bb87e50e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Oct 2022 19:11:20 +0100 Subject: [PATCH 011/496] Revert "Reduce INITIAL_BLOCK_STEP from 8 to 7." This reverts commit 0088ba8485a73c723a9ea4555e0435d42df20a3f. --- src/main/java/org/qortal/controller/Synchronizer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index e4419249..0fe9a56b 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -43,7 +43,7 @@ public class Synchronizer extends Thread { private static final int SYNC_BATCH_SIZE = 1000; // XXX move to Settings? /** Initial jump back of block height when searching for common block with peer */ - private static final int INITIAL_BLOCK_STEP = 7; + private static final int INITIAL_BLOCK_STEP = 8; /** Maximum jump back of block height when searching for common block with peer */ private static final int MAXIMUM_BLOCK_STEP = 128; From 2d58118d7cfa717a4a6521b9d2fa2bd325c7e5ea Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Oct 2022 20:11:01 +0100 Subject: [PATCH 012/496] Always use BlockSummariesMessage V1 (instead of V2) when responding to GetBlockSummaries requests. This should hopefully fix a potential issue where peer's chain tip data becomes contaminated with other summary data, causing incorrect sync decisions. --- src/main/java/org/qortal/controller/Controller.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index ce994757..1e028ebc 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1430,9 +1430,7 @@ public class Controller extends Thread { // then we have no blocks after that and can short-circuit with an empty response BlockData chainTip = getChainTip(); if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { - Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION - ? new BlockSummariesV2Message(Collections.emptyList()) - : new BlockSummariesMessage(Collections.emptyList()); + Message blockSummariesMessage = new BlockSummariesMessage(Collections.emptyList()); blockSummariesMessage.setId(message.getId()); @@ -1491,9 +1489,7 @@ public class Controller extends Thread { this.stats.getBlockSummariesStats.fullyFromCache.incrementAndGet(); } - Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION - ? new BlockSummariesV2Message(blockSummaries) - : new BlockSummariesMessage(blockSummaries); + Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); blockSummariesMessage.setId(message.getId()); if (!peer.sendMessage(blockSummariesMessage)) peer.disconnect("failed to send block summaries"); From cb1eee8ff5f1f30e647cec69779cbf08dff91f94 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Oct 2022 20:37:39 +0100 Subject: [PATCH 013/496] GenericUnknownMessage.MINIMUM_PEER_VERSION set to 3.6.1. This should ideally have been set in the 3.6.1 release, but not setting it is unlikely to have caused any problems. --- .../java/org/qortal/network/message/GenericUnknownMessage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/message/GenericUnknownMessage.java b/src/main/java/org/qortal/network/message/GenericUnknownMessage.java index 15faaa1b..dea9f2b8 100644 --- a/src/main/java/org/qortal/network/message/GenericUnknownMessage.java +++ b/src/main/java/org/qortal/network/message/GenericUnknownMessage.java @@ -4,7 +4,7 @@ import java.nio.ByteBuffer; public class GenericUnknownMessage extends Message { - public static final long MINIMUM_PEER_VERSION = 0x03000400cbL; + public static final long MINIMUM_PEER_VERSION = 0x0300060001L; public GenericUnknownMessage() { super(MessageType.GENERIC_UNKNOWN); From 36fcd6792a55352b8d7753dd7d9b8cb16f42d9eb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 10 Oct 2022 10:28:36 +0100 Subject: [PATCH 014/496] Discard BLOCK_SUMMARIES_V2 messages with an ID (thanks to @catbref for the code) This is a better fix for the "contaminated chain tip summaries" issue. Need to reduce the logging level to debug before release. --- src/main/java/org/qortal/controller/Controller.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 1e028ebc..2146c86b 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1599,6 +1599,17 @@ public class Controller extends Thread { } } + if (message.hasId()) { + /* + * Experimental proof-of-concept: discard messages with ID + * These are 'late' reply messages received after timeout has expired, + * having been passed upwards from Peer to Network to Controller. + * Hence, these are NOT simple "here's my chain tip" broadcasts from other peers. + */ + LOGGER.info("Discarding late {} message with ID {} from {}", message.getType().name(), message.getId(), peer); + return; + } + // Update peer chain tip data peer.setChainTipSummaries(blockSummariesV2Message.getBlockSummaries()); From 10d3176e70694808be0476a95d15804e31fcb948 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 10 Oct 2022 10:28:44 +0100 Subject: [PATCH 015/496] Revert "Always use BlockSummariesMessage V1 (instead of V2) when responding to GetBlockSummaries requests." This reverts commit 2d58118d7cfa717a4a6521b9d2fa2bd325c7e5ea. --- src/main/java/org/qortal/controller/Controller.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 2146c86b..93cbae92 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1430,7 +1430,9 @@ public class Controller extends Thread { // then we have no blocks after that and can short-circuit with an empty response BlockData chainTip = getChainTip(); if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { - Message blockSummariesMessage = new BlockSummariesMessage(Collections.emptyList()); + Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION + ? new BlockSummariesV2Message(Collections.emptyList()) + : new BlockSummariesMessage(Collections.emptyList()); blockSummariesMessage.setId(message.getId()); @@ -1489,7 +1491,9 @@ public class Controller extends Thread { this.stats.getBlockSummariesStats.fullyFromCache.incrementAndGet(); } - Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); + Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION + ? new BlockSummariesV2Message(blockSummaries) + : new BlockSummariesMessage(blockSummaries); blockSummariesMessage.setId(message.getId()); if (!peer.sendMessage(blockSummariesMessage)) peer.disconnect("failed to send block summaries"); From d4aaba2293105e63deeb784b87a8dbe566d25724 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 10 Oct 2022 19:06:08 +0100 Subject: [PATCH 016/496] Bump version to 3.6.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 3be7fff3..591801e9 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.6.1 + 3.6.2 jar true From 7c15d88cbc23dd45d8c090d286a628c05974af01 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 12 Oct 2022 08:52:58 +0100 Subject: [PATCH 017/496] Fix for issue in BLOCK_SUMMARIES_V2 when sending an empty array of summaries. The BLOCK_SUMMARIES message type would differentiate between an empty response and a missing/invalid response. However, in V2, a response with empty summaries would throw a BufferUnderflowException and be treated by the caller as a null message. This caused problems when trying to find a common block with peers that have diverged by more than 8 blocks. With V1 the caller would know to search back further (e.g. 16 blocks) but in V2 it was treated as "no response" and so the caller would give up instead of increasing the look-back threshold. This fix will identify BLOCK_SUMMARIES_V2 messages with no content, and return an empty array of block summaries instead of a null message. Should be enough to recover any stuck nodes, as long as they haven't diverged more than 240 blocks from the main chain. --- .../qortal/network/message/BlockSummariesV2Message.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java b/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java index 6ed6c8aa..62428cc0 100644 --- a/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java +++ b/src/main/java/org/qortal/network/message/BlockSummariesV2Message.java @@ -68,13 +68,18 @@ public class BlockSummariesV2Message extends Message { } public static Message fromByteBuffer(int id, ByteBuffer bytes) { + List blockSummaries = new ArrayList<>(); + + // If there are no bytes remaining then we can treat this as an empty array of summaries + if (bytes.remaining() == 0) + return new BlockSummariesV2Message(id, blockSummaries); + int height = bytes.getInt(); // Expecting bytes remaining to be exact multiples of BLOCK_SUMMARY_V2_LENGTH if (bytes.remaining() % BLOCK_SUMMARY_V2_LENGTH != 0) throw new BufferUnderflowException(); - List blockSummaries = new ArrayList<>(); while (bytes.hasRemaining()) { byte[] signature = new byte[BlockTransformer.BLOCK_SIGNATURE_LENGTH]; bytes.get(signature); From 7c7f071eba29240e1b8045df978d1b6fc3f11f60 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 12 Oct 2022 08:54:27 +0100 Subject: [PATCH 018/496] Bump version to 3.6.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 591801e9..5f439cad 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.6.2 + 3.6.3 jar true From 5c223179edf11bdcc62bf269268f79093b0ea870 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 13 Oct 2022 23:37:21 +0100 Subject: [PATCH 019/496] Updated AdvancedInstaller project for v3.6.3 --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index c90dda3d..1f579a9c 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - + From b4125d2bf15a87195cf8cfc2a9501a76a4c71335 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Oct 2022 11:34:46 +0100 Subject: [PATCH 020/496] Fix for NPE in verifyMemoryPoW() --- .../java/org/qortal/controller/OnlineAccountsManager.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index ff20a8d0..7b60f0d9 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -649,6 +649,11 @@ public class OnlineAccountsManager { return true; } + // Require a valid nonce value + if (onlineAccountData.getNonce() == null || onlineAccountData.getNonce() < 0) { + return false; + } + int nonce = onlineAccountData.getNonce(); byte[] mempowBytes; From 38443583804ef992ffa17a515dd214169a3e19f1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Oct 2022 16:38:05 +0100 Subject: [PATCH 021/496] Mark a peer as misbehaved if it fails to respond with a usable block 3 times in a row. This should help to workaround deserialization and missing response issues. --- .../org/qortal/controller/Synchronizer.java | 17 ++++++++++++++++- .../java/org/qortal/data/network/PeerData.java | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 0fe9a56b..a7dd38ff 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -53,6 +53,9 @@ public class Synchronizer extends Thread { /** Maximum number of block signatures we ask from peer in one go */ private static final int MAXIMUM_REQUEST_SIZE = 200; // XXX move to Settings? + /** Maximum number of consecutive failed sync attempts before marking peer as misbehaved */ + private static final int MAX_CONSECUTIVE_FAILED_SYNC_ATTEMPTS = 3; + private static final long RECOVERY_MODE_TIMEOUT = 10 * 60 * 1000L; // ms @@ -1591,8 +1594,20 @@ public class Synchronizer extends Thread { Message getBlockMessage = new GetBlockMessage(signature); Message message = peer.getResponse(getBlockMessage); - if (message == null) + if (message == null) { + peer.getPeerData().incrementFailedSyncCount(); + if (peer.getPeerData().getFailedSyncCount() >= MAX_CONSECUTIVE_FAILED_SYNC_ATTEMPTS) { + // Several failed attempts, so mark peer as misbehaved + LOGGER.info("Marking peer {} as misbehaved due to {} failed sync attempts", peer, peer.getPeerData().getFailedSyncCount()); + Network.getInstance().peerMisbehaved(peer); + } return null; + } + + // Reset failed sync count now that we have a block response + // FUTURE: we could move this to the end of the sync process, but to reduce risk this can be done + // at a later stage. For now we are only defending against serialization errors or no responses. + peer.getPeerData().setFailedSyncCount(0); switch (message.getType()) { case BLOCK: { diff --git a/src/main/java/org/qortal/data/network/PeerData.java b/src/main/java/org/qortal/data/network/PeerData.java index 09982c00..471685dd 100644 --- a/src/main/java/org/qortal/data/network/PeerData.java +++ b/src/main/java/org/qortal/data/network/PeerData.java @@ -28,6 +28,9 @@ public class PeerData { private Long addedWhen; private String addedBy; + /** The number of consecutive times we failed to sync with this peer */ + private int failedSyncCount = 0; + // Constructors // necessary for JAXB serialization @@ -92,6 +95,18 @@ public class PeerData { return this.addedBy; } + public int getFailedSyncCount() { + return this.failedSyncCount; + } + + public void setFailedSyncCount(int failedSyncCount) { + this.failedSyncCount = failedSyncCount; + } + + public void incrementFailedSyncCount() { + this.failedSyncCount++; + } + // Pretty peerAddress getter for JAXB @XmlElement(name = "address") protected String getPrettyAddress() { From 0d9aafaf4e2f26077bfa2c751a1452db62439dbf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Oct 2022 17:03:10 +0100 Subject: [PATCH 022/496] Reduced log spam --- src/main/java/org/qortal/controller/Controller.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 93cbae92..12ad11a1 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1610,7 +1610,7 @@ public class Controller extends Thread { * having been passed upwards from Peer to Network to Controller. * Hence, these are NOT simple "here's my chain tip" broadcasts from other peers. */ - LOGGER.info("Discarding late {} message with ID {} from {}", message.getType().name(), message.getId(), peer); + LOGGER.debug("Discarding late {} message with ID {} from {}", message.getType().name(), message.getId(), peer); return; } From c2d02aead973b28ba61052c6acd056943f5c8f78 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 14 Oct 2022 18:44:25 +0100 Subject: [PATCH 023/496] Default minPeerVersion set to 3.6.3 --- 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 5b8d609e..40b2a247 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -204,7 +204,7 @@ public class Settings { private int maxRetries = 2; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.3.7"; + private String minPeerVersion = "3.6.3"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From 3c565638c11eecdf35f662df55fbb5051c7f836c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Oct 2022 18:58:13 +0100 Subject: [PATCH 024/496] onlineAccountsMemoryPoWTimestamp set to Sat Oct 22 2022 16:00:00 UTC --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index fad81ab5..e9f1500d 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -24,7 +24,7 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, - "onlineAccountsMemoryPoWTimestamp": 9999999999999, + "onlineAccountsMemoryPoWTimestamp": 1666454400000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From 57125a91cf756a5e9971d9c54d55cca82f774497 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 15 Oct 2022 18:59:42 +0100 Subject: [PATCH 025/496] Bump version to 3.6.4 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5f439cad..eb306420 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.6.3 + 3.6.4 jar true From 910191b07443dc4ea42d90f466e9f4ca8bb8c596 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Oct 2022 15:58:23 +0100 Subject: [PATCH 026/496] Added optional chatReference field to CHAT transactions. This allows one message to reference another, e.g. for replies, edits, and reactions. We can't use the existing reference field as this is used for encryption and generally points to the user's lastReference at the time of signing. "chatReference" is based on the "nameReference" field used in various name transactions, for similar purposes. This needs a feature trigger timestamp to activate, and that same timestamp will need to be used in the UI since that is responsible for building the chat transactions. --- .../org/qortal/api/resource/ChatResource.java | 6 ++++ .../api/websocket/ChatMessagesWebSocket.java | 2 ++ .../java/org/qortal/block/BlockChain.java | 7 ++++- .../data/transaction/ChatTransactionData.java | 9 +++++- .../org/qortal/repository/ChatRepository.java | 2 +- .../hsqldb/HSQLDBChatRepository.java | 7 ++++- .../hsqldb/HSQLDBDatabaseUpdates.java | 6 ++++ .../HSQLDBChatTransactionRepository.java | 5 +-- .../ChatTransactionTransformer.java | 31 +++++++++++++++++-- src/main/resources/blockchain.json | 3 +- .../test-chain-v2-block-timestamps.json | 3 +- .../test-chain-v2-disable-reference.json | 3 +- .../test-chain-v2-founder-rewards.json | 3 +- .../test-chain-v2-leftover-reward.json | 3 +- src/test/resources/test-chain-v2-minting.json | 3 +- .../test-chain-v2-qora-holder-extremes.json | 3 +- .../test-chain-v2-qora-holder-reduction.json | 3 +- .../resources/test-chain-v2-qora-holder.json | 3 +- .../test-chain-v2-reward-levels.json | 3 +- .../test-chain-v2-reward-scaling.json | 3 +- .../test-chain-v2-reward-shares.json | 3 +- src/test/resources/test-chain-v2.json | 3 +- 22 files changed, 93 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index ee2a8599..8c0f94c3 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -70,6 +70,7 @@ public class ChatResource { @QueryParam("txGroupId") Integer txGroupId, @QueryParam("involving") List involvingAddresses, @QueryParam("reference") String reference, + @QueryParam("chatreference") String chatReference, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { @@ -92,12 +93,17 @@ public class ChatResource { 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, involvingAddresses, limit, offset, reverse); } catch (DataException e) { diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index 9760b7f0..dbe36d9f 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -47,6 +47,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { txGroupId, null, null, + null, null, null, null); sendMessages(session, chatMessages); @@ -74,6 +75,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + null, involvingAddresses, null, null, null); diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 42692a18..d483f8d7 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -73,7 +73,8 @@ public class BlockChain { calcChainWeightTimestamp, transactionV5Timestamp, transactionV6Timestamp, - disableReferenceTimestamp; + disableReferenceTimestamp, + chatReferenceTimestamp; } // Custom transaction fees @@ -486,6 +487,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.disableReferenceTimestamp.name()).longValue(); } + public long getChatReferenceTimestamp() { + return this.featureTriggers.get(FeatureTrigger.chatReferenceTimestamp.name()).longValue(); + } + // More complex getters for aspects that change by height or timestamp diff --git a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java index 36ce6124..81bdb2b7 100644 --- a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java @@ -26,6 +26,8 @@ public class ChatTransactionData extends TransactionData { private String recipient; // can be null + private byte[] chatReference; // can be null + @Schema(description = "raw message data, possibly UTF8 text", example = "2yGEbwRFyhPZZckKA") private byte[] data; @@ -44,13 +46,14 @@ public class ChatTransactionData extends TransactionData { } public ChatTransactionData(BaseTransactionData baseTransactionData, - String sender, int nonce, String recipient, byte[] data, boolean isText, boolean isEncrypted) { + String sender, int nonce, String recipient, byte[] chatReference, byte[] data, boolean isText, boolean isEncrypted) { super(TransactionType.CHAT, baseTransactionData); this.senderPublicKey = baseTransactionData.creatorPublicKey; this.sender = sender; this.nonce = nonce; this.recipient = recipient; + this.chatReference = chatReference; this.data = data; this.isText = isText; this.isEncrypted = isEncrypted; @@ -78,6 +81,10 @@ public class ChatTransactionData extends TransactionData { return this.recipient; } + public byte[] getChatReference() { + return this.chatReference; + } + public byte[] 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 2ecd8a34..ebdc22e4 100644 --- a/src/main/java/org/qortal/repository/ChatRepository.java +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -14,7 +14,7 @@ public interface ChatRepository { * Expects EITHER non-null txGroupID OR non-null sender and recipient addresses. */ public List getMessagesMatchingCriteria(Long before, Long after, - Integer txGroupId, byte[] reference, List involving, + Integer txGroupId, byte[] reference, byte[] chatReferenceBytes, List involving, Integer limit, Integer offset, Boolean reverse) throws DataException; public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) 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 2f570686..d4c9d7e0 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -24,7 +24,7 @@ public class HSQLDBChatRepository implements ChatRepository { @Override public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] referenceBytes, - List involving, Integer limit, Integer offset, Boolean reverse) + byte[] chatReferenceBytes, List involving, Integer limit, Integer offset, Boolean reverse) throws DataException { // Check args meet expectations if ((txGroupId != null && involving != null && !involving.isEmpty()) @@ -62,6 +62,11 @@ public class HSQLDBChatRepository implements ChatRepository { bindParams.add(referenceBytes); } + if (chatReferenceBytes != null) { + whereClauses.add("chat_reference = ?"); + bindParams.add(chatReferenceBytes); + } + if (txGroupId != null) { whereClauses.add("tx_group_id = " + txGroupId); // int safe to use literally whereClauses.add("recipient IS NULL"); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 1174f5c8..53458484 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -975,6 +975,12 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN receiving_account_info SET DATA TYPE VARBINARY(128)"); break; + case 44: + // Add a chat reference, to allow one message to reference another, and for this to be easily + // searchable. Null values are allowed as most transactions won't have a reference. + stmt.execute("ALTER TABLE ChatTransactions ADD chat_reference Signature"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java index 449922f4..0dd3c0e3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java @@ -17,7 +17,7 @@ public class HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository } TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { - String sql = "SELECT sender, nonce, recipient, is_text, is_encrypted, data FROM ChatTransactions WHERE signature = ?"; + String sql = "SELECT sender, nonce, recipient, is_text, is_encrypted, data, chat_reference FROM ChatTransactions WHERE signature = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { if (resultSet == null) @@ -29,8 +29,9 @@ public class HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository boolean isText = resultSet.getBoolean(4); boolean isEncrypted = resultSet.getBoolean(5); byte[] data = resultSet.getBytes(6); + byte[] chatReference = resultSet.getBytes(7); - return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, data, isText, isEncrypted); + return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, chatReference, data, isText, isEncrypted); } catch (SQLException e) { throw new DataException("Unable to fetch chat transaction from repository", e); } diff --git a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java index 69a9ef5b..d482dacd 100644 --- a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java @@ -4,6 +4,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; +import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.ChatTransactionData; @@ -22,11 +23,13 @@ public class ChatTransactionTransformer extends TransactionTransformer { private static final int NONCE_LENGTH = INT_LENGTH; private static final int HAS_RECIPIENT_LENGTH = BOOLEAN_LENGTH; private static final int RECIPIENT_LENGTH = ADDRESS_LENGTH; + private static final int HAS_CHAT_REFERENCE_LENGTH = BOOLEAN_LENGTH; + private static final int CHAT_REFERENCE_LENGTH = SIGNATURE_LENGTH; private static final int DATA_SIZE_LENGTH = INT_LENGTH; private static final int IS_TEXT_LENGTH = BOOLEAN_LENGTH; private static final int IS_ENCRYPTED_LENGTH = BOOLEAN_LENGTH; - private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; + private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + HAS_CHAT_REFERENCE_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; protected static final TransactionLayout layout; @@ -63,6 +66,17 @@ public class ChatTransactionTransformer extends TransactionTransformer { boolean hasRecipient = byteBuffer.get() != 0; String recipient = hasRecipient ? Serialization.deserializeAddress(byteBuffer) : null; + byte[] chatReference = null; + + if (timestamp >= BlockChain.getInstance().getChatReferenceTimestamp()) { + boolean hasChatReference = byteBuffer.get() != 0; + + if (hasChatReference) { + chatReference = new byte[CHAT_REFERENCE_LENGTH]; + byteBuffer.get(chatReference); + } + } + int dataSize = byteBuffer.getInt(); // Don't allow invalid dataSize here to avoid run-time issues if (dataSize > ChatTransaction.MAX_DATA_SIZE) @@ -83,7 +97,7 @@ public class ChatTransactionTransformer extends TransactionTransformer { BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature); String sender = Crypto.toAddress(senderPublicKey); - return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, data, isText, isEncrypted); + return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, chatReference, data, isText, isEncrypted); } public static int getDataLength(TransactionData transactionData) { @@ -94,6 +108,9 @@ public class ChatTransactionTransformer extends TransactionTransformer { if (chatTransactionData.getRecipient() != null) dataLength += RECIPIENT_LENGTH; + if (chatTransactionData.getChatReference() != null) + dataLength += CHAT_REFERENCE_LENGTH; + return dataLength; } @@ -114,6 +131,16 @@ public class ChatTransactionTransformer extends TransactionTransformer { bytes.write((byte) 0); } + if (transactionData.getTimestamp() >= BlockChain.getInstance().getChatReferenceTimestamp()) { + // Include chat reference if it's not null + if (chatTransactionData.getChatReference() != null) { + bytes.write((byte) 1); + bytes.write(chatTransactionData.getChatReference()); + } else { + bytes.write((byte) 0); + } + } + bytes.write(Ints.toByteArray(chatTransactionData.getData().length)); bytes.write(chatTransactionData.getData()); diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index e9f1500d..2255c0a8 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -80,7 +80,8 @@ "calcChainWeightTimestamp": 1620579600000, "transactionV5Timestamp": 1642176000000, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 1655222400000 + "disableReferenceTimestamp": 1655222400000, + "chatReferenceTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 37224684..028519f8 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -69,7 +69,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index 7ea0b86d..541ce779 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -72,7 +72,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 0 + "disableReferenceTimestamp": 0, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 85a50f83..7392d5ae 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index ebc3ccfa..ed46cd56 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index cc91f993..a705016c 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 085d1dbf..880af61b 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 75858057..27451201 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "aggregateSignatureTimestamp": 0 + "aggregateSignatureTimestamp": 0, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 0706c5bb..fbc58d80 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index b3644d6b..0f7adf6f 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 1c68dda4..802ad8fe 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 10d2aab3..2becc875 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -73,7 +73,8 @@ "newConsensusTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 5f439602..c3b740ff 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, From a4759a0ef4f169b6934240c562bda218bfc285a6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 12:43:40 +0100 Subject: [PATCH 027/496] Re-ordered chat transaction transformation, to simplify UI code. New additions are now at the end of the data bytes. --- .../ChatTransactionTransformer.java | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java index d482dacd..b966ed2b 100644 --- a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java @@ -29,7 +29,7 @@ public class ChatTransactionTransformer extends TransactionTransformer { private static final int IS_TEXT_LENGTH = BOOLEAN_LENGTH; private static final int IS_ENCRYPTED_LENGTH = BOOLEAN_LENGTH; - private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + HAS_CHAT_REFERENCE_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; + private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH + HAS_CHAT_REFERENCE_LENGTH; protected static final TransactionLayout layout; @@ -66,17 +66,6 @@ public class ChatTransactionTransformer extends TransactionTransformer { boolean hasRecipient = byteBuffer.get() != 0; String recipient = hasRecipient ? Serialization.deserializeAddress(byteBuffer) : null; - byte[] chatReference = null; - - if (timestamp >= BlockChain.getInstance().getChatReferenceTimestamp()) { - boolean hasChatReference = byteBuffer.get() != 0; - - if (hasChatReference) { - chatReference = new byte[CHAT_REFERENCE_LENGTH]; - byteBuffer.get(chatReference); - } - } - int dataSize = byteBuffer.getInt(); // Don't allow invalid dataSize here to avoid run-time issues if (dataSize > ChatTransaction.MAX_DATA_SIZE) @@ -91,6 +80,17 @@ public class ChatTransactionTransformer extends TransactionTransformer { long fee = byteBuffer.getLong(); + byte[] chatReference = null; + + if (timestamp >= BlockChain.getInstance().getChatReferenceTimestamp()) { + boolean hasChatReference = byteBuffer.get() != 0; + + if (hasChatReference) { + chatReference = new byte[CHAT_REFERENCE_LENGTH]; + byteBuffer.get(chatReference); + } + } + byte[] signature = new byte[SIGNATURE_LENGTH]; byteBuffer.get(signature); @@ -131,16 +131,6 @@ public class ChatTransactionTransformer extends TransactionTransformer { bytes.write((byte) 0); } - if (transactionData.getTimestamp() >= BlockChain.getInstance().getChatReferenceTimestamp()) { - // Include chat reference if it's not null - if (chatTransactionData.getChatReference() != null) { - bytes.write((byte) 1); - bytes.write(chatTransactionData.getChatReference()); - } else { - bytes.write((byte) 0); - } - } - bytes.write(Ints.toByteArray(chatTransactionData.getData().length)); bytes.write(chatTransactionData.getData()); @@ -151,6 +141,16 @@ public class ChatTransactionTransformer extends TransactionTransformer { bytes.write(Longs.toByteArray(chatTransactionData.getFee())); + if (transactionData.getTimestamp() >= BlockChain.getInstance().getChatReferenceTimestamp()) { + // Include chat reference if it's not null + if (chatTransactionData.getChatReference() != null) { + bytes.write((byte) 1); + bytes.write(chatTransactionData.getChatReference()); + } else { + bytes.write((byte) 0); + } + } + if (chatTransactionData.getSignature() != null) bytes.write(chatTransactionData.getSignature()); From 23a5c5f9b495bc74c8af2c42f402d328bc6190e8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 12:50:28 +0100 Subject: [PATCH 028/496] Fixed bug in original commit - we need to save the chat reference to the db. --- .../hsqldb/transaction/HSQLDBChatTransactionRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java index 0dd3c0e3..79e798a9 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java @@ -46,7 +46,7 @@ public class HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository saveHelper.bind("signature", chatTransactionData.getSignature()).bind("nonce", chatTransactionData.getNonce()) .bind("sender", chatTransactionData.getSender()).bind("recipient", chatTransactionData.getRecipient()) .bind("is_text", chatTransactionData.getIsText()).bind("is_encrypted", chatTransactionData.getIsEncrypted()) - .bind("data", chatTransactionData.getData()); + .bind("data", chatTransactionData.getData()).bind("chat_reference", chatTransactionData.getChatReference()); try { saveHelper.execute(this.repository); From 6f27d3798c6cc9f1071d981f969517bdd0cae356 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 19:18:41 +0100 Subject: [PATCH 029/496] Improved online accounts processing, to avoid creating keys in the map before validation. --- .../java/org/qortal/controller/OnlineAccountsManager.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 7b60f0d9..fe6e3078 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -192,8 +192,8 @@ public class OnlineAccountsManager { return; // Skip this account if it's already validated - Set onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountData.getTimestamp(), k -> ConcurrentHashMap.newKeySet()); - if (onlineAccounts.contains(onlineAccountData)) { + Set onlineAccounts = this.currentOnlineAccounts.get(onlineAccountData.getTimestamp()); + if (onlineAccounts != null && onlineAccounts.contains(onlineAccountData)) { // We have already validated this online account onlineAccountsImportQueue.remove(onlineAccountData); continue; From 72985b1fc6a11f2a15d36861af5eaa85cf78a9f6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 19:24:54 +0100 Subject: [PATCH 030/496] Reduce log spam, especially around the time of node startup before online accounts have been retrieved. We expect a "Couldn't build a to-be-minted block" log on every startup due to trying to mint before having any accounts. This one has moved from error to info level because error logs can be quite intrusive when using an IDE. --- src/main/java/org/qortal/controller/BlockMinter.java | 2 +- src/main/java/org/qortal/controller/OnlineAccountsManager.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 100e74db..0734d4e9 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -244,7 +244,7 @@ public class BlockMinter extends Thread { Block newBlock = Block.mint(repository, previousBlockData, mintingAccount); if (newBlock == null) { // For some reason we can't mint right now - moderatedLog(() -> LOGGER.error("Couldn't build a to-be-minted block")); + moderatedLog(() -> LOGGER.info("Couldn't build a to-be-minted block")); continue; } diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index fe6e3078..c4eadd44 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -702,7 +702,7 @@ public class OnlineAccountsManager { */ // Block::mint() - only wants online accounts with (online) timestamp that matches block's (online) timestamp so they can be added to new block public List getOnlineAccounts(long onlineTimestamp) { - LOGGER.info(String.format("caller's timestamp: %d, our timestamps: %s", onlineTimestamp, String.join(", ", this.currentOnlineAccounts.keySet().stream().map(l -> Long.toString(l)).collect(Collectors.joining(", "))))); + LOGGER.debug(String.format("caller's timestamp: %d, our timestamps: %s", onlineTimestamp, String.join(", ", this.currentOnlineAccounts.keySet().stream().map(l -> Long.toString(l)).collect(Collectors.joining(", "))))); return new ArrayList<>(Set.copyOf(this.currentOnlineAccounts.getOrDefault(onlineTimestamp, Collections.emptySet()))); } From e45ad37eb57c26445478937444833f2bd22b0b60 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 19:30:08 +0100 Subject: [PATCH 031/496] Fixed bug which could prevent invalid accounts being removed from the queue until the next valid one is added. --- src/main/java/org/qortal/controller/OnlineAccountsManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index c4eadd44..487a5253 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -214,8 +214,8 @@ public class OnlineAccountsManager { if (!onlineAccountsToAdd.isEmpty()) { LOGGER.debug("Merging {} validated online accounts from import queue", onlineAccountsToAdd.size()); addAccounts(onlineAccountsToAdd); - onlineAccountsImportQueue.removeAll(onlineAccountsToRemove); } + onlineAccountsImportQueue.removeAll(onlineAccountsToRemove); } } From b37aa749c6bc518e33d7a2917244c5ef2d4b3f99 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 19:34:24 +0100 Subject: [PATCH 032/496] Removed onlineAccountsMemPoWEnabled setting as it's no longer needed. --- .../java/org/qortal/controller/OnlineAccountsManager.java | 4 ++-- src/main/java/org/qortal/settings/Settings.java | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 487a5253..45b47f5d 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -600,7 +600,7 @@ public class OnlineAccountsManager { // MemoryPoW private boolean isMemoryPoWActive(Long timestamp) { - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp() || Settings.getInstance().isOnlineAccountsMemPoWEnabled()) { + if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { return true; } return false; @@ -617,7 +617,7 @@ public class OnlineAccountsManager { private Integer computeMemoryPoW(byte[] bytes, byte[] publicKey, long onlineAccountsTimestamp) throws TimeoutException { if (!isMemoryPoWActive(NTP.getTime())) { - LOGGER.info("Mempow start timestamp not yet reached, and onlineAccountsMemPoWEnabled not enabled in settings"); + LOGGER.info("Mempow start timestamp not yet reached"); return null; } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 40b2a247..2e57142e 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -290,10 +290,6 @@ public class Settings { /** Additional offset added to values returned by NTP.getTime() */ private Long testNtpOffset = null; - // Online accounts - - /** Whether to opt-in to mempow computations for online accounts, ahead of general release */ - private boolean onlineAccountsMemPoWEnabled = false; /* Foreign chains */ @@ -800,10 +796,6 @@ public class Settings { return this.testNtpOffset; } - public boolean isOnlineAccountsMemPoWEnabled() { - return this.onlineAccountsMemPoWEnabled; - } - public long getRepositoryBackupInterval() { return this.repositoryBackupInterval; } From 1d5497e484849cf63f7662d5cc129ac4b23f4469 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Oct 2022 14:13:38 +0100 Subject: [PATCH 033/496] Modifications to support a single node testnet: - Added "singleNodeTestnet" setting, allowing for fast and consecutive block minting, and no requirement for a minimum number of peers. - Added "recoveryModeTimeout" setting (previously hardcoded in Synchronizer). - Updated testnets documentation to include new settings and a quick start guide. - Added "generic" minting account that can be used in testnets (not functional on mainnet), to simplify the process for new devs. --- TestNets.md | 44 +++++++++++++++---- .../org/qortal/controller/BlockMinter.java | 10 +++-- .../org/qortal/controller/Controller.java | 4 ++ .../org/qortal/controller/Synchronizer.java | 7 ++- .../java/org/qortal/settings/Settings.java | 18 +++++++- 5 files changed, 67 insertions(+), 16 deletions(-) diff --git a/TestNets.md b/TestNets.md index e475e593..b4b9feed 100644 --- a/TestNets.md +++ b/TestNets.md @@ -52,14 +52,13 @@ ## Single-node testnet -A single-node testnet is possible with code modifications, for basic testing, or to more easily start a new testnet. -To do so, follow these steps: -- Comment out the `if (mintedLastBlock) { }` conditional in BlockMinter.java -- Comment out the `minBlockchainPeers` validation in Settings.validate() -- Set `minBlockchainPeers` to 0 in settings.json -- Set `Synchronizer.RECOVERY_MODE_TIMEOUT` to `0` -- All other steps should remain the same. Only a single reward share key is needed. -- Remember to put these values back after introducing other nodes +A single-node testnet is possible with an additional settings, or to more easily start a new testnet. +Just add this setting: +``` +"singleNodeTestnet": true +``` +This will automatically allow multiple consecutive blocks to be minted, as well as setting minBlockchainPeers to 0. +Remember to put these values back after introducing other nodes ## Fixed network @@ -93,3 +92,32 @@ Your options are: - `qort` tool, but prepend with one-time shell variable: `BASE_URL=some-node-hostname-or-ip:port qort ......` - `peer-heights`, but use `-t` option, or `BASE_URL` shell variable as above +## Example settings-test.json +``` +{ + "isTestNet": true, + "bitcoinNet": "TEST3", + "repositoryPath": "db-testnet", + "blockchainConfig": "testchain.json", + "minBlockchainPeers": 1, + "apiDocumentationEnabled": true, + "apiRestricted": false, + "bootstrap": false, + "maxPeerConnectionTime": 999999999, + "localAuthBypassEnabled": true, + "singleNodeTestnet": true, + "recoveryModeTimeout": 0 +} +``` + +## 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. +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 },` +4. Start the node, passing in settings-test.json, e.g: `java -jar qortal.jar settings-test.json` +5. Once started, add the corresponding minting key to the node: +`curl -X POST "http://localhost:62391/admin/mintingaccounts" -d "F48mYJycFgRdqtc58kiovwbcJgVukjzRE4qRRtRsK9ix"` +6. Alternatively you can use your own minting account instead of the generic one above. +7. After a short while, blocks should be minted from the genesis timestamp until the current time. \ No newline at end of file diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 0734d4e9..7e3b4b9e 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -93,6 +93,8 @@ public class BlockMinter extends Thread { List newBlocks = new ArrayList<>(); + final boolean isSingleNodeTestnet = Settings.getInstance().isSingleNodeTestnet(); + try (final Repository repository = RepositoryManager.getRepository()) { // Going to need this a lot... BlockRepository blockRepository = repository.getBlockRepository(); @@ -111,8 +113,9 @@ public class BlockMinter extends Thread { // Free up any repository locks repository.discardChanges(); - // Sleep for a while - Thread.sleep(1000); + // Sleep for a while. + // It's faster on single node testnets, to allow lots of blocks to be minted quickly. + Thread.sleep(isSingleNodeTestnet ? 50 : 1000); isMintingPossible = false; @@ -223,9 +226,10 @@ public class BlockMinter extends Thread { List newBlocksMintingAccounts = mintingAccountsData.stream().map(accountData -> new PrivateKeyAccount(repository, accountData.getPrivateKey())).collect(Collectors.toList()); // We might need to sit the next block out, if one of our minting accounts signed the previous one + // Skip this check for single node testnets, since they definitely need to mint every block byte[] previousBlockMinter = previousBlockData.getMinterPublicKey(); boolean mintedLastBlock = mintingAccountsData.stream().anyMatch(mintingAccount -> Arrays.equals(mintingAccount.getPublicKey(), previousBlockMinter)); - if (mintedLastBlock) { + if (mintedLastBlock && !isSingleNodeTestnet) { LOGGER.trace(String.format("One of our keys signed the last block, so we won't sign the next one")); continue; } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 12ad11a1..6fe6a159 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1872,6 +1872,10 @@ public class Controller extends Thread { if (latestBlockData == null || latestBlockData.getTimestamp() < minLatestBlockTimestamp) return false; + if (Settings.getInstance().isSingleNodeTestnet()) + // Single node testnets won't have peers, so we can assume up to date from this point + return true; + // Needs a mutable copy of the unmodifiableList List peers = new ArrayList<>(Network.getInstance().getImmutableHandshakedPeers()); if (peers == null) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index a7dd38ff..6f2a0fe1 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -56,8 +56,6 @@ public class Synchronizer extends Thread { /** Maximum number of consecutive failed sync attempts before marking peer as misbehaved */ private static final int MAX_CONSECUTIVE_FAILED_SYNC_ATTEMPTS = 3; - private static final long RECOVERY_MODE_TIMEOUT = 10 * 60 * 1000L; // ms - private boolean running; @@ -399,9 +397,10 @@ public class Synchronizer extends Thread { timePeersLastAvailable = NTP.getTime(); // If enough time has passed, enter recovery mode, which lifts some restrictions on who we can sync with and when we can mint - if (NTP.getTime() - timePeersLastAvailable > RECOVERY_MODE_TIMEOUT) { + long recoveryModeTimeout = Settings.getInstance().getRecoveryModeTimeout(); + if (NTP.getTime() - timePeersLastAvailable > recoveryModeTimeout) { if (recoveryMode == false) { - LOGGER.info(String.format("Peers have been unavailable for %d minutes. Entering recovery mode...", RECOVERY_MODE_TIMEOUT/60/1000)); + LOGGER.info(String.format("Peers have been unavailable for %d minutes. Entering recovery mode...", recoveryModeTimeout/60/1000)); recoveryMode = true; } } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 2e57142e..acfd0e78 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -184,6 +184,8 @@ public class Settings { // Peer-to-peer related private boolean isTestNet = false; + /** Single node testnet mode */ + private boolean singleNodeTestnet = false; /** Port number for inbound peer-to-peer connections. */ private Integer listenPort; /** Whether to attempt to open the listen port via UPnP */ @@ -203,6 +205,9 @@ public class Settings { /** Maximum number of retry attempts if a peer fails to respond with the requested data */ private int maxRetries = 2; + /** The number of seconds of no activity before recovery mode begins */ + public long recoveryModeTimeout = 10 * 60 * 1000L; + /** Minimum peer version number required in order to sync with them */ private String minPeerVersion = "3.6.3"; /** Whether to allow connections with peers below minPeerVersion @@ -486,7 +491,7 @@ public class Settings { private void validate() { // Validation goes here - if (this.minBlockchainPeers < 1) + if (this.minBlockchainPeers < 1 && !singleNodeTestnet) throwValidationError("minBlockchainPeers must be at least 1"); if (this.apiKey != null && this.apiKey.trim().length() < 8) @@ -643,6 +648,10 @@ public class Settings { return this.isTestNet; } + public boolean isSingleNodeTestnet() { + return this.singleNodeTestnet; + } + public int getListenPort() { if (this.listenPort != null) return this.listenPort; @@ -663,6 +672,9 @@ public class Settings { } public int getMinBlockchainPeers() { + if (singleNodeTestnet) + return 0; + return this.minBlockchainPeers; } @@ -688,6 +700,10 @@ public class Settings { public int getMaxRetries() { return this.maxRetries; } + public long getRecoveryModeTimeout() { + return recoveryModeTimeout; + } + public String getMinPeerVersion() { return this.minPeerVersion; } public boolean getAllowConnectionsWithOlderPeerVersions() { return this.allowConnectionsWithOlderPeerVersions; } From b3273ff01a8652e536507781eee64eff100c6b8a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Oct 2022 16:47:42 +0100 Subject: [PATCH 034/496] Removed all mempow feature trigger conditionals. We no longer need all the code complexity, now that 24 hours have passed since activation. We don't validate online accounts beyond 12 hours, and the data is trimmed after 24 hours. --- src/main/java/org/qortal/block/Block.java | 102 ++++++++---------- .../java/org/qortal/block/BlockChain.java | 8 -- .../controller/OnlineAccountsManager.java | 62 +++-------- .../transform/block/BlockTransformer.java | 12 +-- src/main/resources/blockchain.json | 1 - .../org/qortal/test/common/AccountUtils.java | 4 +- 6 files changed, 62 insertions(+), 127 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 07c7db6f..55c13b36 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -366,14 +366,9 @@ public class Block { long timestamp = calcTimestamp(parentBlockData, minter.getPublicKey(), minterLevel); long onlineAccountsTimestamp = OnlineAccountsManager.getCurrentOnlineAccountTimestamp(); - // Fetch our list of online accounts + // Fetch our list of online accounts, removing any that are missing a nonce List onlineAccounts = OnlineAccountsManager.getInstance().getOnlineAccounts(onlineAccountsTimestamp); - - // If mempow is active, remove any legacy accounts that are missing a nonce - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); - } - + onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); if (onlineAccounts.isEmpty()) { LOGGER.debug("No online accounts - not even our own?"); return null; @@ -412,29 +407,27 @@ public class Block { // Aggregated, single signature byte[] onlineAccountsSignatures = Qortal25519Extras.aggregateSignatures(signaturesToAggregate); - // Add nonces to the end of the online accounts signatures if mempow is active - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - try { - // Create ordered list of nonce values - List nonces = new ArrayList<>(); - for (int i = 0; i < onlineAccountsCount; ++i) { - Integer accountIndex = accountIndexes.get(i); - OnlineAccountData onlineAccountData = indexedOnlineAccounts.get(accountIndex); - nonces.add(onlineAccountData.getNonce()); - } - - // Encode the nonces to a byte array - byte[] encodedNonces = BlockTransformer.encodeOnlineAccountNonces(nonces); - - // Append the encoded nonces to the encoded online account signatures - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - outputStream.write(onlineAccountsSignatures); - outputStream.write(encodedNonces); - onlineAccountsSignatures = outputStream.toByteArray(); - } - catch (TransformationException | IOException e) { - return null; + // Add nonces to the end of the online accounts signatures + try { + // Create ordered list of nonce values + List nonces = new ArrayList<>(); + for (int i = 0; i < onlineAccountsCount; ++i) { + Integer accountIndex = accountIndexes.get(i); + OnlineAccountData onlineAccountData = indexedOnlineAccounts.get(accountIndex); + nonces.add(onlineAccountData.getNonce()); } + + // Encode the nonces to a byte array + byte[] encodedNonces = BlockTransformer.encodeOnlineAccountNonces(nonces); + + // Append the encoded nonces to the encoded online account signatures + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + outputStream.write(onlineAccountsSignatures); + outputStream.write(encodedNonces); + onlineAccountsSignatures = outputStream.toByteArray(); + } + catch (TransformationException | IOException e) { + return null; } byte[] minterSignature = minter.sign(BlockTransformer.getBytesForMinterSignature(parentBlockData, @@ -1047,14 +1040,9 @@ public class Block { final int signaturesLength = Transformer.SIGNATURE_LENGTH; final int noncesLength = onlineRewardShares.size() * Transformer.INT_LENGTH; - if (this.blockData.getTimestamp() >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - // We expect nonces to be appended to the online accounts signatures - if (this.blockData.getOnlineAccountsSignatures().length != signaturesLength + noncesLength) - return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED; - } else { - if (this.blockData.getOnlineAccountsSignatures().length != signaturesLength) - return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED; - } + // We expect nonces to be appended to the online accounts signatures + if (this.blockData.getOnlineAccountsSignatures().length != signaturesLength + noncesLength) + return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED; // Check signatures long onlineTimestamp = this.blockData.getOnlineAccountsTimestamp(); @@ -1063,32 +1051,30 @@ public class Block { byte[] encodedOnlineAccountSignatures = this.blockData.getOnlineAccountsSignatures(); // Split online account signatures into signature(s) + nonces, then validate the nonces - if (this.blockData.getTimestamp() >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - byte[] extractedSignatures = BlockTransformer.extract(encodedOnlineAccountSignatures, 0, signaturesLength); - byte[] extractedNonces = BlockTransformer.extract(encodedOnlineAccountSignatures, signaturesLength, onlineRewardShares.size() * Transformer.INT_LENGTH); - encodedOnlineAccountSignatures = extractedSignatures; + byte[] extractedSignatures = BlockTransformer.extract(encodedOnlineAccountSignatures, 0, signaturesLength); + byte[] extractedNonces = BlockTransformer.extract(encodedOnlineAccountSignatures, signaturesLength, onlineRewardShares.size() * Transformer.INT_LENGTH); + encodedOnlineAccountSignatures = extractedSignatures; - List nonces = BlockTransformer.decodeOnlineAccountNonces(extractedNonces); + List nonces = BlockTransformer.decodeOnlineAccountNonces(extractedNonces); - // Build block's view of online accounts (without signatures, as we don't need them here) - Set onlineAccounts = new HashSet<>(); - for (int i = 0; i < onlineRewardShares.size(); ++i) { - Integer nonce = nonces.get(i); - byte[] publicKey = onlineRewardShares.get(i).getRewardSharePublicKey(); + // Build block's view of online accounts (without signatures, as we don't need them here) + Set onlineAccounts = new HashSet<>(); + for (int i = 0; i < onlineRewardShares.size(); ++i) { + Integer nonce = nonces.get(i); + byte[] publicKey = onlineRewardShares.get(i).getRewardSharePublicKey(); - OnlineAccountData onlineAccountData = new OnlineAccountData(onlineTimestamp, null, publicKey, nonce); - onlineAccounts.add(onlineAccountData); - } - - // Remove those already validated & cached by online accounts manager - no need to re-validate them - OnlineAccountsManager.getInstance().removeKnown(onlineAccounts, onlineTimestamp); - - // Validate the rest - for (OnlineAccountData onlineAccount : onlineAccounts) - if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, this.blockData.getTimestamp())) - return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; + OnlineAccountData onlineAccountData = new OnlineAccountData(onlineTimestamp, null, publicKey, nonce); + onlineAccounts.add(onlineAccountData); } + // Remove those already validated & cached by online accounts manager - no need to re-validate them + OnlineAccountsManager.getInstance().removeKnown(onlineAccounts, onlineTimestamp); + + // Validate the rest + for (OnlineAccountData onlineAccount : onlineAccounts) + if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, this.blockData.getTimestamp())) + return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; + // Extract online accounts' timestamp signatures from block data. Only one signature if aggregated. List onlineAccountsSignatures = BlockTransformer.decodeTimestampSignatures(encodedOnlineAccountSignatures); diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 42692a18..826fdd78 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -195,10 +195,6 @@ public class BlockChain { * featureTriggers because unit tests need to set this value via Reflection. */ private long onlineAccountsModulusV2Timestamp; - /** Feature trigger timestamp for online accounts mempow verification. Can't use featureTriggers - * because unit tests need to set this value via Reflection. */ - private long onlineAccountsMemoryPoWTimestamp; - /** Max reward shares by block height */ public static class MaxRewardSharesByTimestamp { public long timestamp; @@ -359,10 +355,6 @@ public class BlockChain { return this.onlineAccountsModulusV2Timestamp; } - public long getOnlineAccountsMemoryPoWTimestamp() { - return this.onlineAccountsMemoryPoWTimestamp; - } - /** Returns true if approval-needing transaction types require a txGroupId other than NO_GROUP. */ public boolean getRequireGroupForApproval() { return this.requireGroupForApproval; diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 45b47f5d..0e24bdfc 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -20,7 +20,6 @@ import org.qortal.network.message.*; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; -import org.qortal.settings.Settings; import org.qortal.utils.Base58; import org.qortal.utils.NTP; import org.qortal.utils.NamedThreadFactory; @@ -156,7 +155,6 @@ public class OnlineAccountsManager { return; byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); - final boolean mempowActive = onlineAccountsTimestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); Set replacementAccounts = new HashSet<>(); for (PrivateKeyAccount onlineAccount : onlineAccounts) { @@ -165,7 +163,7 @@ public class OnlineAccountsManager { byte[] signature = Qortal25519Extras.signForAggregation(onlineAccount.getPrivateKey(), timestampBytes); byte[] publicKey = onlineAccount.getPublicKey(); - Integer nonce = mempowActive ? new Random().nextInt(500000) : null; + Integer nonce = new Random().nextInt(500000); OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); replacementAccounts.add(ourOnlineAccountData); @@ -321,13 +319,10 @@ public class OnlineAccountsManager { return false; } - // Validate mempow if feature trigger is active (or if online account's timestamp is past the trigger timestamp) - long memoryPoWStartTimestamp = BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); - if (now >= memoryPoWStartTimestamp || onlineAccountTimestamp >= memoryPoWStartTimestamp) { - if (!getInstance().verifyMemoryPoW(onlineAccountData, now)) { - LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); - return false; - } + // Validate mempow + if (!getInstance().verifyMemoryPoW(onlineAccountData, now)) { + LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); + return false; } return true; @@ -471,12 +466,10 @@ public class OnlineAccountsManager { // 'next' timestamp (prioritize this as it's the most important, if mempow active) final long nextOnlineAccountsTimestamp = toOnlineAccountTimestamp(now) + getOnlineTimestampModulus(); - if (isMemoryPoWActive(now)) { - boolean success = computeOurAccountsForTimestamp(nextOnlineAccountsTimestamp); - if (!success) { - // We didn't compute the required nonce value(s), and so can't proceed until they have been retried - return; - } + boolean success = computeOurAccountsForTimestamp(nextOnlineAccountsTimestamp); + if (!success) { + // We didn't compute the required nonce value(s), and so can't proceed until they have been retried + return; } // 'current' timestamp @@ -553,21 +546,15 @@ public class OnlineAccountsManager { // Compute nonce Integer nonce; - if (isMemoryPoWActive(NTP.getTime())) { - 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))); + try { + nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp); + if (nonce == null) { + // A nonce is required return false; } - } - else { - // Send -1 if we haven't computed a nonce due to feature trigger timestamp - nonce = -1; + } catch (TimeoutException e) { + LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey))); + return false; } byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes); @@ -599,12 +586,6 @@ public class OnlineAccountsManager { // MemoryPoW - private boolean isMemoryPoWActive(Long timestamp) { - if (timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - return true; - } - return false; - } private byte[] getMemoryPoWBytes(byte[] publicKey, long onlineAccountsTimestamp) throws IOException { byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp); @@ -616,11 +597,6 @@ public class OnlineAccountsManager { } private Integer computeMemoryPoW(byte[] bytes, byte[] publicKey, long onlineAccountsTimestamp) throws TimeoutException { - if (!isMemoryPoWActive(NTP.getTime())) { - LOGGER.info("Mempow start timestamp not yet reached"); - return null; - } - LOGGER.info(String.format("Computing nonce for account %.8s and timestamp %d...", Base58.encode(publicKey), onlineAccountsTimestamp)); // Calculate the time until the next online timestamp and use it as a timeout when computing the nonce @@ -643,12 +619,6 @@ public class OnlineAccountsManager { } public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData, Long timestamp) { - long memoryPoWStartTimestamp = BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); - if (timestamp < memoryPoWStartTimestamp && onlineAccountData.getTimestamp() < memoryPoWStartTimestamp) { - // Not active yet, so treat it as valid - return true; - } - // Require a valid nonce value if (onlineAccountData.getNonce() == null || onlineAccountData.getNonce() < 0) { return false; diff --git a/src/main/java/org/qortal/transform/block/BlockTransformer.java b/src/main/java/org/qortal/transform/block/BlockTransformer.java index 9e02a6f5..c97aa090 100644 --- a/src/main/java/org/qortal/transform/block/BlockTransformer.java +++ b/src/main/java/org/qortal/transform/block/BlockTransformer.java @@ -235,7 +235,7 @@ public class BlockTransformer extends Transformer { // Online accounts timestamp is only present if there are also signatures onlineAccountsTimestamp = byteBuffer.getLong(); - final int signaturesByteLength = getOnlineAccountSignaturesLength(onlineAccountsSignaturesCount, onlineAccountsCount, timestamp); + final int signaturesByteLength = (onlineAccountsSignaturesCount * Transformer.SIGNATURE_LENGTH) + (onlineAccountsCount * INT_LENGTH); if (signaturesByteLength > BlockChain.getInstance().getMaxBlockSize()) throw new TransformationException("Byte data too long for online accounts signatures"); @@ -511,16 +511,6 @@ public class BlockTransformer extends Transformer { return nonces; } - public static int getOnlineAccountSignaturesLength(int onlineAccountsSignaturesCount, int onlineAccountCount, long blockTimestamp) { - if (blockTimestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp()) { - // Once mempow is active, we expect the online account signatures to be appended with the nonce values - return (onlineAccountsSignaturesCount * Transformer.SIGNATURE_LENGTH) + (onlineAccountCount * INT_LENGTH); - } - else { - // Before mempow, only the online account signatures were included (which will likely be a single signature) - return onlineAccountsSignaturesCount * Transformer.SIGNATURE_LENGTH; - } - } public static byte[] extract(byte[] input, int pos, int length) { byte[] output = new byte[length]; diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index e9f1500d..893add5e 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -24,7 +24,6 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, - "onlineAccountsMemoryPoWTimestamp": 1666454400000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, diff --git a/src/test/java/org/qortal/test/common/AccountUtils.java b/src/test/java/org/qortal/test/common/AccountUtils.java index 0d0b6d6a..0d8baae2 100644 --- a/src/test/java/org/qortal/test/common/AccountUtils.java +++ b/src/test/java/org/qortal/test/common/AccountUtils.java @@ -124,8 +124,6 @@ public class AccountUtils { long timestamp = System.currentTimeMillis(); byte[] timestampBytes = Longs.toByteArray(timestamp); - final boolean mempowActive = timestamp >= BlockChain.getInstance().getOnlineAccountsMemoryPoWTimestamp(); - for (int a = 0; a < numAccounts; ++a) { byte[] privateKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; SECURE_RANDOM.nextBytes(privateKey); @@ -135,7 +133,7 @@ public class AccountUtils { byte[] signature = signForAggregation(privateKey, timestampBytes); - Integer nonce = mempowActive ? new Random().nextInt(500000) : null; + Integer nonce = new Random().nextInt(500000); onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey, nonce)); } From f83d4bac7b054b73f5ec42a8fbc6e07c8561e74b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Oct 2022 17:01:58 +0100 Subject: [PATCH 035/496] Reduced online accounts mempow difficulty to 5 on testnets. This allows testnets to more easily coexist on the same machines that are running a mainnet instance, and still tests the mempow computation and verification in a non-resource-intensive way. --- .../controller/OnlineAccountsManager.java | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 0e24bdfc..5e0c2abe 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -20,6 +20,7 @@ import org.qortal.network.message.*; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; import org.qortal.utils.Base58; import org.qortal.utils.NTP; import org.qortal.utils.NamedThreadFactory; @@ -63,9 +64,13 @@ public class OnlineAccountsManager { private static final long ONLINE_ACCOUNTS_COMPUTE_INITIAL_SLEEP_INTERVAL = 30 * 1000L; // ms - // MemoryPoW - public final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes - public int POW_DIFFICULTY = 18; // leading zero bits + // MemoryPoW - mainnet + public static final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY = 18; // leading zero bits + + // MemoryPoW - testnet + public static final int POW_BUFFER_SIZE_TESTNET = 1 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY_TESTNET = 5; // leading zero bits private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, new NamedThreadFactory("OnlineAccounts")); private volatile boolean isStopping = false; @@ -111,6 +116,20 @@ public class OnlineAccountsManager { return (timestamp / getOnlineTimestampModulus()) * getOnlineTimestampModulus(); } + private static int getPoWBufferSize() { + if (Settings.getInstance().isTestNet()) + return POW_BUFFER_SIZE_TESTNET; + + return POW_BUFFER_SIZE; + } + + private static int getPoWDifficulty() { + if (Settings.getInstance().isTestNet()) + return POW_DIFFICULTY_TESTNET; + + return POW_DIFFICULTY; + } + private OnlineAccountsManager() { } @@ -604,7 +623,7 @@ public class OnlineAccountsManager { final long nextOnlineAccountsTimestamp = toOnlineAccountTimestamp(startTime) + getOnlineTimestampModulus(); long timeUntilNextTimestamp = nextOnlineAccountsTimestamp - startTime; - Integer nonce = MemoryPoW.compute2(bytes, POW_BUFFER_SIZE, POW_DIFFICULTY, timeUntilNextTimestamp); + Integer nonce = MemoryPoW.compute2(bytes, getPoWBufferSize(), getPoWDifficulty(), timeUntilNextTimestamp); double totalSeconds = (NTP.getTime() - startTime) / 1000.0f; int minutes = (int) ((totalSeconds % 3600) / 60); @@ -613,7 +632,7 @@ public class OnlineAccountsManager { LOGGER.info(String.format("Computed nonce for timestamp %d and account %.8s: %d. Buffer size: %d. Difficulty: %d. " + "Time taken: %02d:%02d. Hashrate: %f", onlineAccountsTimestamp, Base58.encode(publicKey), - nonce, POW_BUFFER_SIZE, POW_DIFFICULTY, minutes, seconds, hashRate)); + nonce, getPoWBufferSize(), getPoWDifficulty(), minutes, seconds, hashRate)); return nonce; } @@ -634,7 +653,7 @@ public class OnlineAccountsManager { } // Verify the nonce - return MemoryPoW.verify2(mempowBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce); + return MemoryPoW.verify2(mempowBytes, getPoWBufferSize(), getPoWDifficulty(), nonce); } From 09014d07e0fc1b18cf37827698f3064e568f16dc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Oct 2022 19:29:31 +0100 Subject: [PATCH 036/496] Fixed issues retrieving chatReference from the db. --- .../java/org/qortal/data/chat/ChatMessage.java | 11 +++++++++-- .../repository/hsqldb/HSQLDBChatRepository.java | 16 +++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/data/chat/ChatMessage.java b/src/main/java/org/qortal/data/chat/ChatMessage.java index 26df1da4..5d16bb7c 100644 --- a/src/main/java/org/qortal/data/chat/ChatMessage.java +++ b/src/main/java/org/qortal/data/chat/ChatMessage.java @@ -27,6 +27,8 @@ public class ChatMessage { private String recipientName; + private byte[] chatReference; + private byte[] data; private boolean isText; @@ -42,8 +44,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[] data, boolean isText, - boolean isEncrypted, byte[] signature) { + String senderName, String recipient, String recipientName, byte[] chatReference, byte[] data, + boolean isText, boolean isEncrypted, byte[] signature) { this.timestamp = timestamp; this.txGroupId = txGroupId; this.reference = reference; @@ -52,6 +54,7 @@ public class ChatMessage { this.senderName = senderName; this.recipient = recipient; this.recipientName = recipientName; + this.chatReference = chatReference; this.data = data; this.isText = isText; this.isEncrypted = isEncrypted; @@ -90,6 +93,10 @@ public class ChatMessage { return this.recipientName; } + public byte[] getChatReference() { + return this.chatReference; + } + public byte[] getData() { return this.data; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java index d4c9d7e0..178a4c24 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -35,7 +35,7 @@ public class HSQLDBChatRepository implements ChatRepository { sql.append("SELECT created_when, tx_group_id, Transactions.reference, creator, " + "sender, SenderNames.name, recipient, RecipientNames.name, " - + "data, is_text, is_encrypted, signature " + + "chat_reference, data, is_text, is_encrypted, signature " + "FROM ChatTransactions " + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -108,13 +108,14 @@ public class HSQLDBChatRepository implements ChatRepository { String senderName = resultSet.getString(6); String recipient = resultSet.getString(7); String recipientName = resultSet.getString(8); - byte[] data = resultSet.getBytes(9); - boolean isText = resultSet.getBoolean(10); - boolean isEncrypted = resultSet.getBoolean(11); - byte[] signature = resultSet.getBytes(12); + byte[] chatReference = resultSet.getBytes(9); + byte[] data = resultSet.getBytes(10); + boolean isText = resultSet.getBoolean(11); + boolean isEncrypted = resultSet.getBoolean(12); + byte[] signature = resultSet.getBytes(13); ChatMessage chatMessage = new ChatMessage(timestamp, groupId, reference, senderPublicKey, sender, - senderName, recipient, recipientName, data, isText, isEncrypted, signature); + senderName, recipient, recipientName, chatReference, data, isText, isEncrypted, signature); chatMessages.add(chatMessage); } while (resultSet.next()); @@ -146,13 +147,14 @@ public class HSQLDBChatRepository implements ChatRepository { byte[] senderPublicKey = chatTransactionData.getSenderPublicKey(); String sender = chatTransactionData.getSender(); String recipient = chatTransactionData.getRecipient(); + byte[] chatReference = chatTransactionData.getChatReference(); byte[] data = chatTransactionData.getData(); boolean isText = chatTransactionData.getIsText(); boolean isEncrypted = chatTransactionData.getIsEncrypted(); byte[] signature = chatTransactionData.getSignature(); return new ChatMessage(timestamp, groupId, reference, senderPublicKey, sender, - senderName, recipient, recipientName, data, isText, isEncrypted, signature); + senderName, recipient, recipientName, chatReference, data, isText, isEncrypted, signature); } catch (SQLException e) { throw new DataException("Unable to fetch convert chat transaction from repository", e); } From 9d74f0eec0b4a8f208c8e8713d6bb2a07c74e1fa Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 24 Oct 2022 19:21:29 +0100 Subject: [PATCH 037/496] Added haschatreference, with possible values of true, false, or null, to allow optional filtering by the presence or absense of a chat reference. --- .../java/org/qortal/api/resource/ChatResource.java | 2 ++ .../qortal/api/websocket/ChatMessagesWebSocket.java | 2 ++ .../java/org/qortal/repository/ChatRepository.java | 4 ++-- .../repository/hsqldb/HSQLDBChatRepository.java | 11 +++++++++-- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index 8c0f94c3..2601e938 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -71,6 +71,7 @@ public class ChatResource { @QueryParam("involving") List involvingAddresses, @QueryParam("reference") String reference, @QueryParam("chatreference") String chatReference, + @QueryParam("haschatreference") Boolean hasChatReference, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { @@ -104,6 +105,7 @@ public class ChatResource { txGroupId, referenceBytes, chatReferenceBytes, + hasChatReference, involvingAddresses, limit, offset, reverse); } catch (DataException e) { diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index dbe36d9f..76ed936c 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -48,6 +48,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + null, null, null, null); sendMessages(session, chatMessages); @@ -76,6 +77,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + null, involvingAddresses, null, null, null); diff --git a/src/main/java/org/qortal/repository/ChatRepository.java b/src/main/java/org/qortal/repository/ChatRepository.java index ebdc22e4..c4541907 100644 --- a/src/main/java/org/qortal/repository/ChatRepository.java +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -14,8 +14,8 @@ public interface ChatRepository { * Expects EITHER non-null txGroupID OR non-null sender and recipient addresses. */ public List getMessagesMatchingCriteria(Long before, Long after, - Integer txGroupId, byte[] reference, byte[] chatReferenceBytes, List involving, - Integer limit, Integer offset, Boolean reverse) throws DataException; + Integer txGroupId, byte[] reference, byte[] chatReferenceBytes, Boolean hasChatReference, + List involving, Integer limit, Integer offset, Boolean reverse) throws DataException; public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) 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 178a4c24..08226d53 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -24,8 +24,8 @@ public class HSQLDBChatRepository implements ChatRepository { @Override public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] referenceBytes, - byte[] chatReferenceBytes, List involving, Integer limit, Integer offset, Boolean reverse) - throws DataException { + byte[] chatReferenceBytes, Boolean hasChatReference, List involving, + 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))) @@ -67,6 +67,13 @@ public class HSQLDBChatRepository implements ChatRepository { bindParams.add(chatReferenceBytes); } + if (hasChatReference != null && hasChatReference == true) { + whereClauses.add("chat_reference IS NOT NULL"); + } + else if (hasChatReference != null && hasChatReference == false) { + whereClauses.add("chat_reference IS NULL"); + } + if (txGroupId != null) { whereClauses.add("tx_group_id = " + txGroupId); // int safe to use literally whereClauses.add("recipient IS NULL"); From 510328db47dfe512ec47f9b91e3cf2d74ebd7c10 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 15:50:43 +0100 Subject: [PATCH 038/496] Removed unused timestamp value. --- src/main/java/org/qortal/block/Block.java | 2 +- .../java/org/qortal/controller/OnlineAccountsManager.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 55c13b36..c024308a 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1072,7 +1072,7 @@ public class Block { // Validate the rest for (OnlineAccountData onlineAccount : onlineAccounts) - if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, this.blockData.getTimestamp())) + if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount)) return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; // Extract online accounts' timestamp signatures from block data. Only one signature if aggregated. diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 5e0c2abe..aa35541d 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -339,7 +339,7 @@ public class OnlineAccountsManager { } // Validate mempow - if (!getInstance().verifyMemoryPoW(onlineAccountData, now)) { + if (!getInstance().verifyMemoryPoW(onlineAccountData)) { LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); return false; } @@ -582,7 +582,7 @@ public class OnlineAccountsManager { OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); // Make sure to verify before adding - if (verifyMemoryPoW(ourOnlineAccountData, NTP.getTime())) { + if (verifyMemoryPoW(ourOnlineAccountData)) { ourOnlineAccounts.add(ourOnlineAccountData); } } @@ -637,7 +637,7 @@ public class OnlineAccountsManager { return nonce; } - public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData, Long timestamp) { + public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData) { // Require a valid nonce value if (onlineAccountData.getNonce() == null || onlineAccountData.getNonce() < 0) { return false; From 30cd56165a69bbcbe75eb7c35eb33383cf3b653d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 16:02:25 +0100 Subject: [PATCH 039/496] Speed up syncing blocks in the range of 1-12 hours ago by caching the valid online accounts. --- src/main/java/org/qortal/block/Block.java | 3 +++ .../org/qortal/controller/OnlineAccountsManager.java | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index c024308a..99a82808 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1075,6 +1075,9 @@ public class Block { if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount)) return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; + // Cache the valid online accounts as they will likely be needed for the next block + OnlineAccountsManager.getInstance().addBlocksOnlineAccounts(onlineAccounts, onlineTimestamp); + // Extract online accounts' timestamp signatures from block data. Only one signature if aggregated. List onlineAccountsSignatures = BlockTransformer.decodeTimestampSignatures(encodedOnlineAccountSignatures); diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index aa35541d..53968cfd 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -737,11 +737,12 @@ public class OnlineAccountsManager { * Typically called by {@link Block#areOnlineAccountsValid()} */ public void addBlocksOnlineAccounts(Set blocksOnlineAccounts, Long timestamp) { - // We want to add to 'current' in preference if possible - if (this.currentOnlineAccounts.containsKey(timestamp)) { - addAccounts(blocksOnlineAccounts); + // If these are current accounts, then there is no need to cache them, and should instead rely + // on the more complete entries we already have in self.currentOnlineAccounts. + // Note: since sig-agg, we no longer have individual signatures included in blocks, so we + // mustn't add anything to currentOnlineAccounts from here. + if (this.currentOnlineAccounts.containsKey(timestamp)) return; - } // Add to block cache instead this.latestBlocksOnlineAccounts.computeIfAbsent(timestamp, k -> ConcurrentHashMap.newKeySet()) From b64c05353157b74ebba12c8c2856d6847cf0e390 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 16:54:53 +0100 Subject: [PATCH 040/496] Reuse the work buffer when verifying online accounts from the OnlineAccountsManager import queue. This is a hopeful fix for extra memory usage since mempow activated, due to adding a lot of load to the garbage collector. It only applies to accounts verified from the import queue; the optimization hasn't been applied to block processing. But verifying online accounts when processing blocks is rare and generally would only last a short amount of time. --- src/main/java/org/qortal/block/Block.java | 2 +- .../qortal/controller/OnlineAccountsManager.java | 13 +++++++++---- src/main/java/org/qortal/crypto/MemoryPoW.java | 9 ++++++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 99a82808..5e838458 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1072,7 +1072,7 @@ public class Block { // Validate the rest for (OnlineAccountData onlineAccount : onlineAccounts) - if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount)) + if (!OnlineAccountsManager.getInstance().verifyMemoryPoW(onlineAccount, null)) return ValidationResult.ONLINE_ACCOUNT_NONCE_INCORRECT; // Cache the valid online accounts as they will likely be needed for the next block diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 53968cfd..1aea118b 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -72,6 +72,11 @@ public class OnlineAccountsManager { public static final int POW_BUFFER_SIZE_TESTNET = 1 * 1024 * 1024; // bytes public static final int POW_DIFFICULTY_TESTNET = 5; // leading zero bits + // IMPORTANT: if we ever need to dynamically modify the buffer size using a feature trigger, the + // pre-allocated buffer below will NOT work, and we should instead use a dynamically allocated + // one for the transition period. + private static long[] POW_VERIFY_WORK_BUFFER = new long[getPoWBufferSize() / 8]; + private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, new NamedThreadFactory("OnlineAccounts")); private volatile boolean isStopping = false; @@ -339,7 +344,7 @@ public class OnlineAccountsManager { } // Validate mempow - if (!getInstance().verifyMemoryPoW(onlineAccountData)) { + if (!getInstance().verifyMemoryPoW(onlineAccountData, POW_VERIFY_WORK_BUFFER)) { LOGGER.trace(() -> String.format("Rejecting online reward-share for account %s due to invalid PoW nonce", mintingAccount.getAddress())); return false; } @@ -582,7 +587,7 @@ public class OnlineAccountsManager { OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce); // Make sure to verify before adding - if (verifyMemoryPoW(ourOnlineAccountData)) { + if (verifyMemoryPoW(ourOnlineAccountData, null)) { ourOnlineAccounts.add(ourOnlineAccountData); } } @@ -637,7 +642,7 @@ public class OnlineAccountsManager { return nonce; } - public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData) { + public boolean verifyMemoryPoW(OnlineAccountData onlineAccountData, long[] workBuffer) { // Require a valid nonce value if (onlineAccountData.getNonce() == null || onlineAccountData.getNonce() < 0) { return false; @@ -653,7 +658,7 @@ public class OnlineAccountsManager { } // Verify the nonce - return MemoryPoW.verify2(mempowBytes, getPoWBufferSize(), getPoWDifficulty(), nonce); + return MemoryPoW.verify2(mempowBytes, workBuffer, getPoWBufferSize(), getPoWDifficulty(), nonce); } diff --git a/src/main/java/org/qortal/crypto/MemoryPoW.java b/src/main/java/org/qortal/crypto/MemoryPoW.java index f27c8f7a..634b8f9b 100644 --- a/src/main/java/org/qortal/crypto/MemoryPoW.java +++ b/src/main/java/org/qortal/crypto/MemoryPoW.java @@ -99,6 +99,10 @@ public class MemoryPoW { } public static boolean verify2(byte[] data, int workBufferLength, long difficulty, int nonce) { + return verify2(data, null, workBufferLength, difficulty, nonce); + } + + public static boolean verify2(byte[] data, long[] workBuffer, int workBufferLength, long difficulty, int nonce) { // Hash data with SHA256 byte[] hash = Crypto.digest(data); @@ -111,7 +115,10 @@ public class MemoryPoW { byteBuffer = null; int longBufferLength = workBufferLength / 8; - long[] workBuffer = new long[longBufferLength]; + + if (workBuffer == null) + workBuffer = new long[longBufferLength]; + long[] state = new long[4]; long seed = 8682522807148012L; From 59a804c560c22d2655f1f80b75293ed930861d9a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 16:57:52 +0100 Subject: [PATCH 041/496] Include "blocks remaining" in systray when syncing from more than 60 minutes away from a peer's chain tip. --- .../org/qortal/controller/Controller.java | 6 +++++ .../org/qortal/controller/Synchronizer.java | 26 +++++++++++++++++++ src/main/resources/i18n/SysTray_de.properties | 2 ++ src/main/resources/i18n/SysTray_en.properties | 2 ++ src/main/resources/i18n/SysTray_es.properties | 2 ++ src/main/resources/i18n/SysTray_fi.properties | 2 ++ src/main/resources/i18n/SysTray_fr.properties | 2 ++ src/main/resources/i18n/SysTray_hu.properties | 2 ++ src/main/resources/i18n/SysTray_it.properties | 2 ++ src/main/resources/i18n/SysTray_ko.properties | 2 ++ src/main/resources/i18n/SysTray_nl.properties | 2 ++ src/main/resources/i18n/SysTray_ro.properties | 2 ++ src/main/resources/i18n/SysTray_ru.properties | 2 ++ src/main/resources/i18n/SysTray_sv.properties | 2 ++ .../resources/i18n/SysTray_zh_CN.properties | 2 ++ .../resources/i18n/SysTray_zh_TW.properties | 2 ++ 16 files changed, 60 insertions(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 6fe6a159..bcd010e8 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -838,6 +838,12 @@ public class Controller extends Thread { String tooltip = String.format("%s - %d %s", actionText, numberOfPeers, connectionsText); if (!Settings.getInstance().isLite()) { tooltip = tooltip.concat(String.format(" - %s %d", heightText, height)); + + final Integer blocksRemaining = Synchronizer.getInstance().getBlocksRemaining(); + if (blocksRemaining != null && blocksRemaining > 0) { + String blocksRemainingText = Translator.INSTANCE.translate("SysTray", "BLOCKS_REMAINING"); + tooltip = tooltip.concat(String.format(" - %d %s", blocksRemaining, blocksRemainingText)); + } } tooltip = tooltip.concat(String.format("\n%s: %s", Translator.INSTANCE.translate("SysTray", "BUILD_VERSION"), this.buildVersion)); SysTray.getInstance().setToolTipText(tooltip); diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 6f2a0fe1..cd9483e9 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -76,6 +76,8 @@ public class Synchronizer extends Thread { private volatile boolean isSynchronizing = false; /** Temporary estimate of synchronization progress for SysTray use. */ private volatile int syncPercent = 0; + /** Temporary estimate of blocks remaining for SysTray use. */ + private volatile int blocksRemaining = 0; private static volatile boolean requestSync = false; private boolean syncRequestPending = false; @@ -181,6 +183,18 @@ public class Synchronizer extends Thread { } } + public Integer getBlocksRemaining() { + synchronized (this.syncLock) { + // Report as 0 blocks remaining if the latest block is within the last 60 mins + final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L); + if (Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) { + return 0; + } + + return this.isSynchronizing ? this.blocksRemaining : null; + } + } + public void requestSync() { requestSync = true; } @@ -1457,6 +1471,12 @@ public class Synchronizer extends Thread { repository.saveChanges(); + synchronized (this.syncLock) { + if (peer.getChainTipData() != null) { + this.blocksRemaining = peer.getChainTipData().getHeight() - newBlock.getBlockData().getHeight(); + } + } + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } @@ -1552,6 +1572,12 @@ public class Synchronizer extends Thread { repository.saveChanges(); + synchronized (this.syncLock) { + if (peer.getChainTipData() != null) { + this.blocksRemaining = peer.getChainTipData().getHeight() - newBlock.getBlockData().getHeight(); + } + } + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } diff --git a/src/main/resources/i18n/SysTray_de.properties b/src/main/resources/i18n/SysTray_de.properties index b949ca8c..4dc7edd2 100644 --- a/src/main/resources/i18n/SysTray_de.properties +++ b/src/main/resources/i18n/SysTray_de.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatisches Update BLOCK_HEIGHT = height +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Build-Version CHECK_TIME_ACCURACY = Prüfe Zeitgenauigkeit diff --git a/src/main/resources/i18n/SysTray_en.properties b/src/main/resources/i18n/SysTray_en.properties index 204f0df2..39940be0 100644 --- a/src/main/resources/i18n/SysTray_en.properties +++ b/src/main/resources/i18n/SysTray_en.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Auto Update BLOCK_HEIGHT = height +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Build version CHECK_TIME_ACCURACY = Check time accuracy diff --git a/src/main/resources/i18n/SysTray_es.properties b/src/main/resources/i18n/SysTray_es.properties index d4b931d4..36cbb22c 100644 --- a/src/main/resources/i18n/SysTray_es.properties +++ b/src/main/resources/i18n/SysTray_es.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Actualización automática BLOCK_HEIGHT = altura +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versión de compilación CHECK_TIME_ACCURACY = Comprobar la precisión del tiempo diff --git a/src/main/resources/i18n/SysTray_fi.properties b/src/main/resources/i18n/SysTray_fi.properties index bc787715..4038d615 100644 --- a/src/main/resources/i18n/SysTray_fi.properties +++ b/src/main/resources/i18n/SysTray_fi.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automaattinen päivitys BLOCK_HEIGHT = korkeus +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versio CHECK_TIME_ACCURACY = Tarkista ajan tarkkuus diff --git a/src/main/resources/i18n/SysTray_fr.properties b/src/main/resources/i18n/SysTray_fr.properties index 6e60713c..2e376842 100644 --- a/src/main/resources/i18n/SysTray_fr.properties +++ b/src/main/resources/i18n/SysTray_fr.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Mise à jour automatique BLOCK_HEIGHT = hauteur +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Numéro de version CHECK_TIME_ACCURACY = Vérifier l'heure diff --git a/src/main/resources/i18n/SysTray_hu.properties b/src/main/resources/i18n/SysTray_hu.properties index 9bc51ff5..74ab21ac 100644 --- a/src/main/resources/i18n/SysTray_hu.properties +++ b/src/main/resources/i18n/SysTray_hu.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatikus Frissítés BLOCK_HEIGHT = blokkmagasság +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Verzió CHECK_TIME_ACCURACY = Óra pontosságának ellenőrzése diff --git a/src/main/resources/i18n/SysTray_it.properties b/src/main/resources/i18n/SysTray_it.properties index bf61cc46..d966d825 100644 --- a/src/main/resources/i18n/SysTray_it.properties +++ b/src/main/resources/i18n/SysTray_it.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Aggiornamento automatico BLOCK_HEIGHT = altezza +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versione CHECK_TIME_ACCURACY = Controlla la precisione dell'ora diff --git a/src/main/resources/i18n/SysTray_ko.properties b/src/main/resources/i18n/SysTray_ko.properties index 9773a54f..dc6cb69b 100644 --- a/src/main/resources/i18n/SysTray_ko.properties +++ b/src/main/resources/i18n/SysTray_ko.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = 자동 업데이트 BLOCK_HEIGHT = 높이 +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = 빌드 버전 CHECK_TIME_ACCURACY = 시간 정확도 점검 diff --git a/src/main/resources/i18n/SysTray_nl.properties b/src/main/resources/i18n/SysTray_nl.properties index 8a4f112b..c2acb7ce 100644 --- a/src/main/resources/i18n/SysTray_nl.properties +++ b/src/main/resources/i18n/SysTray_nl.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatische Update BLOCK_HEIGHT = Block hoogte +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Versie nummer CHECK_TIME_ACCURACY = Controleer accuraatheid van de tijd diff --git a/src/main/resources/i18n/SysTray_ro.properties b/src/main/resources/i18n/SysTray_ro.properties index 0e1aa6c6..4130bbcb 100644 --- a/src/main/resources/i18n/SysTray_ro.properties +++ b/src/main/resources/i18n/SysTray_ro.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Actualizare automata BLOCK_HEIGHT = dimensiune +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = versiunea compilatiei CHECK_TIME_ACCURACY = verificare exactitate ora diff --git a/src/main/resources/i18n/SysTray_ru.properties b/src/main/resources/i18n/SysTray_ru.properties index fc3d8648..ff346304 100644 --- a/src/main/resources/i18n/SysTray_ru.properties +++ b/src/main/resources/i18n/SysTray_ru.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Автоматическое обновление BLOCK_HEIGHT = Высота блока +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Версия сборки CHECK_TIME_ACCURACY = Проверка точного времени diff --git a/src/main/resources/i18n/SysTray_sv.properties b/src/main/resources/i18n/SysTray_sv.properties index 0e74337b..96f291b5 100644 --- a/src/main/resources/i18n/SysTray_sv.properties +++ b/src/main/resources/i18n/SysTray_sv.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatisk uppdatering BLOCK_HEIGHT = höjd +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = Byggversion CHECK_TIME_ACCURACY = Kontrollera tidens noggrannhet diff --git a/src/main/resources/i18n/SysTray_zh_CN.properties b/src/main/resources/i18n/SysTray_zh_CN.properties index c103d24b..d6848a7c 100644 --- a/src/main/resources/i18n/SysTray_zh_CN.properties +++ b/src/main/resources/i18n/SysTray_zh_CN.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = 自动更新 BLOCK_HEIGHT = 区块高度 +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = 版本 CHECK_TIME_ACCURACY = 检查时间准确性 diff --git a/src/main/resources/i18n/SysTray_zh_TW.properties b/src/main/resources/i18n/SysTray_zh_TW.properties index 5e6ccc3e..eabdbb63 100644 --- a/src/main/resources/i18n/SysTray_zh_TW.properties +++ b/src/main/resources/i18n/SysTray_zh_TW.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = 自動更新 BLOCK_HEIGHT = 區塊高度 +BLOCKS_REMAINING = blocks remaining + BUILD_VERSION = 版本 CHECK_TIME_ACCURACY = 檢查時間準確性 From 166425bee9e943ddf8c603da7e1c7d4184481276 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 17:20:39 +0100 Subject: [PATCH 042/496] Added feature trigger timestamp (TBC) to increase online accounts mempow difficulty (also TBC). --- src/main/java/org/qortal/block/BlockChain.java | 7 ++++++- .../controller/OnlineAccountsManager.java | 17 +++++++++++------ src/main/resources/blockchain.json | 3 ++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 826fdd78..5e1f44f3 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -73,7 +73,8 @@ public class BlockChain { calcChainWeightTimestamp, transactionV5Timestamp, transactionV6Timestamp, - disableReferenceTimestamp; + disableReferenceTimestamp, + increaseOnlineAccountsDifficultyTimestamp; } // Custom transaction fees @@ -478,6 +479,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.disableReferenceTimestamp.name()).longValue(); } + public long getIncreaseOnlineAccountsDifficultyTimestamp() { + return this.featureTriggers.get(FeatureTrigger.increaseOnlineAccountsDifficultyTimestamp.name()).longValue(); + } + // More complex getters for aspects that change by height or timestamp diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 1aea118b..fd2c38df 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -66,7 +66,8 @@ public class OnlineAccountsManager { // MemoryPoW - mainnet public static final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY = 18; // leading zero bits + public static final int POW_DIFFICULTY_V1 = 18; // leading zero bits + public static final int POW_DIFFICULTY_V2 = 19; // leading zero bits // MemoryPoW - testnet public static final int POW_BUFFER_SIZE_TESTNET = 1 * 1024 * 1024; // bytes @@ -128,11 +129,14 @@ public class OnlineAccountsManager { return POW_BUFFER_SIZE; } - private static int getPoWDifficulty() { + private static int getPoWDifficulty(long timestamp) { if (Settings.getInstance().isTestNet()) return POW_DIFFICULTY_TESTNET; - return POW_DIFFICULTY; + if (timestamp >= BlockChain.getInstance().getIncreaseOnlineAccountsDifficultyTimestamp()) + return POW_DIFFICULTY_V2; + + return POW_DIFFICULTY_V1; } private OnlineAccountsManager() { @@ -628,7 +632,8 @@ public class OnlineAccountsManager { final long nextOnlineAccountsTimestamp = toOnlineAccountTimestamp(startTime) + getOnlineTimestampModulus(); long timeUntilNextTimestamp = nextOnlineAccountsTimestamp - startTime; - Integer nonce = MemoryPoW.compute2(bytes, getPoWBufferSize(), getPoWDifficulty(), timeUntilNextTimestamp); + int difficulty = getPoWDifficulty(onlineAccountsTimestamp); + Integer nonce = MemoryPoW.compute2(bytes, getPoWBufferSize(), difficulty, timeUntilNextTimestamp); double totalSeconds = (NTP.getTime() - startTime) / 1000.0f; int minutes = (int) ((totalSeconds % 3600) / 60); @@ -637,7 +642,7 @@ public class OnlineAccountsManager { LOGGER.info(String.format("Computed nonce for timestamp %d and account %.8s: %d. Buffer size: %d. Difficulty: %d. " + "Time taken: %02d:%02d. Hashrate: %f", onlineAccountsTimestamp, Base58.encode(publicKey), - nonce, getPoWBufferSize(), getPoWDifficulty(), minutes, seconds, hashRate)); + nonce, getPoWBufferSize(), difficulty, minutes, seconds, hashRate)); return nonce; } @@ -658,7 +663,7 @@ public class OnlineAccountsManager { } // Verify the nonce - return MemoryPoW.verify2(mempowBytes, workBuffer, getPoWBufferSize(), getPoWDifficulty(), nonce); + return MemoryPoW.verify2(mempowBytes, workBuffer, getPoWBufferSize(), getPoWDifficulty(onlineAccountData.getTimestamp()), nonce); } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 893add5e..34671c76 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -79,7 +79,8 @@ "calcChainWeightTimestamp": 1620579600000, "transactionV5Timestamp": 1642176000000, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 1655222400000 + "disableReferenceTimestamp": 1655222400000, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, From f739d8f5c6a9d0edc4a9a138f5468efe6ee8eeb7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Oct 2022 18:06:34 +0100 Subject: [PATCH 043/496] Added increaseOnlineAccountsDifficultyTimestamp feature trigger to unit tests. --- src/test/resources/test-chain-v2-block-timestamps.json | 3 ++- src/test/resources/test-chain-v2-disable-reference.json | 3 ++- src/test/resources/test-chain-v2-founder-rewards.json | 3 ++- src/test/resources/test-chain-v2-leftover-reward.json | 3 ++- src/test/resources/test-chain-v2-minting.json | 3 ++- src/test/resources/test-chain-v2-qora-holder-extremes.json | 3 ++- src/test/resources/test-chain-v2-qora-holder-reduction.json | 3 ++- src/test/resources/test-chain-v2-qora-holder.json | 3 ++- src/test/resources/test-chain-v2-reward-levels.json | 3 ++- src/test/resources/test-chain-v2-reward-scaling.json | 3 ++- src/test/resources/test-chain-v2-reward-shares.json | 3 ++- src/test/resources/test-chain-v2.json | 3 ++- 12 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 37224684..4a883bd9 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -69,7 +69,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index 7ea0b86d..e8fee5e0 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -72,7 +72,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 0 + "disableReferenceTimestamp": 0, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 85a50f83..17a713a0 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index ebc3ccfa..b57c3195 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index cc91f993..60b3cd76 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 085d1dbf..2d044687 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 75858057..3cf8848e 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "aggregateSignatureTimestamp": 0 + "aggregateSignatureTimestamp": 0, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 0706c5bb..93965b76 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index b3644d6b..06422e71 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 1c68dda4..6adcd0ac 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 10d2aab3..95324b56 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -73,7 +73,8 @@ "newConsensusTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 5f439602..c0fb9861 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, From fa80c838645effa63936acbfd84c0fbca0269b56 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 30 Oct 2022 17:07:56 +0000 Subject: [PATCH 044/496] Remove QORTAL_METADATA service as this uses its own protocol instead. --- src/main/java/org/qortal/arbitrary/misc/Service.java | 3 +-- 1 file changed, 1 insertion(+), 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 5d94d806..99e9de36 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -47,8 +47,7 @@ public enum Service { LIST(900, true, null, null), PLAYLIST(910, true, null, null), APP(1000, false, null, null), - METADATA(1100, false, null, null), - QORTAL_METADATA(1111, true, 10*1024L, Arrays.asList("title", "description", "tags")); + METADATA(1100, false, null, null); public final int value; private final boolean requiresValidation; From 4043ae19285faf56411ff1a5dc7a6033c1543c09 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 30 Oct 2022 17:23:46 +0000 Subject: [PATCH 045/496] Added QCHAT_IMAGE service (with 500KB file size limit). --- src/main/java/org/qortal/arbitrary/misc/Service.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 99e9de36..981aa119 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -38,6 +38,7 @@ public enum Service { 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), BLOG(700, false, null, null), From 0628847d14db75aa30e784ef81c1c26794d7c56b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 30 Oct 2022 17:25:11 +0000 Subject: [PATCH 046/496] Removed QORTAL_METADATA service tests. --- .../test/arbitrary/ArbitraryServiceTests.java | 74 ------------------- 1 file changed, 74 deletions(-) diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index 4db8bdc7..d71910f7 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -101,78 +101,4 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.MISSING_INDEX_FILE, service.validate(path)); } - @Test - public void testValidQortalMetadata() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"title\":\"Test Title\", \"description\":\"Test description\", \"tags\":[\"test\"]}"; - - // Write to temp path - Path path = Files.createTempFile("testValidQortalMetadata", null); - path.toFile().deleteOnExit(); - Files.write(path, dataString.getBytes(), StandardOpenOption.CREATE); - - Service service = Service.QORTAL_METADATA; - assertTrue(service.isValidationRequired()); - assertEquals(ValidationResult.OK, service.validate(path)); - } - - @Test - public void testQortalMetadataMissingKeys() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"description\":\"Test description\", \"tags\":[\"test\"]}"; - - // Write to temp path - Path path = Files.createTempFile("testQortalMetadataMissingKeys", null); - path.toFile().deleteOnExit(); - Files.write(path, dataString.getBytes(), StandardOpenOption.CREATE); - - Service service = Service.QORTAL_METADATA; - assertTrue(service.isValidationRequired()); - assertEquals(ValidationResult.MISSING_KEYS, service.validate(path)); - } - - @Test - public void testQortalMetadataTooLarge() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"title\":\"Test Title\", \"description\":\"Test description\", \"tags\":[\"test\"]}"; - - // Generate some large data to go along with it - int largeDataSize = 11*1024; // Larger than allowed 10kiB - byte[] largeData = new byte[largeDataSize]; - new Random().nextBytes(largeData); - - // Write to temp path - Path path = Files.createTempDirectory("testQortalMetadataTooLarge"); - path.toFile().deleteOnExit(); - Files.write(Paths.get(path.toString(), "data"), dataString.getBytes(), StandardOpenOption.CREATE); - Files.write(Paths.get(path.toString(), "large_data"), largeData, StandardOpenOption.CREATE); - - Service service = Service.QORTAL_METADATA; - assertTrue(service.isValidationRequired()); - assertEquals(ValidationResult.EXCEEDS_SIZE_LIMIT, service.validate(path)); - } - - @Test - public void testMultipleFileMetadata() throws IOException { - // Metadata is to describe an arbitrary resource (title, description, tags, etc) - String dataString = "{\"title\":\"Test Title\", \"description\":\"Test description\", \"tags\":[\"test\"]}"; - - // Generate some large data to go along with it - int otherDataSize = 1024; // Smaller than 10kiB limit - byte[] otherData = new byte[otherDataSize]; - new Random().nextBytes(otherData); - - // Write to temp path - Path path = Files.createTempDirectory("testMultipleFileMetadata"); - path.toFile().deleteOnExit(); - Files.write(Paths.get(path.toString(), "data"), dataString.getBytes(), StandardOpenOption.CREATE); - Files.write(Paths.get(path.toString(), "other_data"), otherData, StandardOpenOption.CREATE); - - Service service = Service.QORTAL_METADATA; - assertTrue(service.isValidationRequired()); - - // There are multiple files, so we don't know which one to parse as JSON - assertEquals(ValidationResult.MISSING_KEYS, service.validate(path)); - } - } From 985c195e9e1a3cbdf6791bae3ce73947d2925931 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 30 Oct 2022 17:33:21 +0000 Subject: [PATCH 047/496] Added GIF_REPOSITORY, with custom validation function and unit tests. --- .../org/qortal/arbitrary/misc/Service.java | 35 ++++++++- .../test/arbitrary/ArbitraryServiceTests.java | 74 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 981aa119..5dd8d94e 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -1,16 +1,18 @@ package org.qortal.arbitrary.misc; +import org.apache.commons.io.FilenameUtils; import org.json.JSONObject; import org.qortal.arbitrary.ArbitraryDataRenderer; import org.qortal.transaction.Transaction; import org.qortal.utils.FilesystemUtils; +import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Objects; import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; @@ -48,7 +50,31 @@ public enum Service { LIST(900, true, null, null), PLAYLIST(910, true, null, null), APP(1000, false, null, null), - METADATA(1100, false, null, null); + METADATA(1100, false, null, null), + GIF_REPOSITORY(1200, true, 25*1024*1024L, null) { + @Override + public ValidationResult validate(Path path) { + // Custom validation function to require .gif files only, and at least 1 + int gifCount = 0; + File[] files = path.toFile().listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + return ValidationResult.DIRECTORIES_NOT_ALLOWED; + } + String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(); + if (!Objects.equals(extension, "gif")) { + return ValidationResult.INVALID_FILE_EXTENSION; + } + gifCount++; + } + } + if (gifCount == 0) { + return ValidationResult.MISSING_DATA; + } + return ValidationResult.OK; + } + }; public final int value; private final boolean requiresValidation; @@ -114,7 +140,10 @@ public enum Service { OK(1), MISSING_KEYS(2), EXCEEDS_SIZE_LIMIT(3), - MISSING_INDEX_FILE(4); + MISSING_INDEX_FILE(4), + DIRECTORIES_NOT_ALLOWED(5), + INVALID_FILE_EXTENSION(6), + MISSING_DATA(7); 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 d71910f7..e6a51776 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -101,4 +101,78 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.MISSING_INDEX_FILE, service.validate(path)); } + @Test + public void testValidateGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image3.gif"), data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.OK, service.validate(path)); + } + + @Test + public void testValidateMultiLayerGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateMultiLayerGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + + Path subdirectory = Paths.get(path.toString(), "subdirectory"); + Files.createDirectories(subdirectory); + Files.write(Paths.get(subdirectory.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(subdirectory.toString(), "image3.gif"), data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); + } + + @Test + public void testValidateEmptyGifRepository() throws IOException { + Path path = Files.createTempDirectory("testValidateEmptyGifRepository"); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.MISSING_DATA, service.validate(path)); + } + + @Test + public void testValidateInvalidGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateInvalidGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image3.jpg"), data, StandardOpenOption.CREATE); // Invalid extension + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); + } + } From 055775b13dbae9485a0610b5f0836271659217b9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 30 Oct 2022 18:54:38 +0000 Subject: [PATCH 048/496] Include a list of files in the QDN metadata. --- .../qortal/arbitrary/ArbitraryDataWriter.java | 33 +++++++-- .../ArbitraryDataTransactionMetadata.java | 31 ++++++++ .../ArbitraryTransactionMetadataTests.java | 72 +++++++++++++++++++ 3 files changed, 129 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java index 33802d4f..8b1d00c3 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java @@ -23,16 +23,13 @@ import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; 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.nio.file.*; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Objects; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class ArbitraryDataWriter { @@ -50,6 +47,7 @@ public class ArbitraryDataWriter { private final String description; private final List tags; private final Category category; + private List files; private int chunkSize = ArbitraryDataFile.CHUNK_SIZE; @@ -80,12 +78,14 @@ public class ArbitraryDataWriter { this.description = ArbitraryDataTransactionMetadata.limitDescription(description); this.tags = ArbitraryDataTransactionMetadata.limitTags(tags); this.category = category; + this.files = new ArrayList<>(); // Populated in buildFileList() } public void save() throws IOException, DataException, InterruptedException, MissingDataException { try { this.preExecute(); this.validateService(); + this.buildFileList(); this.process(); this.compress(); this.encrypt(); @@ -143,6 +143,24 @@ public class ArbitraryDataWriter { } } + private void buildFileList() throws IOException { + // Single file resources consist of a single element in the file list + boolean isSingleFile = this.filePath.toFile().isFile(); + if (isSingleFile) { + this.files.add(this.filePath.getFileName().toString()); + return; + } + + // Multi file resources require a walk through the directory tree + try (Stream stream = Files.walk(this.filePath)) { + this.files = stream + .filter(Files::isRegularFile) + .map(p -> this.filePath.relativize(p).toString()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + } + private void process() throws DataException, IOException, MissingDataException { switch (this.method) { @@ -285,6 +303,7 @@ public class ArbitraryDataWriter { metadata.setTags(this.tags); metadata.setCategory(this.category); metadata.setChunks(this.arbitraryDataFile.chunkHashList()); + metadata.setFiles(this.files); metadata.write(); // Create an ArbitraryDataFile from the JSON file (we don't have a signature yet) diff --git a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java index 0f8b676b..33da343c 100644 --- a/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java +++ b/src/main/java/org/qortal/arbitrary/metadata/ArbitraryDataTransactionMetadata.java @@ -19,6 +19,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { private String description; private List tags; private Category category; + private List files; private static int MAX_TITLE_LENGTH = 80; private static int MAX_DESCRIPTION_LENGTH = 500; @@ -77,6 +78,20 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { } this.chunks = chunksList; } + + List filesList = new ArrayList<>(); + if (metadata.has("files")) { + JSONArray files = metadata.getJSONArray("files"); + if (files != null) { + for (int i=0; i files) { + this.files = files; + } + + public List getFiles() { + return this.files; + } + public boolean containsChunk(byte[] chunk) { for (byte[] c : this.chunks) { if (Arrays.equals(c, chunk)) { diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java index 357046fe..d8071777 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java @@ -25,9 +25,13 @@ import org.qortal.transaction.RegisterNameTransaction; import org.qortal.utils.Base58; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.List; +import java.util.Random; import static org.junit.Assert.*; @@ -279,6 +283,74 @@ public class ArbitraryTransactionMetadataTests extends Common { } } + @Test + public void testSingleFileList() 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 + + // 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); + + // Add a few files at multiple levels + byte[] data = new byte[1024]; + new Random().nextBytes(data); + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + Path file1 = Paths.get(path1.toString(), "file.txt"); + + // Create PUT transaction + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, file1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize); + + // Check the file list metadata is correct + assertEquals(1, arbitraryDataFile.getMetadata().getFiles().size()); + assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("file.txt")); + } + } + + @Test + public void testMultipleFileList() 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 + + // 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); + + // Add a few files at multiple levels + byte[] data = new byte[1024]; + new Random().nextBytes(data); + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + Files.write(Paths.get(path1.toString(), "image1.jpg"), data, StandardOpenOption.CREATE); + + Path subdirectory = Paths.get(path1.toString(), "subdirectory"); + Files.createDirectories(subdirectory); + Files.write(Paths.get(subdirectory.toString(), "config.json"), data, StandardOpenOption.CREATE); + + // Create PUT transaction + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize); + + // Check the file list metadata is correct + assertEquals(3, arbitraryDataFile.getMetadata().getFiles().size()); + assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("file.txt")); + assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("image1.jpg")); + assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("subdirectory/config.json")); + } + } + @Test public void testExistingCategories() { // Matching categories should be correctly located From aead9cfcbfe23747d60ce1a59d05be9354ac22fa Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 1 Nov 2022 08:55:57 +0000 Subject: [PATCH 049/496] Proof of concept: speed up QORT buying When users buy QORT ("Alice"-side), most of the API time is spent computing mempow for the MESSAGE sent to Bob's AT. This is the final stage startResponse() and after Alice's P2SH is already broadcast. To speed this up, the MESSAGE part is moved into its own thread allowing startResponse() to return sooner, improving the user experience. Caveats: If MESSAGE importAsUnconfirmed() somehow fails the the buy won't complete and Alice will have to wait for P2SH refund. If Alice shuts down her node while MESSAGE mempow is being computed then it's possible the shutdown will be blocked until mempow is complete. Currently only implemented in LitecoinACCTv3TradeBot as this is only proof-of-concept. Tested with multiple buys in the same block. --- .../tradebot/LitecoinACCTv3TradeBot.java | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java index a31a1a28..a4ae921e 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,27 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + messageTransaction.computeNonce(); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); From 9c68f1038ab899e69831345f10916e9ea6732115 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 5 Nov 2022 14:02:04 +0000 Subject: [PATCH 050/496] Bump AT version to 1.4.0 --- lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar | Bin 0 -> 161850 bytes lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom | 9 +++++++++ lib/org/ciyam/AT/maven-metadata-local.xml | 5 +++-- pom.xml | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar create mode 100644 lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom diff --git a/lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..c2c3d3556575dd7793cbd08ba3013003e8eaf621 GIT binary patch literal 161850 zcma&N1yq~a+P~dWph$3s;_mLy;#%CD;O?%)g1fuBQ{3I%-5rXzNP&Lo%$)O{ng91n z*2-F0VJFXi!mZc++w#)yAkp8vfq{7=p<}E9r?t^7|K`mb1@II6?i(3VWkGsLSusW# zL0L&LQ6*&t8L?ZL@v+a+^b9iy()2Ww<5OQ1ndVuy4;&bzWauPiW}M6F6|ZFIq$iZF zr05i3hN)$y#+8^BSs_a8X(q-cXTD2~EFv&S$Us}L?H}wOK)jI$?*I7yR>o_xnb^uxcX$ zpx(TR1OIHlKl%G7`SS|k8V|m!x83fE9WcQ3yebLzzRbSx7=!r#wp zcyhhP@$#0>@$&rjHRU-q{84HmETuN3sc~NIvg*1E3y}3b1EY5wp&H}p8+zB+2h|V|2RW4i zO)h`d*9VK1n!Zfu#&}9;wRH{+pLlL==1)vCHXLekIYV^eytnSEH1=s(mI=GP2bK!3 zoyBx|MUjdTUob^gZ!USE&;+S@ynxZ^#>rErEiJUUoXskIR@O(boEtQ5helMot^vkj zdo`xv#$!&7jVk2S{@CRrW=`?CzTqX2q z?~#PA*=v7{Wy>cG&j_RBaf!u3hUXE=dB_yMV1`TBi9*B^#n=f|JAj*POs&J2t`ZB#B#{4p<|&L3e|fMcG)qP=F(r@f`SuD;RPRnzHxcGwzqc^wc%c#Xy`u=kobYFlH6nk#16fFZ`NfEwJQ@lBl@ zJI_Udzr@n1>L{Rgx^%*UBL!w48Us2NBOIey9ihXQcLCpQL2OB6)dg-mk!fZ7qvgCn zeiiS53#tW1{ZT4E- zY7&$dBdp>llU?M0%XV$_#_`Gde6B!#-E6NSbR{_AWF#18bzu(2>Ba!Vqg8f+`!Gkr?L0s{4%-m z^MWFrTh8&`4-(YA$}?*Y$$ht03?tN2J>T6|uO#wQmU%$jCKF?lf@BM{hc$$($n@&PE^bDvzX-lVB8ii-J)bfwi?L+1 zZ;qHM|4;z4!lqd(hQvJ{LBnQIzCDg%JnzbNhk*RG^e{Hr?DAS=+FoTt+_X;3JCY&p ztLG@88RCODGz}NtRhP(=R{ghp&Xw;yv>Bdz){1q>Lyh z#()u5CgN&+RbAoFoxz?r-c}=LRKs{#Fwvoy#6Fb%L9};;r0%4|;%LlL?heDpf~ZT! z0F2Q_DF*}ve1HN!OlX>9o;lABHP4X7P1G)rC}!Pv;U^@mtMX$d=1K#xxIj>t6d`|! zf?^Lsb(D$y+}F_6^jo@@hyHS0j}zFCulT>-{Z1SI6-C|6Jfz1V-nyc#J-vqq7}ZJc!MN?UMX-XsBbOc8rU7IKfFP$XD(uN2@1jR6<%RvW6BHNQaI)^{(y**&o za!~-+5&E#UdwQ!EWqo^$=O8XO{OkQ`To4>)lCDS2YBe zF|?1#RdOAtRh$Rx$?ec+IDIZ(M_z*Jck~V245p3UZ1mp*0MM`)k{pi-LYx>fP#Pk- z_9$lLU#t`1X+h+g0~u!o%KRY0zOTC_t@eBUx zcM)+&b`Y6ahUntDW_^`0qC?j8OSN1)Auo`b$9WP1t&)vAtVKmrFumhg^}1K5aJ|&_ z-tGO?8IE|zVsa^K0*p_?KM}nx1Mh~M8z{f8V!_VMj|`_g+-WTo6Fb_bNcJIvgiR1g z?-qo@#D#w`M+BFgc8i3FP*F-qN)JaIa2bzm<2=Q}oaKhfH^etbkLdl~3A*^9Oz6N) zu=DQC8<9_||@$4tk z8@Zj;;oYdnwq{d9sQX*Dm?2@%dbj}e6M3I@;bRn1GoHhWy%rCgQSpWQCa_^bpMAbo zKg9xC@tnMa6b1h(KPD!%=*E5dP(lC4QBGl-;P|$iVps{v4Hss;j#XL7-c!%}O~)3O zCbW|?0m;7hn|+N;V%}|_&`|1-{=Iv@Cqklxj7aRXB-!4NmR~Ws%WAbYPC>qP&NR;= zXwU68myp#hhu;;J81CEVC8ZtdPwD4!Ze6I-fIa{W4BYepU(v{#ICT`?PwQkDn|Xtc zvfeYC>KWoeKj;%hI+qb6?B@Ee&rEPdzLLMl53oh*7HTF=i9kq*f`4A5VCvZCVxb&>-X%NL;gsIlYKn ziW?x8kojQH{4xq2(vh=hki;rt5!uR{tQ)`y`j16M2j?TGMAX4aa5~^P`c*VYQ-PLZi(gh3Kr-48;;j za4Z_?qv4Uewtu%Zr(eron!vW^0uH!D|FpF~%T{@y1JIg`{m;@hQQ2AvRRP_Hfe2ng zDj~@zwG=kbfUyXa%BaB{unRbtzdLEw*3UFfUg_X^sy>zGo|wODAzW~}Fn|eB`Zl?q z>^S8<*)rwm;`{pagx`(IL3%7+M#w55j@)kr^As0#PqX`3XA9owkmo0*qSVPJ|1Oz(hiGK ziR}t`?GV0h`g1_M%r{oZgiIilt-3*z0x$9;o{xSUc~+CN&4uHT{7+Ux0aXMkE)VVp zgCv>-twh;pVH|+BG4XerzSR{`y1q%PEQ#6`s^pQ}V&(;xqu!p}Y4W!S$B@1k{ovLs z>4UWPjmyyRk6MP)*&2GaKGY4tRT&DLqIfK&14S(AV$TouUw7=6@*C5g*AjzT%j~9V zX+?U#rGJ6cq=T}+j15<-n>kgMp|!{aEuEs-+N{i4p$M;#>NnKHW?JpXG=cmOk|H2? z7zSS?Nw^A)v4aKhF(syz7TD}zGpg_!W|!v%Vh6<{R=PCn78X%SF$95*6eo&R0l z1`Wg=L~iQc@j6zGT>TGXS;H8|VG%y$2Tm3s0 zC*km*&!Z}QqRqV7b4iRdqsiS|7w$)}pY`KNAX08fE)7@5D$Jxjl6(NOABq<+G?5@l zMM(J1YPbGw^xuC}N0ESy{@{NNuq;e}b!>{@VnY!9wP?e|Tq8F}B^E+g-cztKXRsev zSW%jax;=pn)O5nqSiZJm;);1~n(8L$b@l!sKf<&zve9B?Wjy0?GWAm1_xbq;em5)M z1W9BY&U&8-=KVEMKrmGrRVo#0fnM{h3L}ncSY^|5#u`B~e_JJl+7F8fliT`vAk*n< zs=m5ai{c0IfGQYSwh4KTsFsq`+#Q=V4J9%i8S{=RMq==9z960YkYS)e35ogvcnx2ug>8<5%Qmql+XjQ| zNJOu0cYoox?}jZEsfe$iC{b7sB$hNd`!A-$T7nj&OQ@#hKRpjwge8VyT;0b@DsEd# zRoL%@$|DS#G$(mcxciUPyo9SDL6{F2qYrN$4`n{_dgN;j})Ce>&SQ(%_Xw2?{a~ zB;K20zObrO=Xp-|s4D}6GR)NVb;IIiMZ&Oz*RF~WD88QVZ4X` zyM^f;1Mrx@7IyN#S{TP)fz?}4#&%8s*?Y~anHf}6_*QBzNyv|k9sx^$1cp6OxgkMS z0+#!((K)GqyCVO4YeMz_6dB_4n^y!>ZIrJzDsd3C#+T>mE1ayhKh~-l_1^LcE{H*& zaoFrdMIx*>*iH3%gU(s?o}Re-6idG+5#CyK8+WZPjwZjPowWuZFbKDz{DAD!9!uUO zA|QZW*b#qA3|~3DCzwpTbw1TyPw;>euqQ>WOuDT=X4bX}r_y!$Ncd1Q>g>#!l=VZ{ zJcX0sQyH^SlsodA{UOUTY1Kmw%Ag`zEGmyG%W2jl63$IN(`=;Q)h4@KAeNFfJF8O0rIiwgJM(AXXl zc=FYzK6Q74CC=W7=poy5b7UDM4?*l1wjV(Rf&8GAcPJeKFh+o5ynP07&12rRMRdpSzWmMk5_4;&$OJ72|s*UGnO(cS2mNZRrIpWOv7I7&t)3xbTfYps17rcXDc~0XBV71 zbU0uYCC;dVok`KTysG((A$f48lW&`p8)P>A;;dWtLvCug&N9q2i6xD;wHTdQMt_Cb z`ebH$F-R|l8$^rOd#hG(glFbIh^!Ged+(K;OoO6piB?2%T-X)pXFiUdGVnpz^qYCi zFfJA#0IPk>k4@2wMrTN8T?x-MTaeW_wn##QwDw`3)21ty*J81h$p7bTA+(DMZ^ORB z5M4n*{8x0cCLG;)yc4}#%Sr9NcE9%$xW|HweODSG2OoyLV*KZlTSu#1y?k)a zErRwC!hlS%?0Ug515fbq9RAEgma?U3Q1 z{WJe7b~WGHfxU$t%&z}CZ((NpEAuP;tF_WmSN*6dKtoibq9uTdELuqnsn{2H6RW@N zR}AE}N>11?ah2M}coC+6Mk9=vO2hvCzSK1s6kWiQK7P^RIMs5LHRAL0_7VPzdpnE% zc5ak7^y*dfJ9l(IgdxdeQ_HTI;t(3X5}v9ANwBq&nh0wly(0ge$F88QSbZwLV zeXb?^E z@hXf4Ox9aiI}VZJu{m}%D&gzn+Ofp${gwz2Ea`!ZdsWJxoB}97y7qJ#AavfW3L6d- z*2l9D_6udntE7EJbl2*Lj#^iu52X~+V%OBpZEvN}uBQv3Q!&W^Jk`oVC^Otz#!ON} zQ6x(^QwshLgiMg_XIY-lU( z9hzp6ja-q4Pw=}7pt)sMru95CC`peuhF%74WaZuvKXWWv&EymiuJAYNqlr<}a^IT| z7!UdNr5 zFcj)RFyd3_Pb`A%rc?f6^g{-ZqnH8wLi%800NQGfo5;yYei`5bb&e|2Qkh-sC;!6U zN9I9pVd7l74hsmei)qdC$M=7a>#c)fEt6nNo%kQ(Iy>{9miliX)^XyJ2;skOt7`R@ zI>(I`m!#+a2Z&u~r)$lo8|rP3Y(El z#I87fk{SV6V3-mG1QEAdtrmIp)TE3OUXfp&7$uW;q11D?C~D0el4nU{+&F^SKxH?ei^2 zTU!-yO3)OGcZ7?`+gW50>!w<`UAInhtAtQ=n&2hv&5>KfY1m5vmKp;Ym+=u477 zbJaNu1d=3n@(pY>O=h*OSgW~d^AI4EH!&wtWGrL5tZr=RscY$41EQUt2(nJF`;q(h zMMKO9xr0gKk!(UigIjh1W)cGJ9>9T`&toaB8GUJESNV_S=E(C$9h2YU$e)e)Sv8>KJTpXD-io3y*nzGI4LjMhpm#t#vA5ppY5MXk>_szfWZ zv-U7?Ga`D%IA_(sZ9eXFbl>R=E`WX z+*Afi;0hy*&XolmTqX&@({g`hw&PiIkgiI)tar1_%!?Bu8cWRdd={IBuaLUByV%fU z@mA|cgp{Ak%Bwmj z_-oeq&71a>D^>(_-{4c6e@Vbb)N9Lq6YCJhI3MX_a39p{ByIc2Vz<(1m)5!mf>52i zRhGXTWl}bpY<%8cc3Ho#GnKZs70=4o*)lwMlxm@f;Y!>HX$&tCI23`dQ@baXi-Bg%d8%%W|k-7Lj;QMPkXui&cy`D4^(Rp@Jv0O79uU_}w82+;! zsb~wv4Spp!3W&4$fA|Fp^a~6U>!?iRX=kP*JNc?soYvk7HiAWOG*;RfMzV z=TsZl(U7xkH1mw5zVMhJr4scLgNZymfvbcaoIW1mNMOJY_adaj68>8qzh5Az!>azt zV%eTCm)G@;PY7>?My(Y18lvtK4`gVGDRd}~4B2-EY zO8=h>k(K*j=wMBDO#qqSG6TMcjTl*zNPw|kTN;L}9yu&VNL~p_*Z-z_&7iU0x+b=x z=&qJK-DC{Futt*|0%~icK3Z+cE+r%F z-1nD+=Th{v9aY}uO{MWgNt!BY>p9NJf9 ziI!5Bhf%-IPQpD9+!{_x;|pygmJ0WuKve_{JR41p4|EE7h2_l#+K2haih&*=K1YGE zem)qW znlEH=UD7y(Y)p07Y#?Tv2>r$3T%!n=MAc5}$_Uu~7AJ%Flsv$Ak$v{4x4=5tmT<4Q z!Vet2C@a1!%2?w!8c#)3Gy^9tEM0$SHU4W>R#^Z$^9{@w7{S^N;(x}(|3tUUe}%+I zl}jZ&6?A?^*p-?B1VzoKu_VQq0%ioW24!SFi8lzas8W?GXNK;6^dp_)7x3@)9(*6L z9&la+QYYuH(^+3dCy%){n(hbWLw{sujtZ4s^~LQgi1NkkQ%2f&UGJ{&;}dr3b+KYM*N-Ajif?Gu z!C(;f>ah<@FL#(iBj^h-+QHd6T%mF0s53rTog91+5^TlO7~4E(pi^5>;e#2U5YeCzr|7(;= zA_0mHx`k4eQn=_&sXGbA5O1^Db~sn32yv7sI_&1+W|0%Dl0_E%%zzg&H=BbkrD#wT zr^R+TSCT@MA(Z*4g@-xuEgZp+aLiHOr_&8NCwDQa1M1|Z^n`X1^k{ZNX&h(YH*TT#jNvGTHVE!$WHjEv{NgZ(lBc?D{Dh(@kC!rJuXw&F1 z#E>oQ(9FZ5cGYPjlC0&lqSTeJ>4UjOtW9M3c5-)oJO`lO+8=c1WZtT+o2tjapFw&r z{y`Tl&H2q{Kbn_x%v+9b>?aE^>HPvj`|rc5>Ge10uIL@)gAmx65s>IiSD7x|NPvLq zDJxSgn^4RG4aO;Y@(rDLmIcCsqm^QrJ4ZjtDy3yyCmc;XXxe)ySiX#!W2w2Ek-T|@ zcGjYHXxF4)lag1%?3>oIC#B7ZOve@U_tCwhjEH%?gz6(-m6?|N9N1~-I=jR=)#Fp> z4C8$lfeP_}mwe(LY7p3iq5a*l-wt7k8m(i+K48+Ri0@nP6J1C2^xIy~A(odt(^Ff6 zRhEqc>|3=aGmPFhm89q`GE;(p2Di=vy0jyQCnS*{GxlH1m~SaPJR^ps`w4=bo4vkF zc8uFH`R2MUT|a!>djq;eZ4l9kNur=Y#c1(K9z(-3e%4waF`XSm9L<3yRO$6sv}>aZ^P^9I_x3bjz;A?5k$%CYu~v( zoERE9TI2l!zYnnn(FU1o^>vmVZBn*qkshv4&sb?#eq1%td)J{gfw8WnPy_wzW1I?= z9AP!gN{4mkuKiY8bwkta$YJ{&8sdc}no}Rr>Y4jQ^58O2K;s}9qOV(&Rz8pBA{yE< zzw`h((5>&IwiY4e4;XRZ^Mxs%s>QLkJjLBFw~TeX68rm>v<;>N!gMlu1jIA{ebbIlFI*k$9YGHCOMk ze}#u$MBdBb!R4wN#y4JWx?XbMuX~zyzVbXm=p#lu2clRPJ8Fx=?BPm{D z%8LCP{3OREZ+mkX2~+cqwPvr`)(wtp-HFrpX5cZzibEM2TXB5_6}i3DPSTCdh;eWA zBe7^-=9-K~*ay(0aVCs6l)_RdYR*#{@660Bd#A=e^c6bqJkrkIBCrV>hR{i#cy^A3 zAHY;Mz$&5K&Fb}bvzY+KcRBtGtR4Lutl_V#;JHSF!J6NnVC@kM*5dvXtnvH?YvaX# zfwioKe}T0Ozn_1CH9Pix2gCzE2_ye&h5H9-8`N=Tgk1RsHogM-2?=+38Wu0!pIj<$ zMkDohWG69o>p!HnKg_>S+y88)dcCastnrD{=bB@{+H))qWRZi_fBfkLh+RV1#3eI^ z40>YQZ2$+Sy0``*-Gike%BQC`;q0D|aSLC@?$$(@^|x~8+(T}t;3mvq<(;$zEeEg! z@p8Kad3mUma;xGiFCwl((Hwv`Xt?Fgtq!$>W+tDe^zn+9p{sV&c8ioKtCXCuGKKd~ zqK+-(Xc|6|>q&?r06M(i)87%~4kG#WT73Tm0g@e%F!MgHW&%u?z723f%oo%vPs3|X9c&<^DkkWVZhAZ=Pv(0hWAwjE1Ka{OEEjQQ^;v7r^#*F7g& zj=Z*PC)-~iZ!o@;tAEWgECe9Xqa}aVHetGWGC*goArLOzgVJ@#6x7 z(>~y;3%%mMLi+V6S zNf}Djz707vQv2$CgwyPkEuu2-22k>a(BU1muJ@myRDWhodPw${akQB0*T^7!gbLHz zrc{UI%&k4=iyfv}*rztfwEtL)bI2Ci80dbNo*Uo8PnIxvO-QGwu(u_Dv;|KWnNcX_ z>OE@kMa@>C3w}Q4am51fj_f6>cq~C0;+Mh$zF1WWP|}&*USVTQgcwU&PuLz~@gF+d zp!sGgssB?0#>V=u224k3P9B+`vw$#x2nq@c?!gZNikk3G5>UVVqwq7KTU($d9zAUQ zI?>0U=h60JLpX%bZ(pf~HoEo8g;?(QGd4QXH#+W*sxLpi;p=gMdzW%H#D|Gp2M7m9 z1EO(~a2x=%uoV1JgW?S}J15$+!HPh%!(wniU*kwFb zWx+$iqg%XbavEqY z2ys?`r+d6+v9!~J;5x*3Yc8KwXX)DhMK-_4X^j_EVo@bWPvI9P9e^UHNxmB-%K2rR zx{nnk10w0e>~pk&F{#ZMAkUa<)7y_`(| zEK_t{Um-e&p0V!~gOQsBEeP#B-qNRADx`3XEg)0?nsHaSF+R;aN30SUy8c))><`*vte&*zhSumJs zd;qA@pB{PP7fpcK&HX4ZtDxfk3BJR4b<{5as?zFN{l!_M+cAnehlk^%yxY#NqzhSfO1HoQY%0-eub zv~qu155>(ZhkpicZwglmJ)X%L6vfInWQO<8r602fOyqzuKJ`4_ZUuwyo<0}Q3kE;U z9FEimkm7v9S)QXZNx9$}c;jopxS`RnjIQ`V=P}U#(WtfYh3gGMv^_Fuf@F2wSOHR; ztsx)Bcitxslysb>*4cWFsxGYWwb{9a^)knvz~%wBg44?d5uPmBtGrZ>_)q+TG}dv6 zf))l9bEPJE%yTIz}>2&uoCQM;2_TN`W_^`eH?e9p>LbfFxXAh$QXr*voaU+UmSjoQvdo2v`#YqSho5S*kMvfU8h#ROIr2S2A2)7vD2 zRmG_;U{x`}{)g%Wj&Y?wRK@m78F|YG1YlM1PXN%E#cBVumX#VU)XukSe^CjwN#^E; zDSo9DXC}I_qNyBHYb`fR%zJM^6TPpfp@jng;r$dX6)7VPi+L3lYM&ZWIY8#F2(}8f zZhBq2)>Mj|?!@x0SSs$MG=u12j^Gd39eHdPdCcp?fl9_OA^STKH=d03c- zwYc5fc9}u-$-_)-d9?>u>zDHwzm6&rtuoVFFLd^j5;@H?6x#Khf5~}Zi)jv3PR<~T zEIa($L|@6Og2hjsvL~Y&F>isDdy8^ccq@)S#tmZc!tu^EIs25o}tVVF3o4n zd-_WKo}mnmBXv1@6ENMENujd_?tI~nK;dbK-JK!w0qngkqhLZILMek)oW*1^WS3Q8 zaRd2~`THD;m8?nfb?^V9a=IP5`PK#Y0yl8JBKl`>{-3;%^Us`>sQl;TdoIaFi)NmN z3PZF&LfUVaQXj=HY_&i>j%Mb8&8qlY+&7!AnWzu66rB(7ccP)8gQmYl#r}o#6y`|IV5A-4X$sDN+<1-G)R1TCC*8FzJz7D&?aADqDz4JD}ccli< zlD9)FOk}xq3Zni1vYV_6jAwVxC&mRxhdvW*9ht{#B|0hafK*2IZyJ>(&jyLB3tK*8 zoocj^HMaCwUVq|B-A4?NTfu?81(7$VHR$ZL@3&ECU87<@l<{t$x z8kHz^sGRhFO6ZwZ!&IzhsmRdoXF0!n%0)k$a7g{&fg5k_%5Z)GtAjfIMfR##79h-T zI8xhBZtDuL{&1YffUss^rk=wq@iRpdRNB9y+_JARdP4AX-Kur{d&xJ84Vz&h9hK&9 z%NTh?mYnpZabG`R$k(SwF=XL{9ZBFRIKqXauhuxt*~sU~5SP1zO0AD(Pj>~eL( zkj!d+ott)H&~=v!M4x)_)S%yVx>Q^ehlq9X5UqL2eDpgE;_SE_%fdKPT%(kiY%!7> zcC}`qR3O(3@i$ohvQh-;iw|RGG9@VzuJ5LgG#fK z;b+5L#;#`IA&Zpqzv^{=oLLQ^iuAd8v$=yB2_C=@Y7fSQqY#Z@2$2{w37Nv3a8Kx_ z1(c?exY4rem%?DXSOnzLV4@@Uwn}Z4I8Z!xY^e@;2+*0N6 z^#oVJAtD~Cq+M_S-kFY-T(ADG0x6b14gX&PDOqz39Zc$+10R&I`Y#FLVVcT90lDf7 zDE^^*nKsc*z_#L*4U%?TU;pZlKX=3b^Kc0{I1SAH7|%$(Ot&?0@qOlbLDYXc_YogZqLgF+{VWa5g?pvH`YkVq+N#&!IxrjfbWcreFl0dt%~z)w*y$EgEzoQOX-j!rbm z$4|*7As_79ghJ`0gOYx89QKmXT%8Z_Uw6aRlJwTgjK?V$Oe-9}NF~<~U;u zFx8bfa|(AedZXO{6NGVK7O|=?E?M0qRNplDr4&&ldbZ?}J<|QcAiw_fXN^2+#YHm` z_m*m2+k>h!XNr*RPN!18jRslzwsq!koD+09s1a*Wa-`Uk+HouEQjPvL@hr5*%n*|U zfdRMpjeLPa&qImlGXf{9WjD3QnAvefuY&rxxI21zkjX>Y@mUz7H_;&#*g1Q+~a- zr-pBZKQp6heuB0D1USI~LncuI@5ntc8z6y*~` z8ib$%@rrOqK@R%~d2OhWkTr5^vDPs>q8hv3b@f3s; zHr$OMP9=>%?~I@W*}5LIsi&m7+7oAe zNEH+5iYY*x>Xho_w?LXJi&T%L0uwBdj!S2_Z#9vIj#{c@PoKC|OcyNwiRBO6bl|hc z{mx6mwTGV^BRba9vUcc~e}_^sl7+^CEi;8CxN!ialGvzu&aqsbe80)r!iGdTl@xVJ zeOPJHFc{OiZzD3L5f46z^7eA+WjJ009R>$VcbQ;i3diByr-biWHCyaef%uaC*;u~_ zw=t62IlzbCT%u{rB^g933*p!{Bivalbc97iL8m*PbkQp2E539QGRC3WDgYyCwE*at zrzJ6)H~HO|!BH0Lz0JR((x7M9_}0D)rwxY2@c;4$9x4akgq(8b7>W%w`VB450CATG zvZBiTy!n7s+Do=+_sXn&hk@411}v|W-m7Q8``K%vdow9RJmbfDIe=q4jPPKgCEJQo ztFHdKFDVkdq{=&DEsE+>mXGslHPN+dNw4NN5L&%- zBgST<(UaQahWZ4*CDMKcxy~q@)Nvs4{ZL_1H<^#M)@?Vip^OqfpdXY%Nj5dc<8_JD z3N}CuW5HQC)kIFordlzuM4G*>a?Y33WW7HVTM1($GM3GzU%?C(V?*%bACeCDb|zhR zu#Lg~uaXXqzX}rXKScQ0;8RW;x~f$ADg|G#=5=Fs6TupMI`SlRFDi3@Z68bhzcl!d z+vFb~-aRV}+BQbko2|&Wba5Z0-|G0jyk4VqQ)Cs!c4x9(7_dirHfQ&~L;s8}lYouN zh<{R|27nJb^z<2}Wk}AHl75UjSH_DR1lCUI&$k=)FwHp?EF*b}_kP+IXCQWznL4yd z{~7fWNl@51^GlC~1od;;Sb)H^eap#XX4p5c-3~al#7z0s(dz< zqb?*dO!MGeo2a*ogrOqb4fwVXDwk?}{Kch7M!uQXdurNvY)QwYP~Se;X$w#HAKf=M)umm(<+lciJGpr@Z9E-^(9x)I$pNiuTSZ9`94EleVCe+*tLPX zugMzp=9AbVzM+7l2m_3$RXHtKf)3hWva)aLhK!5X<^5=Re@iyB%~-Hiy6CYFt<}Q% zN|sQ2P;A``H0r&BJ!J@*H3?`C7Mz8l%NYt`$_S8z1q~*D1jw$~f4bI9Ex)lL!#{XZ zWUcNhVIq@l!HVT9XD#^2x7|@jC@IS4C$B)%cZmGSWccnyUZt(~QJxn~wm{;A<(Ka( zr}p(Ims58p1IDbpcBqu_l4)jyI%|ct5ICx>UK5rxtDdTKe~Ma^2<0rPSxqH<(!@%m zie10T??!2>x>)Nic_|`*o|!M$dn88K6DX`d66+Zj) z5Cqf4JK#nbxzs@R;SXy&ZLSFUCu#dCIh8Q$OT;>fXjV3b6)tEm4pVgcc}G>wI#G_m znHeTskN~X3O&JbrnW6zjdrJ@<(8nZ6RB?Xg!>C}?A7y1wQGX4kw8i{_G3Xx1_Tq;q zdMq2_fUvYjN4MVXU}e2Sp&H_xxCss9ZnYhVux!RZa}sqmkU>@3rK(R#v!T5GhibD} zC#|Is++l5lpQQinw3JFh(+Dx_6t!WY{M;IlQPKy^QRyKAa zZ84JJ3gYiIcIOX|+H46(el7HUz-<*r(UszOG>o=NzMGS$Zm-MtU>RD{-q|7^nH`Qs*YTP73cVnoISx6=N0R9Jj? z9$n@@Osk=!YMfTX)ad6gL(=Qi{f@I=GlhEDCcf>^L(?A|LJ4~gy7Y^=+lsrTdlic$ zO-5K=&tl$NIe+A|s5AQs0G)94N+&j7!t?wv)viWvIw$SKI%4V6bfGhEixMJ8OOs(@ z!@p8YolPA<-aI1Ml5Q~;M~$I0T5)`?bo|93Y_#>=xQwbojyAZLMw(@6sH#&xRXVH0 z*|U+2pI&!1T43veJQzEZNz9_noGh4&=+R`dX!#6Y?M+ul42^Y3-IG9HL zIeK|U1l`Nsbgp5rpelS`GKsG?j=Udcn5dndG6JXJbPJfk>rFmmgMWvAJbyjdO_Jn9 zhEPyrr4LmO={!fB#oa11omP zwaPH*(EwT1Sk)COoT^dWL-p!Hgmn7ow5nyULh`Y5Cu?Nw4x6!tg(3R9;>Sza>_!JB zmv^D&O5eoN^XS~kMC;nyX!$^92M$9nd&wqc+dnW&pgV|+3W?}V%4l%8*t;Q&r9#cg zO)x2s(V|MGun_K$(}_fQtVu+W^dlmE{19z0n?tIx6;#F=K^Fc07(1)Dy0;};CqV;? z;2PZBU4py2ySuvtUAViuCV}7<+}+*XHMoSk$lmAlJ?VRU_m4*&;9>r&=Bygy8-6V! z30H__lpkE}=-L^^BOlEGHMoP+Fv=w03pcMctd%sq9mzN?inQU6_3xXpj&kZIahM z$*;RD~iOtXBIxSTbH}FE--hsz$hyy zXoY%-qnq*C@G)U8`h_7y)^4FGAyctXZH{4u7j#PkPK<6;Ox>KTRcE)G$jY#g(;H-N z71YAH8FnnURz+U)d2sSF6Lb)InKLnSDK}ucPSp{=m>&j5$`n{Hw@V{$v69*i33c>Z zOQoe$*+WgSYAqMazp&YUyp^kA2vGlc%V4_ccavQ&-}z2(O$PZXxvuesNV@J}y#KD) zL!-M&4%w0U5tnRfS#45*ck1O3BPtQX8l#YBY)7J@uB5pN!5;G`aTOJr*+~XYQ9_}K z)6>d>0r?QXq$>CpkI9M4wYtZ|@z-bLS@hy>t!3%yX{(1;_x2PBd51@0!1zM#)39zP z-SYiUBNKW@D`Fhwt3x&x68^dggYZQ`y5-~Wgj>u-I}#*=goKxXT-aK2B!v|i0o!Y~ ztWCN48ykE5{X+UB1?Rpch*Rgfx2DOxqT@23g&wNIaTf*T*qdl_5^>vcF^-SM3K(f@ z+jU4vXgM|CLu?^8iR1D~Y>fx32KM!SIt~U|kHpF};WrU9#7+fy?f0SSp!2Qs0|Y%*%!55Ce9TyE{!HdC9EJ^Tqh1bE&bHWjcZ{^5L+(pQ2!Fohi>@&4) z1YY0}^+p4yMP3S6!@Ah>6v+(`$wP+o0M@GAy{CYSFG8Uq`x4pOo&1u{KnI$J>- zIgOf=fH*uQ*eK6YSr{!emyeb{!p9fB*(i?2klI0j6_Pn*u90{_#2>rC&H@h-tzAdP zxDAvxeobE+|60h+9)cEfL4qgbq`TLcq2fKs8qGjn%pwv@;oeJD zRb0hm&&BTy<%bzNI;nNNutes5=_JwRNj9i;cOrfV?2!D)Jx8b2wXOU4V~W-{81>5* z%GB&y$H93yF|OqtOYB)8;SO8JokUCwr^lK6jTXlD+myoTAycp^@%Iyz`p+NdlS_K@ zZ2c{AS_H_xO@)+FI^~ir_6X5EXsGc*=4^pM#^%Y?9*Kq_UyVui?D_~a^I37cpgzn) zY)X1aoZVev4*c-(3nUB|HliRAT=FkXq|Z6g6Iv41SPJh)Ck#!cx&)jWh=u0Q(4Qif ziua2VoB48jFi5=XCAal`7D+p3koA+H*}9SzMK(Fd?@XYACn63#HN$08uKarS^oRI- zE6#Gi33Q0v|7n8%cgsjrI(vmRfMi_|#3T}uRm0QZ@|9nw=>-PDFi}j&i;HB(cHSOQ zHxFOhUA2|b?Jz4&?foh#dPEcC%dl|WeQvmIbv)zRJN@-^ea+>oYz4s(c!#hX3*8wE z!HdR1Z7x=}QU!$K=k5*zielk1-LV3OV$4CTh9~uA6*j32`ap*yl8mndAK@nS)S`Fb z?-6Cwmdl94CmFs5sz4~P#tV{%K()F*yrHK_lK)e!e@ zRztq(E33ht<4z)sa%L$}<^{xR2zX^R2tFJWCh3#a%M{F8Bn_dA&taT3IBu-V?Z^27 zo&9;?4KPkTliqdofji5WX3zIiK7P8XAM+`(KS8VYI!<8_7UE&)g<(CW34N}J_o{d3@0 z0?_(!mIPn7%%Lf)VwjNAQ?+%tqL9^W833wd(j1hzgc%4~1S9mTBy%6;zD%PF4-}4{ za|Z%gG4qncW$S+o571xFspzE~hb??U^H4OOL>33H=@i^p{v5DEZ< zzf6gDx9(D^k~2m0G}Z9ri#pfp4?HKeW~Fc5;z+^TvZyWcQSy*kJnW}K!I z;K2ou8}oZi*nsJHynXR9Ft~u7C)0q(7s!t$W3OoSDOYXSO!yU^dt z$35oJiIENl0{pxW*3YrV zrSh^M51bz<$O9K5sIOA!W;}4I!edD5&S03CZfwC$*NBq3;#kE7>)Dx*TP2qBL+P71 zCB>#sD2x*ezW%uC2k8y*A!mP#BFqPC9SRipO@-i4^|E9wP04SuN;03XX%YBWzdaPW znU`+V&Nz*h2ryru_SLNTCe?t12{epa3kZH%&S2*FVHko>jYorg56fSK!B6CuBMp6Z zm>?%pSU}|)nAJN)CJ%=VAVMEcl!Kw*PAuEPJ^g}wsV#=o&S%Q7*Yv0yz0r3ssYq55>6yqStO*{fVYGX>0B$!o7zeyJ`|03SQOdw`~r0YK+Fw;n#Y-C-PzOce~k2=bp zzh&nAI6d|(w>W|Md>XAVw$g)zG9Eje!Eu{Wy~jPqv8UtneA1WwR;#a1p7`7X#eSQx zU(_C~Lc9{C=ZIX)-VppK>&2p> z990nhr|^TTHh-U^@{+kRoMW_W_3}{p6-^hDwWLXw&W|ip=#z7q>`$hsK(lDBADUH< zNur_H8`n5P*0^_bn43IH*AmwH%QS1qB5OpBwBAmB#-|rg`l^v1tqms68^|u<#ObSy z>jID72kSGM1xT9XOf3z~1|D4|soj7s`S*yAPO_n~|HMm)>%NiGakkp7-uUpeo96HlSZ45sw;7pvgCEuHj?Z zs6C;vu+8+Sksz942%F+ObPLxnsq<3qTH;*oXyzQv&()Xi^E5kj!^7Keh;+vJsIVNn zDLB4N_f&twAcb=%_?>tgBcwsCUNI2gx&0F=fN=-Lw2xUR>@Wpq0rLGl5im5qF4<bUlIwG~V9b5JWb7*WLy!z! z<>M9MG3O7mY>Y6@T^F;!TnkN_#c-!xw3YG^cMVxx^_a~tlyCr(XwJv)Uu$tZRikLK z&CzJ5U{igc6!%5bp@60bH9-33H{K>L1`bj^?y=b;LQfI6#E)u=RS)~w3uouYN#(xh$+;NN` z*IkdeBq>U$h*4?n{o#N&mtY&C+S(U)Ndq(JT0+TtS97X|L6p)3>lW*qojc@;J0;{5 zCe{)NJ1&v&D3YtcJGpPn_V{{(sZ?6vD%g_oYXN%=BHJI!s@CG zv$0^QsI6Gw@>;_m((6%ZtQ@k|-p9#5W_1qcr${X_K?j_&W0h{94%rwfKl9icH>R&# zO3lpBC@N+3)9hgSlP#ZQWNesxhKJHlUuLdwcyXBvwSOjkR*X(Px8_Wp(}t82dK&7S&~&Tm)!_>($c8a@+#@AYouA%=eNL`4NQK+{qCv9=b5eV-52r}f+Tqlg zK@r*w5lie#2h-0G_-yRq+)tlCYf9PDbD2D0w&wHMbB*%|tu5sTL$J?7b7-~@d?eO1 zjyV_f1Q@yERKB4&2j&yZ%|LV*GLRf5E)0A?F-900(tf2PA3yXX^xdR%+~TSxhDglz z5=Ji8CM>Tw>|`_RPtZ=W9dp~5gW|JLMh$+ZTr>%_Omv|v_8Mbm0&fPvC4~HhVf&dq z65`j59CH+kGj#!1gJi8? zjmxc<&h5Zra64Ur8lm6&Nuxn`KK29ILxRax>0!1lO%%WcmxTQDCez1@KgMYS7EEvS zL7j0H)EWQ3H^l$q-~6S;wW=Ce)H5`LNdZR&%zx;3-@<2^`9k`;fDJsgs%7y6I zxEjJ@!xnO$v?-Br>YCUh?~QMTs@w~Gqm_Ih!}mU2fa7M8Nb_zmHj5PF5WCy_&6}B# z0L}^a38=6Qa`%O1%jGlX0nUEBw1!c;-klMPNYk}1R9upGjWaV(l$ASIbC!unRs$+} z;l`q-;kC_<%3xC*H12g&f}D#ixmZ=b^i8w^wSxO|oBey~P~eyKD)rdzL%hx3{cLc$+ygATx#DxCyn5 z$qF?xB^u|Ww+!QLqnm8fjMyLS@u`2IdxahCJ9eMD6sSphaI9u6qxMKJ(>wV*nc5(e zqJFf*l^|8wXH_m(w!R0vxPrMN)kOOQd9tVmF4UQtv0t(w9aYdj zDOwoqLJWV-79KE6Sk7U_vf(DYOmO-fcjM~+0A_lw3a*P^0j@5y;RK%c2?Srsc1oa% ztWLqN5noKl&|uT=wu=G}7)~uHB>Sp(9r=ORka6m1FViDi#CI!=^Vi^U@pPpO2wAypZEqW$Njn==wPO$z!n z#s7y-ljZOJl&LJKh|G`h3&@R@B>e7O7`eZQv>OCo2o@e+H=&Z7QjowGcC~opz{HW$ z*;DWh3-@y< z#mr8G#o8z^aT=sd+pz!-ZAH3RfOUlnWU}F~_o9{ntJqwkJx;&vD6KsgVfyYfjVO{c zCM`+Ste@Ihm5tSj6cTF=byfFIA|2QDj77C$LzuKoR63(8C|{QMcC0)y3Cfg!qy9S~ zI}(k=Pb@lB1C!cz(l!;2YYHAC7>P?N?RwidqIVqWP=5H=PDCNw(@IGMb(%DBJEd~6 zxp6Oi@o5-M-C6Wh3fy(uHx@LT7KditNNn|`>v`J>Mr??#?8sEbbS24so~Ao*w2*_^qpi{S4^NW%W&+NUoNB z6)#2Zs)R|M6L$eWq{p98=FM+$=Hp_PPDuris-U<1KZ*f=@g~`}^Faw3j5-_Rd!H)j z2aK}Y5YtNyCHSSMu-d`aV?JPIBeAUM1#CPa9(m0(GT8HlMsf9*#kNR-NJrJTm_WsY ze5;BUyDkRpi@>qMBjnN+mYc)lgh>6`1tyft}nKd{j#lw zS>R^%_G0U^ZZ<>A#KL9NdMNh6fJ}cJk(vb5Odf@uLM`S@Ey`%XZicKEV#!x!;19jK zFI7$d#OQIO_4x`}<5Qqd<`o_po=__h|0P0CEP9h2qZi!aKELhAY_MX;CaO$+HlgyY z@#IVv1LK4GdqX@#p5ZT3Vg2wmqCT@btREacD*g2BBt4)WjlIPm0R08oeG40l+KMa82Scyx>glx!bW#O137x%K#btvHSkJRts{q6Wj%4>xBS)W7L0cUB#`e zj7{~e{@OKzsKLT+hQ@ZU_~5^kj*+n=GM#)VL1TQC8Yn127rJba9rHo3=vb_f$Vhpx zk&n4DgDI173MhC(sG+cU?ZJ$znppYJq1FzknFAbandZEo)>hwU==XJE&#Dhnf22+) zt&_5X9o9C}6?OJ=+S%7>ZvfyX@U<3tJM-+76E zL2EBf&{bIu_1k!4ITX51Ddt8vt+N564(%Oj<;rSscj9Y!4w`SQQ7qPY_8Lu8uOBBP zp`IC_6F&Eza_XNCLxsd+ihg)VpptfmRw|atcwcQ3z`tKYHB!Hn=^l$bO4r%VEg5AT zWURb?{yAGf?<~2JL1}b784Rj_JbJUwcU`>zwuZBivc#l}C`8pl;ky`3S&lFNEVXmE zA@U4K8K9rUmy^TLww6`EB2G`9^ds^t(q>A#Htx`cUDGMEibq@`k%|xG7hi);4QVy$ zf&++QixW3+LnM_PIR@ZKwDlT269zhYTsW-v}8>W4*DQiaEc8JEKM>G_ip=4ybR`J6l+|xgRy*QH>dQ_ zj}gl=WjLN*N3-121DZcKjXNycEYPo=`=kzCM%jLxbg~Nip|tRoK}fb?N0HN#kr--u z=ecQXDb81mz#Q3^xh3Unx zoL>giY5J${$URDmkzPX6_zF!LpRU=(J>1emSY+TB*gFD(=-c>@-d`$1x!FB2X*Kl3)J-BxANF9IKMO|RCKGhe zmE1z`aBz+4OoKsi^EVK7;nwncCstn&;}-hdgw@N-z+8U4}+l;60IaSvRyntopf|Z zcid(LeXF#)&7-5xIpV}A*UVN9ph_M?G^<SS|}>G zgy6%!i&<_BGp3uEWt)SECFrCsvG!A2r%cGrdPGcQLi95Q*i;Gw$fgD?f0)9Lb*9At z(z>g@GcUN)YKn4SR{ePE`!_!_|Dt=R^|Uq7(_%LDZ+@gISM4!~A8Aw&U;mpQ$%+Ny zM;?Lrk^X+cVjuT~@w&ulx*ei+V#SODUTy9lK{j^^xLmdB-|}b)cO_beS9!GZTgq2? z^eURpSERq?(YH0kAbGR^NFHtfTOQr_hddf&bN@$qH0dAm=$Zdp9v$~v9?kSy9&G`# zxd+9Wy)UHI`?xm`X|tIGvbm$%DE^l`y6pcUkJh*z@CAKAukz@!|0a*-`XA)c%^-O+ z`+t*1FZWK)3H+~0Hsil2l7G|4itB4glKhjxP|p1qKcY}lD)i|WB~(gnyn2wi;QCn8 zNHe#=FRC*x7`FoO)uIM%9AjZptG?$I9mT<}pgb zB(i+9oYxC!VYXImHBcL_U%hnHsccyJQd4NT*rHLrz)j1Wn=Nq@3UvxfXtuUVr4kGV_%7ls=Ha|OTabvkFA+|0R9A8VF{ThY{wtJ4oY1g(RRKzNLa$Q1Oen5Aa0qU^S*pknDJ1dEVMWO9TOpp2pGoW6m?<0 z+Ix_=7NYLc1heM?bNG^)QZS(tl%!Ir+=n5hW&w7AO67-DIJL74pLNAfJn$@blY@$G z%PgA%E7}t67=KuD7D$C9HHGm4 zXW!Ew7RgT-6v9&GaH% zrH@d-rf~7U$vU_2su0e)2($dDlIa=sDVsCD?BEVyqAsz4`U-=@*`q9<>t7gET^+Sg zq=J}KiK24e37Sdd7Cerre<;G67DZbz6vjkwP*U;hP#@ zQbA^=xdHbebyC!N()^N1b@Vg!cR5$gPgOesKfTpdE#8f>YYm?!`l@^(nBIj-+clzG zp+j{SCpgNBCZ3_N=DCU6tFy#S{se!fn3P^4P-=-HYs(OkVQbfu2nR{DTzU%yKra2} zRERgKE8N-)-nCN8wZeI1aM{bad9^p0{q|v!oNuc@G4jdYBR%za*orYN;u-uAO$2U= zNk%>dyNj4Bpd_jbE&}K*rG_C$T+=&nn1`Gq6md*!qX4(ELDXY5JV^Vi{mnCB8^Uwc zyn8Zu3*vp`M8CK!+0obdAkBtvwgR&#r~77AMGS(8rUOw(tfFK0-#4|FVZGB%p2rRK zh>tr3isrxn`onVt-*U78d@YmzBv<$spG?sjq;Nyr8!Y6 z)yWJIiOSM7CyA5uE|*97$uX=bqFkHcIl4GNQ2SP4lUDTy6NB<6Cn3qkNqFfWIk*Yj z@^SiNa~!q$?xhHe6(XSm+g#t5{9CZp--cjR?l17$|?`6*HKSjz^hW6Fg{i@9kL^gtQ^0%bV76#^!?OjrXY>#U`a^Ww9A}pvS-N z+rEcv;d5xBXZ#6wOtruO7*ERq#3UA7)83OqVFE-j#Fsk|2pGba^HW4L@@=*HVERIE zh<@$BOzH(*-4PdqQG`FrbBF3z8MyJ)@n<1w;B8J&jA41?WgbN6Kkdq<9-05`gj!MnonoG9E80L|TA0BVSP5t`d}4B9zB0mu%6D|RAl#!7X8g=|$&*pS zPxlZ$5y~1W_v=u1@<}VOL80g_mZMoyD^p7=On-q#Fs&Oz|wZRaoD9`X>b;Vd;DfltHGmV1)YL=CiEUK1HwH5i=}t) z226sf^I67{GJ#h(v#KJB^_hI_WliOmR5+O6VcS+CKQ+~t-05Vi6-;jh=Vqh!9Cq)$ z%E=M9k~3rK!pry$r5-M8HiOEGgjy$a&mbeIUMkzqQ_=S&G#6}dX%lcP7;Ot*o&(So zh72l>_!g}X3(5;sdN|q4T28~zQkn=r!93@^ZFwG5li$7W%bUe1f5=3hXwM%;{KkzO zz2ZjJHaa4m`YO&rxDlSeaF2d};T}u=jeGnN^p2JKFWe($9Yz>{1ZZsqEw$zZ?ga7c z2rcF4LR%&?WA5asvDp*rorq)P!)IZk^2$L5L$h)wL$lq~ouM!PCXZ?sL?INTe`l1O zVlv!SJErO`CbtNomyxGuWsu?qi{GO6Uy{d#b)^TkhD0JaU#<4=bce|5#W|egeG6T1`9Ddjgi zZH>2zDZ7)QBYH`U^yyvRl(%o~Sc|0^2}>@^#0;S`jM2FgYoJ-*G}d(B1*145rV zPjM6yL^uJa7>4PElBJgtr~u)mPXxTXnBhx1Qv4o8+M~M;Q0{$HrhuObs!UoYF3T*% z#4e3}Uly)D396+6GTo=pmTE2J-8_GoAx>tx0&Gqk$CO;a07GF1O#2*5Xsc;G-nuah zYh^+X<_tp!9J?x!Na|5o&dSPRcotL0g30WNBGO@OMPH9u8POC}nd%zkbAhlRnLLkK zT#F(%3J{PW?6p&9VXYvJcsr<+Qw;Gwpw7ntS~V@|q>p}x-t5AnvO9i`Q)*PPWqQ}d zBfh}FW4c)k9HMv^bF;P-h+c7&p%`Sgc~qy*@kx6!DF(H;+s;t)&Y|m~FpAs&5ULtQ z>Om`r2X`3_FBDt zxtLOZm|o=FF1^UXG=Es1|DC-Qcu88jk*`n*shGHEd&CU5^1vnsWDMXT_hYV2@%bCY zKcS}rJ=&q3ph+j!|FBJ9_?KT0G^qF}io#-#=#?o1a21VdXA}Rbr!2Wy#&|0HmfZ;=v zMgUO-^Va&PZ2R*8Qwj#-rI1E$9LQ>Vd`*~iX-ec0ID_B*C_%37u^`&)awVZbX~23o z$mXfDPTuGE{*7PCiK7=<)1djlH)P4W+TdQSeJAiUrrHvS&ieQAE_}+6w1>{RkVd9U z07DeR=LVuh`CE1y2d}Xh?r)fORuevJu-=lNT$}<#n_C*bIZl;YHLApuH{3)i!BvdL z%qRL$diqJ|zf-rEB||_}71w;A;O>n_87#50Dn3Vd(1L5%M$2! zHNw}NI>@x7KTdxn@Lu{Bv+HV|KYt5DPp`r4mphZzC*&$rI2_CeZltI$ADVu0CD4t7 z>yFE@u2ME4;^IkW>z8qXmv5o+mYRR40-I*WsZ&xOt2Ga~One6?rc!-!Q!qv|@hMYd z^@As3qnB~BolGs}pi7jwPkZKgvHR+I3!4Y~L9}{X0P~92L+Az>kB(kSfi~!$csFn# zw9u&a#53RAZyKf&;MES0inlTl^W1>l&Z8FB>MqQ(L30e*GW7}_LMg5OCFogRP4|K3 zB}ffQ&=R(d@Y7t!xkUzXK3JB_v?_o`7IW(2gUE+w2dIHA-Izw(r>IJFWORpK&xn_M z+Cj;BQW4KJl$lu!TZ2IQDExu`&R+d0W;At3Dl4Shd2k78$+ljl?p&!9sW>SCn;apI z@^wn_#Y?;j#`2A(UFk`JYA2ne29jf&sZ7r#ShJxu!hNe~?Gw&znBBgF4q|&_m$Axi0^2@(9D< z?d!i%=E6ZKb7>`|pp?11o4le_36=Tqz{@*KUtAb(hR(hIpkS6$Z#% zrmRL)-taR-FNL6JHUUYO3rl@7v>lx;I^`liu(4S1+s6tuC)q42OVp4V4U8&=KIT#C zBAP=J1hRYueg~2ffYRek3Wy^N#G_=T>B_T^^ga0jY;@Y@l=5Z46^nG5Wm%P+^RytjnxRiV#{FKeM_i#h1 za=JlzF_d?WNPiQ*O9ii(bb%?=(;vh2w3OT6ccA0n4C-5We{Sgh<%<;3chdiBDd!wJ zWHZHw0(?Q{g0q)e)bi*uUlf3i(hT7fqvjJUkt^+_gvAd(B`rtQB5^T3hQ{^l)7Muv z=m0yH3t=*h7a_44H3o&vz3}|He(aNdGd9j7*W-d(|DC9-9N=o)K-QJjq2K2Jh{}zR zkxYmdpHB{l{4NtAsmLJXA^JVlX;4boFSWq@){jB?d!J2Zp})<`PO@Y7-;5+nu1P*Q zw)%*aj7Z;ghT{X`k^{XTw@FV;KF@wcxXRzpIHPIi-_I_J&&B(U(8Z9=ywZ=n+cDexV9@Wjs`bJsYvVhXOynxbOQ8AIZD8smf029xxy@K_L{>K`H%my893EJF?gQQKTmp>rH=L+sYr;BWL^DDg!Jo^dk-{D?=Dd6AN zVx`ycFq!T??xv@%;I+wsbbj4TIEdZN`x(=Sy#+uDxGBK5XqvvuY{p?Shu2_atJCAc z%T`Mc&jTwLHnNqL1aq994q=a_BN8HLJ!r1(9GT1d&{XlcM875Qw9u{~3H+tgPQj}L zUSqJFtIKEL5+ma47Brio;~28h4QpNq9d!XFVeoRf0^yKCE=Y)T0VZu>u#Y0z`SoEl zaVNR%5YsU<9P?~&(8Fk4*M46RyQz4kI)rn_-|{I)D#JqnJvoJ`B|y?(XS zIGM|Yf+#bu5WjKcylO%01!l!=7YhxJn{Sbi7ZSt3lpx8G9A1bLJtDfPZ_O@VFUlNx zhe@&vd2uo$0cr-OYwTyVB2LcioiZpXd85ZN_FzbMB^#+enricf)-qwlF+SOr=`|^t zMG340zxVY$VE413P+bEEJzWfOHf`gdl=xNolCtd+2MhZ&`Y*_vpsxt!leSbn_{{Gz zM}JG=1wDQ9wy-!4Tgf@!Kl3z=OQ*tsB=OFsQZkt_uv5Ad4!|!|s^q}B@p%rbEtI8p z=ViHNr^62Eu+u`v6mD;M?Gy2q8}x11FC`lj@OZgFyadaa9CB_d-qd>g@)3coA-SAg z^qB$}xsKoj-k~}WNa?`$s_e814O|CAfW z+0k0O5*}7T#g_2@S8RWWC$s)x_Yy>2}p zb#mxZmzpX3q~2<_3Cc`rGxA?mhpsCD#fvX<+>SQhW^wV3wt7RIqp(1!FFV`8-{O4T z@{0^+#bwyef?^z}!$2GQcKZ}KCqV02Tc_f8Ji{}_Rpx~8Rb(0!!1RqTEWpT)dYXXu z!~0ZA;ZN{fS`a3m-)c1Bbpy)D3gseW`YLlyB&ZZx((t@C1F+_5TZA4sLkjeD)>g}z zjAY$X#_e|**age)au7*==Pt@Ko@(7iW59Hg&qlVM^D@f@Wt;hDC%$B^qt5;Kbh^0b zll?n)u?3X7xE3kIL@k|@Ct&!RyU6gGyBMASckbe0cy5QmN8`o%e4%>-I{MC$s9xLD z*WASieUvFs?&7JaHt+xp@n~+kEgakGBPzPG)%?8E1%@V|Q2g)YWSpOwl9HoCX%$(x z)}Z8M+5HtZ=9YU0V=XKJ@z_q~2z~vOeISySs73vfGGP=?X@0Vk<{)zSHz${@_Pj@{ zED8^Cs2KVhE@CdxFPbalH0a+9Zm8z>m?>pnu!RYJB7ay=HppSu>bg$o>wz1_kJo^x z8f~dg0cCaA7-X?a0qbk9bbWrp7VQr`<+_>)?*gV$b%`i0lPNCA4LU%9i!{rupuok= z4~5!WEzyb>ue$uq9*Yz3C*_5^?u4v>>uv67z zMl^k<#n??RM>Hp4CmCdjX=~HsZv+;wg z0Y#<&+DGN#vv0E2#bUuh3K!3BdQB~eUgN>EJM!HN0+j^oF!;qy`ny8)yI(#(#3X(Q z2jOUO8st=t3ngA9fij5Lot(GOdY|jBm_v5?IJ593nWKUZ@Am*qWQ0l;B|fmECQ?=! zZhu%edzR)|RR%4QU<^ecm&7Jyj_CM<7vC5Vo+QXcY9?Cflh|%6g7$sAI_eL_ z1F&C1)Z?h=Yw$7~Q~El&9mIMk--!zz5tb_wgR#YseZbjZx$mwUIV2-)#lOew&4yNQ z>X@Y;nk^P;4Ata>jk7Tn_l$Rf8tT@-U%+32c@AgTqCD+QDL11LnQTB4%_W(3dTjh- z^t^4(v`h**V*kvl`BN8U)m-ycuDuKzm9C@(zeh7-79&nWV89=qByz*L3GiCTPDcrU zQ6oq8f+QKB6`#qHSa?#Vk;~{k=;wOw43GCMch?j^p&_#u=c}}Zvk%O$Mh+YRz{gMx}jtw2G=AJnCK zD%?Hb#v!N`h{2L$i9m;pW}#cn{TUW%jo})#S3MB$><5oRkb{_v+{q1g8rkW`0r-<-=Uy| zuTW5QJT%0T5@{HcQlv=PE|*2xg;Ulg3O`X~-6yt(yl$bbYS1w=?U+nLa%M}7_B_k* z+{B9sQWK>gc7)h_1g+O*G5VrN*x-V|4kZ31F=_@lf{y0&ZY_jjSG^d>k@|dL! zwLU%9c!HN9ZR}cCbOCn!&=37)Vy((fcV8}^WVSln*w}?5cp@9|Nu7m!WB-2rCuW(F ztup5?cw_pj#GvUf04g%{N&r`j4|$JAwN{aDRxc*`ef!NAFOs`X$A9(#cv z6?+_*HqW)F7`>bZtRDhbmC|au32e9sb;P?0fY>F__X#N4REgLV_+7&q7pB}wt1 zgcw0vO;>TiC}-7Np%)r@4Ey=pCupQ6FoW4|5(@Zr$C=(tk0!e#K9AQsRK8?()JC96 z!+NSV->!{(E46Ie&-1geB8!PSQPcLG!Sx;Oql~C<>+zh{98bv=MudkOF~+V&E6U@* zxtx%;4+cWjI1CT&mD~2wGp1RIA0E?uc>-Y>6n7MawW_B6v@7=u7#^n<$B2-}Abg?8 zs@qdZZw${azd#>|vJ&am9ehn68x}&C8zkY>=VLjb8qN5lJNP|WgGI3 z?J$t!()tvxBtB|WVw$sw|8Yc(x$6=nUSghwdI?L@BdH_=HF3~BPcV{2hMQqh%_>jo zQ6@zw%j`j;;r`;zng=e=T#OFdFik|Wgx{^3zM0OTR`hwPzk^x6Qog4sl;$J+_Yt|Y zBBqi1HXC5=`?b=VZSIQu&4Y`i1Ip{5MQ`CLO=?xrLV^So3axI8SGoD3#_Wbqxxvo@3MR(dJ57 zzc|7KcDbmlzI5~1Hv|q*L`PDk2$I&q7-f3wRr0w5w0a2b_W>2=Gp$dvWW(dh_ky+) zVZ-plCO%Px{n^F+`5~4~;8o>Yz$jA7_iPj=j0=$W5?8w7n5vaY22CTH6mf``(ygL{Ugx5wyL5ZG^NeDu?uD&M#W@)OFah*rEaD&@ygwBu0%p zOUozc?eY&8(?)CWF^7xxLUT?p=}`*G>z{H=7e=A}4`X*3RoAwy2|K}pySux4aCbs* zcXxLSwt~C66WlEjY~k+i65QRtm3{U(w`$jS>((FIY7J>Xn`@3S`{@071!`Iv5J~@L zHIMY7NL;(uLbK~BgIIPy63zY>tBJERf$by;Vl_KJtR`c~|H^9G@@%{1&{~3U^2$nB z_6<1?whBGrzgbP?zgW#2dJwCrY#1f04Uh2`t9b`vH9vt^&FRAbmDP-k^?lDYJktoi z;+V4+8OV&bMT@B^0J7Fb@Dr$_#assGIjDA8uCtB7<0&^OMhrdh-fLD%uE`Dr6(YBW zMOpn}HJ2=dNp_|=vTf=#cwRER>dN86?2?{5S~?@pPaB?4U49@CQ%-*Yf#8}|=<`=v z0=F0^#wVteg7SI>P`{-aMV0+r>R4jJ#CKyOizwnH=1p}9q;RXq6!2+#Xh56B)8T(@ z8kcjRq1FuGQq7le6zw*PhXV_>H5Av$*ZOH|F{S1Qnt3ea_>BepSd4=*C}_F&o%IXv z#)sC2&*5f}62K}`GAAm_M2 z#5lA{>W_0w*9cu*fKjgqKdC?6h5sAjPkM04CA*YK5F8*5ZhcM&mqs9|YKQTxG^%YP zH?2-ixv=8WW7|mIeSKJ$=yvlH> z3%n5i51(-Ihfe_IRhj*#kr$L#U{;x*f-apQ<9bq${9feT>~U$_X~GB`TH!ldH8_>}a)`BaGAu*#1NlX6~TtEXU{)W`5-HXYqo zkzfof-tV@VMtCjJcp&DOzeRfLmkVM-2~$})f9b+oppPL22j9~cS;GZYu(rS_*om?Q zZ~!4Ft||z-j17KxI%dk+KyQ}+QN;gy_5Zi0gY>tjQ}M^sshedM{mavl8Yn&lI(v5Uj4;;o z;lEZN$$lQ2y=fIJJM5T4MlM;-@)SI4yI*}CZ*3Fw_XUaID90o>WK%Jguwn+;5DE^w;Zq70lVk(2B5P#j3q=v$Kzs5wtLhK))A+^Th}H5h-O^NUpkt|V=2R~F+PR8TxP68V zt|8R16&^14KgUvOQ&i=Dj-~K@NaT}_DrE-V^WXs;b+KMahPQt}os#*OA`ScGk=@Ce zREsue!*?MNr~~B^DClm4eS{+$O~L5dW^Az|h2?6ws3r;|T@zj|kz z?EV3DB>w~I*#4iO&hS@0$>q>=We}(X_zTnl#c%%sb&@Rxxgc;Quu%|X4e56@u`Mf@ zEz%x(cVDJa3|$v`9Bx+=3t(w`V0J#M;YjezF7J9JE$36WqT}iX$901?zD}oy@hJqX ztIyU{%#p4SHux~VO35uPMQ7bSPCy4zet$N;@eqG+d{5rlQRAvxd;V;EFA*ix1< z8{c~sJ$s!#v7dO~1QzN}XIoBQ#XC9|(fQM<*^q9(e~rN+q@b5#TCVJpWI|5;5UKL_)tBsDE7=Ivug-gy?+Blz3IBN&kG+B`>-hi_rr(u z&mTVE{If{JSRzt`R-;Vi`KJyo8{aKPCtkyQ0 zLB!YPm-xpG=ZRMHaZui&^Zq*D{hYDn$K2p7sgE%Fs+TDb>5=9t z4;_*IahKJwe#AT9+waLRi26HAnK5HV9%=*q_pjc+UZ6n=sv43K?iUE`#P~a%=3LKX zvA)T6GpP=C`#V3;y>Sa8L9vQrm~A_Tu!#K5XOY`xXddO$5&2>YE4#&@YF0{1Z-DFB z3z)KAx=`@}L~Ziv_N^>QrQyKh74_hl;Newnf~6IeejT5tLAHq;?Wfg97hxLQjB%u` zsQJ8O$|>9}-p-~RqSfY{KT1i{q7fpIF&@8U0-5q-(q=~4gj2P5Qgn?6+xLyR3P6c}LMlNkksYWS8FC&|w z{xhsvY0X%1gbAm5_a1Gg`OLKmr+zoaYM2oQV-2sYNs~}_QoA#We`1+K*qA1HeIKk^ zxkHFNQ{0jgqi=3&Rzf!ud!mE)k>iG%ac&WSq9cHopSLX!$H>@J!s20~u)fY-jLH-I@ZA zRXpa4xrR>gA>}dj8Z4_vwJ>z5dqz0dDC^xIxg5xRXxpva-3BX^yX#dvfW=%Rqt%-d z80&S146G|8R<*-C6 zE>dQ%uuY{GrSkR(OKy-Wt%|%V4?SnAH1*f$mx93%DH=rLH|cP$mSp1!^KQVOQx`Ps*aKJ{-S0nbc;pX9vK}X zW*aIs+I`ZTzD1)|O3b<2Dj6MGW*bV(KepjBIwZ^%Xcr5NI^@r{%AZ$g+oF93J{UA( ze>=fUS{px;GWA}{Vr`Q(b0@Qy3;0&uoIkR%uzxJ-FgM3VTsQ~e&1>HH=aax3SlO6%{l zDUDfauA^MHlABagTN*soCOW38)+Vy94AoTCL$j~^5q{sNty;cgZ0sw~!6KWYK5)ZP zsY!HRzM`UQ?b%vPGG7+fScZ!iqS}7G>%*?X?s)c-BO|%GQdA0qL3_Uj!b({YEM+^0 znKYnoPcnO&%_8#ki$L@CDSNY9#6;!VwliG|o%MF5MBCo_@6)4u65_Fll7W@u)8bvc zP7=*^4hKexobr_wQNX;-rQP-T5z=Fu*_@a655c0L4V(+Wlee_)rNNm|#>o{Fk9pEL z{39mC*l`hGeMy|Abvd=)p>z~6N{lIM+GkFx1!;-x!Kj;0KCZ88l^yFG>+_69ah2V- z1kkOYVI(+0yj}yw#aj`NBGU~rn#-3~j`y{KX=yf3?;O{WIZSqp{JV&>!oMc&GR)1D zgxTA%St8orCHQMZG1Ke|!o-Y~y0Ll}+JCRE6x_AG{LaZ0+M1QXrqMFL(v%iBi+(lT zU?auc)ZrRY&0z9Nq;h%c_c6?4P=G+%nyBIF;jQXY86D^Ia?`ZQ9yfea`>(8FpW0I@HANc=n=? z0j*m<6}46QWt=5}{2KY$ONI;&DTMVlHwn#v2iPY)$j8UkeKh zT>B>V)M`+rNa}siN2*{Qr(Tt^O?dU`UUk<~lWGgIU3kDp9+hyW_D=0=@4xI6b!~7)Z7atnH1P3Y z$SX_^N0FqPk&QY`y9}R6%+$10%AS0v!Y2&Ifnik*csuKh=FMG3KRLI(W@w%_D;rB4 z`h=#se+)PG^x!sMvh~GJO+dkT!(1?TE?X=M(6;!BduE1Y;lX%kwfLO`;n@+-obN~N z_eCK4g^6N4EOE6BJCy!3bKt#&$2US7L2of*L(Hkgk9R)+8$H(SFO+(vwIAKS8h>9D z)`|==7GmQc%!e&s*1*ALBD_g6n6OLMO{}Xk$2uJx2GKNb<{T=>9QKlS$(>BrZ8>Uo zY8Lfeu^=g=G4Z&>4A=dzrk~JQ@$6y;S{b!WUp=LIW<_;=WohFSE*N!PkfV*Me8JtW zGiP2!&CMIrRMSmgSk2l$RGGisqtV)fg;%6Jav0CzgxXugcwjLB_sm#uw~~lNj$1dR zkRU}*B%I5n*?!at?#{i@95Rr^>d_*3rCy{v6>_$CteBmyO~F{9mJHWB{Z+$w;sRQ* zSD&pvuQ<+D6ZaX$TMa0==#1-~ox$rQb-AzzcOstls$wi?%w&0DdUq|HpoRwTVs(Ob zf)Vv=*spX;;X*U&SHE9hNs5kN*2HClu(B3GZvKuvQ$3whAW(IeSzknx#CmR8=l7y> zn*GEDs~#^G2mx+jF|H^k2rDuNNMp>)nqk5b?Y0`jf_40v|or^~$!ubPkwX zq>Sx1Z*jd72sMtVIn|9jf1^g=Q~b7US&aLv52T15H9|?278~ zA*+_tWaG5=MsAE{!G|2(_vRkdp)$5iIpSJH@SlTLW4EXuU7MoEyW1K z<+oCWSM2H&uAi>ix#daBiNcMdG9f(RN#xghwwAtybImXN;P0=R=16Gm)dh`??;*?n zbC;fJzPYtXOOmY58U4}qF^7wv4|+@|=f^v_4re@LN$xIvFBUWaJ0-4ix-TcZ2XLcr zxM99Bhyue|PwRO^FD_m-$FNW!w#wNlVGVV3;`1%^PwJ(yF#~_BOWrVYb*2|>AC)hj z@d|D!u3f3w3A@M%qqOednP!_F{RQXyFGOSFKX&Dis=Vi_4vzhL1a`a z0UoPK7^yt)s*UU^7eB7V8{Ay7#g$VEe=JHEr8;MwI&e>|I=qJ8%KI#gRUU0W18vnf zqoP(gb}@mb1e*i>B^IGVepjNYKnw|qaVSZ7+A$9X-=AK*kG6V}DV^W-0o>AUZ7*wn ztYbT;hnJ8R!>|c@#60?ZnV0$|GOkz9fWX>D%7oLGge>?HjVh5rzEcLUGzBwvMch_r zwaV=)*1jdR*f`~1!$XJYT4`BS^%4C=7vPIS*-nlLyL-(Y$#yc!aVD#!?sPOss`A#H zNvc=JPR*_ZvTy7hYN1tebFN8qQK}f$oLMIs%!T&KvbX6;FB^!e$5IhpiQ!Mfv@E@$ zNYWmB`I0E6A0!I-06T)egU8#~u)aZ-P2Qv1#zeX0g z#**dgJh>5eQ##-AX8iUJ2Yw^&seKKzxteh!udha~d)I#>mgD=Svu6@pgZ%-MX=Kyf z_(P9UKm-IMW{cRSQh+(M4rPs;L2DOmKoVFv1S3|9$fiud9GE%`pRz$zmsWr)*fAt0 zv<^d!qCr9z2iO9v4dx=LLza;8rdI$SL^g#(L03Jb9jZsPP<5ADfGyYx?bmnzf zIvDheBmy`hs58InRg6SWNFk;#L++~WLWD#RDRr(KS81KZfe75Pvq(^wkSV{!Om?f! z17@r50ouBo!<~md)frYMJPY3MiTq9Qj#6p&K;>h$N<4DO{arpi;!047vO9($i7<`RVk1YM?7|6# z?0%oBsq6={TV7o~z}KbTXlgR~TEp7uJ|Wms=ID4tKxd7$CHee}Dj}&sW5(rUm113I z+#z5+6NjA?H1rZdSGxGpE-SY6CH!N~l5?he%3BZQSN2w_Jgm`q4H+EXErqN>yOE=@{gj1Xh0u$k6 zi(Y~&v;giq{K+XJ7kV#@p7}tV7eb1F(=af`iIiACg0UM=qXqhY%eQF_n*t_#F=mfw z@7m3UDN)=o-%>7~fo`as-j2dt6RNGUxB&~@_)5S5PdQyq;ccqrlXA{vJz{smG8k&Y zC*rogM)nz8N3M!ZUl}t+JwLOl4{}7`a2TF*b5-gBlw=0PwnUVR3n!LLhm$Sz{3_u~ zS7h`f0aGYK1-5e92wK{_%gqwzBr&-sYxbVM6#guQmY80(hs3fKZ$F=K7ws$j=y2Khu7YneovZ?K=&#!_ zxOm3HC9I~{T-zJDBP~Q+!?6k1kS%D%=$9z)5h^}0l9tg^Ifo+>P4BKRSg2vfBi+Q_&us zQgDS6=D(3}0Od+UF!y+VPGI1pmC(a?W_4R#sNDj9r) z2U3+5Av)cEcXW@LZc*u(hHzJSI%wk4XZ`>uyBp}@{?q0;mDEQL=cGW_ah!_CJuU_- zJ_buPuK^EJCPnLwLrYl`_r99_Oy6>~bGpU9e*0}n&+*-++JqWiuL%C?Nb(`^hfJCg z>HBv<$UAO$nYn zIc%0lk1w`Yk33h8UV{mCg~1&#$=|(GXFFUvrj+PjsY-t$|==Zhul--2C^s9gt;ceEf*!bktNZ#>Ux;6 zNLj@!+}2UCDy*g51=6pHvlN`_{``J)xK^X~RE1H(w-V`*skQBBRr_MT>mIb%sf%T5 zpfAGXn>X+}t?CzuN+bfwo`_`TS>pGIukdhX_yFcl5LF1Cimgd5LF>asilo4$S37|A z2CrW?P{OVShnx7DIx#~cM0R732Q2AR9xd2NK}`?4nhfgc@1$Zn_&M|m<_aqwXmg2N zO4!C?#eFxV-}7kABw*zQlNPx0gfZy*guxsr8h9aQGZt}sy>G54%mp>;?3~mxM(>&^ zi`XIc^2P~gQ~aVE&R-zf6)iZTICbau@?P$sAMz&+akGBr(}_N2kZs2FV9qR-l^oEB zKI)2mUN87k%VM;pT$+KdglPCmE&NxLthe*I9_d8VpNv3w36(GG zC%2Qq^V)lJ&83eMP@eD5Xz3$MR2n1abIpO$!U^jwx3lsy507QswfFH0L{&@=La}3k zfDDnT`H(zH%V*f;)c(pR3tyMf@s(}Db;1DN{F(*!v)P%OhSV%~&6g2+-qzVQlol%3 z`}BVITNi#`i_8wJ0AIxx(C=FvGc^Fe_f8CUFS+$`#^Wndc?`j@KQ$PVq+zEeU%vUl z4yrG#Br$3Tc=6>AfiFbo(UPGt_{4AB(D>m!hBF>=MAZuT!!Z(@m(=(yQ3X#BzVYVB zo$FUIviv9k#)=-(?0!GYk2Jq135ItmD|#%m`&B5+Qt?XjqXp!MZE4PUe90+PaE$j7 z0Amu{5}$b}%`Q@KjepXF&O5e(;cGjuMtLBHB}(s?^oq{T;g0c-00D_UHn{!^&bNWs-_^_f z#WoYDeXMYE4n3c2v?m0~Eb!> ziTTF!3PEFdK=~Euz$k2m{RGp%d_s3LXZI+7y?iL$1oBh zTV{b%hAlzF4Dk2Jh2IT{x>K1~6_`2Uk&7J2izZ~}J6A#ll%9r|}LOI9v*b@(&S)|8WU0wKN#-l|_?9`4^qVNCeP`U*0e)YScn>9XP4gF0(amU{ z@v%-SxT(umn{Qthq1z1TR;32UgK9FyITpIi9q6J1hzwx(!WjxpdHEEGGOBmTEGJZ=rJNRIp^y_4ooTU>UBLhRw9~fsV@BPI0?;icG#-meP$Yw1)Xer{974K>yG_6 zhi_RkcAp~mWYa3dXbtHK=PJaPhhL@{G2^R@+X{V8WDcd>5^gA0sC;{S&d~4Td1GG3 zel44}c`kySHz7-r3#%zPulw+O~%{{N>`nlWiecFX{KBOL#qF&d`FO_$u z3nLHCaQ*?+$ag(pqXRhH7*tyx_(25@5XIAm6OdG!a1ZDL03;fj^X|3_%=V9lsms@a=_EvZ6KWA$9f;uoqw`_En{%4r8^g`zI;=U+%c9s=j-etq z<_Z<|-MI$&x^e}Lz#KZ0)*-&@5^Mk(pTZ%yOC_LAC?(3IHA)})MP+jUOc4E{qw5}` zgYJSMz#iI1b#nwvAN`@Cs{~Sj<|4EU2!;f`%5?ED;2x%3eN!)>8M<9~lQ2LXu3co4 z0*ndSBfHBM%o`F4!VYDTq(*gf32Xrl^mIa9qqYefu*X?9v6ONGH)yaBD>_Wd6Qw(s?51wUx4@CoL>hkx1s-5;xFtBxj#?w7n)W2gxs{Da1zS_CBDMV?98%#_RRZi3NM}!D*P_+ zEQ{CujBkSNe*E{jzyCX0*Jr*3=5Lx9>KqN5=>TdY+N$TvsPGntG0TGi(7561mCht~c ze7H4*TrBAFlTDEL)#$JoYE?o#D_ZTM)2bUQ3S0BoJlSXzJ$msedS^F&lK_fVd~Rr7 z@VLkL_I1&%>KT3A9G~aUou;8H+0mh}%V=fC`qq31elyM0wDEeQxS0!wdWPsMoQ&Ab z6@y1qhD0NX24c6Ohc{nll*B-wc~gs~)1ymrhBfL_;E3xerW%pMOyxK8FMz9C_OxO? z#Fb12&EkyuN?2(c;mH|vw0cROvDphlOHhZknrr#|YjnXLZd`v$VPV&o* zktM0~tDx(fM9kW~#e!#*H#wN!PgJ}#w z+QqtcI%gM&-8wtmG#icD+O>z^1lEJ)8ViQqn89*W1P{8 z@i7DOAbX5Zh_Pi$6C&^KBa-)N$P>jAtfXE%|lds*6f0 z?_C7t9_sE3U&5epp3c}S%8=+53K>{S8>E{r7d_c*|6!X-xqF)8qZKNyZUIwX%=+?I z4+G}hcEC-%1OxNIk_=1i`Id7Xf|jHrcet@0Cc=QD(ZD@L;Ma>xEw50u@QO}h3M6LC zLT(znTJ(J`5oDGowr*cS~oI8f2lPbwlqF6g_fE zy%W=x-UJBBi-w3ihXQeYRJVvL;}{9puZ>!Ux29`E)Fg2}jne`<$`0}DD$^^2#GG^x zIz`N@t0SwN{U&TMitjK#CHTH^9h}V^2+Q0@9)IM>{r+i$8+}qg@1CD$J)ddmb;ugu zZ@*H7&mSj%Rp6KI%x=X8%6;hs*PCI?L&F3@Bobox9EIGVdzWjP6^HQ@eXD#kL0WOz2QbWcl+W1W_CA8&Rx zEu#S<+Re=lZLvcfJ;F%h7HS9JtW6Q&fxoB=QV{ZoPQ_{|yA>f8TYp{|&!-vrtCOXo?&*dUEGK^N6-*iPN5k+FxiA#?JP1IukiT zEoRcrY3o;NtNDw+1*%Py+ThbcR-`k?iY!kGym0sErYvOJ2U(GO9YrTRKZfcsn6Whn zP~O7Ff`nq17O2IISb)f%ciobJqzXEv z%q)_R@yExduJOZ%)$5R?gKerC=+*RdZB6Opnn|I!9iYB4yvaz;VEQ(3fOQ6GthF66 zf*6W%Xs3fNj#Q2}M@%k%xVIzxwDr6Yt~1o{+OEl&n#aP@kK@c!@RS7N13zbbnUjswcXVb+(ys@QmPbw9aVAdQKYm(q2U1C>W5xK8nz6KJxnG| zJ**5q1i_>2GbTG95Qe%iW}-Akc5YEleSNh}a;5`NX!T_t6RPk7h_I^VvU2B7IY8rw zDCAbzY2{qU3IV(W*$NFbrR61bf5s0R*#PG7?r)&+Ln-IZz0X=8j-(pnd{!io zGqHk01fxnp9tq*z8Z>v4Fc)}y6B&>{)Ank<{WZ z68#klovOa5toML4c6dW>lX+$leTG60_8TIgM`#(MT(DHIQ($64@Tf+#axy&TRE(Gg zl6-JkRtdJXc!kTykeCg?+LG29Z?oHMSoMz}y7WX~TrVdmw@ z9S3@KSJ`5vNh5I3>QmWJfWBzggE$(@7vD%~kEA3kSt3nj>>p4jT6N}~O!s)}l9@0g zyq9Q2+cycI0UhyVeRhkBSbibrZ6-L>o}BLGtL_bcW|8GT(U>*FX+rcJJdvT5sARJB zN0!FD*9RSwR$z-N@fD6nXCg*C0cT`q0GCV*oKebq&w|YmB}G#GACoDRfd*iScX#;K4IEL)(|BsuJX<{l&`W58cF#yHU64XORue~FNCBk z^Xf7sTMm91-2jVv12#W;viR`T=f^W>ev$f!))x&uWbxBYJYP^tv?$RNXkrn{A;~J+ndmM8 zUB%J=<5kS|pH%b}rJ1kjx>SXhG>VKekTh5*jY5S&abV(NvN#bTC?gkLE0&?`%amE< z;xF{(C)|HD(~XDS$opT~P!U-1X^jg@&Cc@z`#UlBL>~$-slyquvsxl>0OUY=AU=>! zG~6&?OAkB3un@9Ab)`0lBcNMsD}*n3DBLAFzOSo3t3Xa5M=*62>udd49>t_?4S{uu z9)sq3HofZLjJkuo=2A-b*s*d6ZlbNe&;)Pvo+;h*J%d&>y3RSZ<#M$t2Tjnf*%}M@ zifhZYm=z5fGEZ_6Pf|p?Py;2u=GaeHs$7X|j9LDH4Z$cP=$ly`!NFvPYtyq{*5i+@ z>ZtGQ2BJ``&H@b#W2`hZP^>kq;+Q`K?ME=Nf97Hk%vIDH`sV@WiCCjtMQVz>q(yZjeM>`M%$NR0ghY+!_R~Nzn7;x8bJxfLIh|&`O297aWBxPAWmaEVO@a%ovX!$~4)l zez1-A)awo(nuD$UEtr(KoLWOkrGkdUh8njikvW?-uDHTH^kQq=z6*ViuY*BDvvyu*@n8(QD2e;p?=Yk??6|GRXLSA zy750SIG{Pw;Wb*cQ8mIN7d^Gf~(nN)t- zCG@lEP}NSOm~4k-uB+0F4(E5`NGWUF2!9FBmszJVTRkk>0@abN*0QfH*4h+S!oy{0 zI^^a!s5RzuHu4*sVg;X8a2u&x2J;(AlNi3E{+fM^QzZ`L{Rz}Z22nKd!y0f~FsOeh z8rW5Nar2##+bH1DgUl;}qF$m{>UFGzICO>wt+6aKUqT!_Nyt9Du|G>j*-xt!!=s#e zHaRzgX;BuA1sXXW5P(lzZtG-(l0u^115tx@spqPUAWi7sd$#3_B$TJlXVq6?~$%AuI3J$lA5-W{V}#L zd&o{85A)}E$e%vGJsv#tNY>o5-xJFf#2|OM!%uOGu=H&50CW)r#qE&MIAFOf6_{;j zhUxC|5j=1&I@-E!fTllZdK*~QdQ#iY2e`-hQgd6y;#SzdpmvnjOVMu^q9%I@6zq55 zy~1vM8bo-wM=W$Koz&{Y2!QfpGJ9W2*8MzEWdQ5G<_kHKnumsJww7?uynh7!)0-5v z_rotB`*DG1CzB9Ck-7E7KStlY*4#fJgNOt^(A(j^3ru4D_uc-l-+?;&1hB&5Yxtl+=1&)z{>5)Anh5aRgdJR>(9p#shEfNeJWxu`_2Desxu;+)j5-jM0Zpbxw|}i z5&q3I8LUmlFV~K1VVL$O)rc#pw`?BOF&*pGm={`u%UBHLsuA)Jiw?#e4acR0vfj(E z2sW_8fx2~*TQ4NjuJScw3$QiR10#X+ zsdyXms5%x~-jA8>c@ja zY_kZKEl(g5%G*dA*~{R@=2S6Q=KAr$1xM|>ZG08rQo+sAl(1Di5uN%B$7@##w*C2+ z@$iQMu>wCEMg_RW-Dg7{8@AHYFuA{0VaEN&a*&WfLb%bkC)T47XnP!;{IrdBHz^RA zc-f)lXE#=&bgRW~1(-5?=R&(4}~ zIJHpoX?{u|>2S=Y-6M<Fk3@rHk8b5tud}O%WQAT}b8Q2_M(G=Kl|#hl*5){1x<0O8NBP zzmqurUQ>KktwC{|*nWs@Lp_}Ai5c<%sI>=vpWhCK>T_X)7fR$>i^UQSe_6G+&nbBOA$3hu+&rfN4}0pl4hky|g?pYUrhUu{x&s ztBTUDZ0Bkq!gS?^exNGe1Sha##^A_WOo>Q=VwsJQ%U%5;TR|TP8`M!_AI^`XJY6&F zAgq$N-rWA;6}6#SPl8>IgblV4z ztq;;^(Z!;%j`nz`!wk2xe_8X!oE|vuDkgyChrMp~?)s@L7ArULZ-t*y-{%9#4F;sc zOlIVu-5nKx643XT9l@yxTc&&l-2V94ZYMg>84uq`oXQyMmcQ3^Ns{AOY`X_f%=;$279Ne?m|Y(#v(+# zjG5LyB6z9Ux?sEGHxKdyN-3Kq>Tzt&^q+Ycu+m0AvQ zZ$aqbdoZLoe`daVC~lrrUEnvJ#yF+)Fcd7{zf&u#y+Yz@ek)a^c*N48{c3klB%(|3 zjwcEl<#&Su?-U)OHL)2pnTvy6X8k^i_u9i}tV{rUZPChX15Os$T=z~yW>}u2+=Dv2Qprii54X8V&csa5iY6ewU z0y4y%)E|xyNj9^%3C0tjN25z`=JmQC@s2)Br6CH4a<7yV=8Jyr3bABBjkG8L$8aIE z_5eL3T@iG@6Y>uu9Vq+thF+mn(Fi`*q)5>mIYM~HZ&DVX-B7|b2>Jac{}|$C8>`YO z)AB^Et^^aWAc?y0W0(al$;tHn*ki5P^_fV|@~~L0)tEi7Zb@h0{ePbY9-blfAp+et zr2a1pFw1{pJO8y66?I>7(-rGdQ;aLit<1$hR-_9hmXUsxDnm9V#c2}NWB>*JByN^Z z^%8AnboTry96X%}GEZSgJ)JNyuPDzEqqGa<8YxmI^631@zM=xP_Z0fzI`XW_2?=5!u~Re)pPke zdu$|P)jd(y-O6bU2D>8j7}mdT6jWvNtWgZl4%ng(>gXO(g*Ew1s->uT__n3X&C(Di zZ>V0R;k3If!f!;}$YzwhVlCD}+={-`_CA=c-^yfzLpuZRDzoGU14?88)!CwnA2 zhZ{%qgrd?hoW|%-?=qox8?dC{o+l;x7R{2Ji>P5XdN3ljuz{-JJD635fqB=RunclW ziFxJ&A(^tej^PVdtZ&n%+%8$N*%c$QUaDy~Y6*0Da|}@76RvQ|E#0vet9E5ZLEwNh zg7wmxw#2iXcEy3Dh(_VC!fAF|?+7uI?L2g}xjEG>wqEN~M*^n$)8cJ6b_X_W3X$&(U}u+X0&+=#){l!*EDXS~6{$qQ`BN%1(?TdXCPC;hV+ z_zuY#Oj6@lEnj!xV{aISDqG_dR}1I}-XWJf^^6g>c(H^Kjn<4I zq(TAHO=;Qv_iQ!t9R&ICK}N;h)wiN2Jt%c2@3~ms3a=#ud|b!=D7ZV(L!xsO8#gw4LuV&$8Fg zvi}v(qEM63OPmY^!U_TOKw2O!&=}wfz!wz=5COvjyCfKJy{YQzb$X3zk8N#onQ%fl zi}vI;z<2bHF!MXWc#ZWmahP zp~Ij1IeRwyzxXDkvsRFFwRy8g(y$^P>x523nnafwt-yw&Z}vR<;B@7~e2PRj5TdJJ zsldnxm?o2w>o(MwlKznGOz+R1m?Xa|Fc_F!WkF@R%_x8I zTazNg$17d;1Qm}U+;Y9;GGG&N07!{ty0L9Mzu1&$+gW&Irdrk}L>i zTQe2^J1Cg`N~7gF`V%SVmy=;{A|zxofdJ{@ZvO{t%iM;hI~=IW4kY~p+1^47GQ+yi z8JXD7`hyY)?w7(st){fwh>23zTY%mx+#5I(EFoX{k4>zH%1$vK z^NcE#icju_ztcR4vSZfv1wqV{1Bxn~^r&WPmk$&iP9n^qso=#eEQzLd_R_8`M>`2j zNHWpjU2ZeQoNMjeUWO6fjhMSK^+u*-iuBs$bMR*~nLTfHfR>(`416`h&A3pTmh=?a zZiX=NoR#{`bNrHQ4WQMsW7p6q3#u2C_?q-Uu z;*AkP!lsDbn`3RhL8tKo;OCg%Y5MrHD@hxpLe3K}rs7&QE&^*#ej1yfuX=}yt+5>2 z&q3A`YqKuJX{o4XG`?0(|gQ8cGCLJD~R-0i0FBZ z)cF8kd98U8|NRR7{!+)j@0(I4=Bd|`-p@8H&BRk@ma@Jds0B)qC)lP)lj}K;UoyyV zK4SXzvitUc`AS4}Z1VZ`vHAjWeFvaku}4;-ubV!f!;F6pP%7k#NSHb!CVJ&%b98{1 z{|Eknk+CNB|9I>Bk7uK+Jo6QqpR154kr)yZ5_FTkqRE@gS29U0HLXzgpm9U@ZADPW zWq?UoB^G!-iE)$|z#)41_(naj)@6V~k>>3*ezrb#whpqKK$ToV1O6aFIagy`Hu!pY zXZUOQdmKz`8$ciW6%9QM`GU^G+%Xt%rm^`-YT5Sibj6jRW!^>33~k?sN||b3lh^no{w=5yY@#Eu^5vGbAKC25kKkAAk;jCR z@<`za!w6XcBesV_3v5vV=4gT{*kW2>n6y{OpggS$Z%?2M1l<*$a19l%65U zK${9l6ft#j1Thp-^>w1z@8amWOmy1WOD-Ys`lt$nlt|%A*agT_;*@$w>@*oa&<262 z4{|Nv&KjO|#5wI!I^7e&Ssyd%K-=P|=YuCBQo^AqO%!Tm0GZ6b9KYtL4;l>+CMuem$>puaeq2SiLat*h54wrV7LRp>0Ywaabk z>P~1xE7eSTZjkLaqB$5c5REocDyKz+Ub4Cl;LDU!QJ-7bZYn#imIOI+>I5fgsysq} zfd%2)_t8d5sWT1*_;Ie8%HvVS$ja0pc;!Zs1a|EBRHx=NTL#_TIrW!VIQK&}9TT|_ z{>&lUI3shfu#-{MRYWIT>BAOT@(}+4mt#rIQ|*tsGOGjvn&8KDho9Zr@{JJv*-e)I z6hFS9qqoB;ZoKLV6!ZXn5PPFo(RDwlmBGG0OG|r_I!;|WN}V!ZM}WmsVy#}OQMHVd z6q$Z~F4vr$A-hZqURO&QJy1mmj%EZeyiYJ3-&Jdr4#M-i0gg`(0yQ?da8C~_Nj7|! zp^{GW{tSpksPu}*5}9wV+lS05yZN$p2;)9f8R}rj`YQV--q+rK~8I9 zpjaV;iR7)P-3_u*Zmju$y$v8z+j1_k+D_Xv-`t=mo&}^vw;f@>iQc+(`TP=E%o^9| z96DLrUM_#W?!Cda;B^a%XJAoj{4MT>Rgmmxs&T zQp30i|FYw*hDw&%0k_hP`7{-cUbIFk`in|h@BWXP1oaGTlldlIe6HmiP)hQHpA9Ld=lp?eAama!c$*P&551SaVLG>a(rU z$W8q=h611L7GUyyfHpE$sFjqUNA^`X@D9SZtdT$)8FW$ojj!O)0MCgcncP%1v_YC| z${Zwl7Yd~z?ll$C(~qV*nZ~&El0*uqMa{2^>vC!9x4)gW=YHGBxQ-B4^$8AEFPJ%g z$G2=xb+6sTRD4)%;A50h=VqxNF@k#x(z3zv`a8cIB3Ie=Sf;};buB?Xfh?d`pmc))ZfSwt z^!6>8PRdnyXcPN=@y>R54OMxTE<7e$t&hyJA!etB&I=0>zm$XyodCWRQhpYNL7!Y& zsn*5!BT=uH3E})1(z(tt>OcE0lC>MxLqOZ?L*QLt_>Z-@vZ0+l&@tQ4K)~9{-a*&O z!Tw(rd4l|3oPO@OX4{?CsN0YEuw=wv&Bez-LckzK?B-Z{>yf4FbP%P|=1L$@~{tTEb8anC+JM^yo3O??8;BvwnZJ=j`}4lQM4jUnzN zseqfe5wEuI$JmX}x(AkNK8~Vm53L*Z%{%UV)39xs??89~l|@$zjf(cLU}+LyUplCB z9mj9GNT8=0Zded2iuNOf*Gye=eoR>&lM2cCYH}YarqaBHYU}v1Ufy8! z1Md+!N<$0gjls`M0F7Wi8pgYW4%x-&eYMb($t`jKJsQM-Ic~tzUj*I|dt>HJB(F}NGD9`$5SV+lwujT+l zA_%C3_}}JYjQ`b^GyiKsT&Ja~s;nIVb*)(1h(sQ%oL}ua5Y{RNv<$woKfn z0P-z@{tX*ZAp$r2w__ov1_%P>szK|+hW$m$f#mba1F(8lj%L)|$?*{(Vd6R$1QA1= z6D_eB%)bobFoMa48KfVK|>T$o+~mH@<%I^F3HSsljUxukU>2E{q+_Vi_58HmGs&Q5|C^+NqkMh@B!deiXQN?86A3bO_O6gioe@!$wkeF z3$L{ zOEiJ!P)Q!U{Gk!QW@0YU!P6RBB9l6$dt z`SoGn!OX!z^&RS-z29MOSsKlR@-XK?Ob`jG;}EteIaqItfi)P-!J}IXAXk+7D~96i z7CS41>cxNYD^c1n5YKX-Mr3Z}IbKfzcTi#AV(@M%Z6V8yvaXpq+H@`2(jtAq9{*c` z&DbOIcdWkVqWioi`0&|JaXU3OMIZTv=%qQOWVJ}=i83ApGCwCn*&F=`sji|`>>0*) zv+MYCP<2G%7XRBb*5?0|`LO)m82fLYvH5+41ZmJ_sQJx4^7?oU5yW6pE0;cxd-Pl@ zGxt)AH{jbU=*%=%Kr%kAVY{_>Hkj#e&)CV1gU+_wsm*U)AIk}{ynQ$!skW>@9&C7h zkY)vj@yvB*xL9__M_$^Wz&>aHNM zCce{f+Q)!827%lO?hT4&A4Q^{kjO5xo9x@p=;e(kW&8p^a6?6UzaaIPm%Nj@3?NTE z$ZM=w$rAtN8SCh?&zHp7=YUJzT3iK6>vC5tU&r-U(K82}e^jjs~ulqA;> zMxSI?=iC}?0s6jsvvgSqSH-r6gEO>A!!f^Qdx5ER{%l7DK|F^+#*kMRV<>jz8wzzPBz{LrUfC?9-U|MR~W4@sYICBnNJL zXE@ty!>zG*Clkap-H{^Dz1NyCE+Zu)Tzb&U+w)6D;1N10tsqa&Z`0WOK4c;RY(x}c zc*qL+T8>jT5;#J41Qjs>V;2#g)VkWRP5Q50!!m+h_)dw$eAa@Uc<4_n2_qa62HO~w zCuz~l0hlXk<)-7&*Hp`LJNt+1xD*@x6>xJ48(-i-33X9WltUd)aQ~TqH*U<+1c5<{ z@_z;?!{1r>Z)lNQBew()s}MDq6dZJ(w&8;d-A-B`r_DZ9b3 za9A&I+`VQQb$TWI6tTt53eWqMgP|$w&5ss&i2~pfYCvN$tjyeKA$Vd#8p`_I-IPeB zz+33mhCPYE6#f$+TfeYxy>dCR^UTS7!ssU7WjuSKL!pOmu~y3#Ez{Bedq3wFR}ziP zfeiig)lnQa=H)t2@Vuke-YQNmh)*fd)KeF!iLX9cDVnM7Ip$)k_Q*H#u62Xaty|Kf zd*vh1oWS&5=AJ_tGK|V_yT0WD$E%CR`v9VL0ePaeE3cYE^dP5V&0bnYISGw zFK=zLue(xIX}b5|6dWemKyPgfJ>nXVHy$1$>~<+XW*TZ+;4tk=+-W1~uf!j92{wpV zFG>wSFmH3>E>KGeitoNbF0e2A>6&@M8ZRt8k{R@b?4%g&M|WW)wO`{m=1VL?P|jk8 z@%4Nz%eNJ}z8%?eS{AUX!sg1>Dt!+1BaSmlrzUlrb*jJ_U08v|oK2mf5q{fu>*MB5 zK!|hz()#;)UO5+LoB8z7Vi_qP?FeWfd~IZOAu65tnEj2yisCWlk0G+LZ0pq8Zi;51 z=1)?4x=6e5Q9h881n4=d{=a58llen0nXdbDI2_1D2CgZ`vZ5b-e;S)W6%r)B%~>eM z)KG_4TT0fMm7Bhmn&LRq=Y0`6#4#$7&tV@|I0Mm*&a*JOFf0_@@#qyM7b>QI9091RGK~`X&9PY4oWOqZZySQ8;@@A1?5Ut}mMVzq2|i83@!P1i+-9KyqEqJ!8h7tU z1D_IMM>P|hJXWI%^(prg#49G@xOWm|w#x^ivFwsyF(R!c2x~1YbbObY}l@ zB#NG|uFUf-4SeRn*~$qN;gISo+=VRVV3E}y&RB9yeMvD2+#_EtUN+z=*(pDi!`1H* zrDUoK=~{3)G^Wolgb(x=T5=iO_RxL)c0BUN;LkQwd&bLN3RPlNDUkQAN;#ON;WGeL zbX6dnB^yV}F3|}wV$ElF~<+yw`%>j?gVya4AAA*GV zK7s+77%co%hKwc9Ma*V&ozhEq98Oy@&f9zO1`C7F#1H;e>_)^FF^M&%? zUGx5jX&yAtG%wL01!$V5cb^-G{(qb1u`QgBdjc7Z2kAFl#t#QoRhu8nbtwJ$h`{NC z3?RJ5KUZh#$_a>{B{FeA=%T|ibIo2R_jFmQ2Chv5J>R1n%pLoXz6h`xtM!hgw$H%9 z1U9(5MdMz@k5q`4l419i=G(ijmgGC^`Y*|A7DC;XYazI=Z8se8vK{sF_{l7z2i^n{ zJEb*%ZntlLCQ-|E)i~JaaRxN-GX64H$oAe%@}n&xSLnz%n&-{$p_4_CuVS!BN7h4k zGCL6rtuShl{NfoA5}6A9Ip{Z*KOga>1+h>lrxM3Vf|u64`*(Omr0_Q#c&~hH{^3-yGenYLOxXqeR(||9l!`--5kDv=qDBUV{cR;1c)Y;d~1M3BWd+$j0)IL^sP_8*K6b2 z5bw*k!YYM$^8L9+^%s-B_q`rouBxRLh$hXuqR|DSNvVNo(k|jk*2;51x4ojM1`AGR zn&)yfQa4$$BzN#LdJmGP$Sxjo}Vw|Tt$~&r#`8UIp>Y(E3$n3He=kddg;nIYlRI_ zRL&;pc<;)&7Z5`0;X+K#bl4$q)$Yq?QsKQ(ipPgj~6Ii-_WfTRs< zVtt#oZQOB-Xg~A#dB&ykH1Tz1)V5%ftE*O1Cm8!$`RjK1c=Vx7R8yE09y0N@KNQ!| zQq*f3KzZm+sfPUYp@2~LbLdvX?K?CO&5!y^Yjw>kHnwy}9H~@2+jY9nD7ljeS!;gD z=Rnm{twasyeVQ_sMw?i-fKbDMX&+NH-+lpxOT&Vr)i$r=MTi?lTp`wJ?9LQpm7jUt zGq4AHyG$j2vmY-I@KcIvJIBWmG3xS1HzFdXD)Z%hKVvz4KFtZ|#4A5*pJrCos+nD; z2^;46Du{YTpe&}fNh;!zu^y&yuCG3_6!>bumQe0n{kB#$9BHjGVN!iylPREF*KIH)U|1&Ez!-jkOOuabT)(%Mj39?uSb2#v60(0dvtMa6hq9eG z@?NbafEF$L+fU5gEO^d}*lbpUuITWW^Adv$#abJfr8}wN)Lxhg5_1glQ7!?ec#pc< zbZExEN>wxKpkSYvYAEOgz4c#ztR63UrBPo1BlR0FKNI|!pZ~UX{L5*K;lB)tHGWUq z-oE&Qn|b>x3RO~=`G~hbip%+cAXvZ`0rC#V@VV<}V(CW6dl6}Q9-Y(q(yw}OxAJ&J z*WwtaM-3V3mg|x(PS2Z0F1mQXe@yOAqa$#c_r@pcMH>`Fn?PHjR~FI6!otO6&HlK{ zffGdUBj)Im+pj=e7%zI9ym%Fk&t%Ej=rK8sBk}!b8<*TqX`Yyb%(I_yg{D@wRg5O& ztfXkEbB%<`;;U)B8oOyiwe^KsVW>+j#$`7la&cg*#&nWH@YUH@vtx z`;-(i5UVe?>WE~wd1w%uW0iy|5kDyzc=_ew(P@;LJ=U8GUm|n2D(jIIjexU~Rsl@c zQ~d{`vRJGuh0_(-jpnsM17%+Yh+}MmW~hNOvx$Y0AftokN1=dU6jz#x$P_SRyIGWJ z`Mq@j81`PejPxIQB&hdPHF+8ru#RrpR~efYFs`FKlM#q1nbYec6Eo5L)(lD79a?3cCv@HLg zQcKl94`s%ZbmK3hv5eW~+i$3FCLEV*Vf8JvWp**~5-oRc40}~7J*C9=1T`7`wmo{! zw?4U~w0JomTs1qKg1)f-ah%Sx7yj-kHxNe;(OLAw1&WTBriT}2dN%ZeJNl?r zyD{9OxSru0n6)d<>dQdfY(*TUR+HydptqW02XG0 zSKSCbQf@)yrD`9i1m8aX^JO>l%Hui!ye_r>sk8R?MNwFlMwCO+GUjm5K|2TI`zkA| z4i%q+>nxEg5H2C&koA8nPXOw}&+A1q@LE5jTA5EpE^g{AVnZQS4kD zt@%a6Qs&#+^De2kDOF1GfVWRGBuoeffWTA_X!OaVHyb0rU)&mjj>byY1DD~bDKbeP zK2#U_u?_?B>f~8IyUcSJ2DmmbAFTOpclrcLLwlrXgi>O zWFqre^{DN`HK8`OA3%N1Im+XFeBBL{w}k`cZ6)2mmq@isgx>odQge=zQN=$QpcAMo zYLR!{phyFxb_9PAlQ&|!R@+9Rg?$q#w|n-6uokBga(fv4apE4-o49SD;@8 z|8O&(UBC|y?ss6<2|+fg%>Z=d^y(T7JJ3rWBW)xTAF>ApjWe)zBi=-z#X>IU#xxUD zB6dyEYTE*u~e)wENKvArq4O7=A(0XhKRp$ zCFs@EchPzTE;3SU4M}LVicKa$Ss}2~8Z6r1)Q7o{@Nq1y_FtO`6B?GcrOi^(yljd% z#>{i=u1FVSVyOE>r+obwMo@HoLDaY}lJjP8Jafqx0&6C=2vvFrHr`{P;vDpbeQKUO z_tiMwF55CM=DZ`gn2ITfxLl7QaMAu7=K0qlk@`Hp=+a}<+Pqs%E+q7Ku!`_u&1=lt zbB5BwJC=_9Iph9ec&S9)c)5;@`LSZT2twW6HmLHm?s;v(!VbQodlb+WPJpR9e#%Jn z^(9z}F24gst-}dFYYg2?kUM@4T1;q85X7DQ+J|R9{K8!xbPhh8>19;uHK3=NOvx?T z?R+kKdK8b{f??;?e`{!36fpHQM;$3XX^T*N4G&GN*~UjVXQto3*{2y-0yJ+rl?Um2A|(Rq zU)C=hvv5MemUibEN9>zt^;zF8a6e2|8{>sgCn89s7-|efMr!%|Y^hDyLH`mdY!e*{!>3`wRo5JIV?Ciio707=k9P zcHda|QJ?9t_e08uzu^@x%TS-}jK@{e@F9&$9#<`s^m#j+7u;udO4WIbgO-a^l0*{S zC7zPX0b(9SQWJ`iLwYN9v0=wA0X1XWKhJ!+rZ?7T*JOc&o9!Q^(lv|;Q+*0{^kZB1)-J=9$)4z|MH&sL_A9Xj zpUXqtjjW+RhSqI;g!yOUnw$54Bm+tr#Qu|%;otUX8A=nsrG#&RFH}@zpA?aVyqL^D z_`ib9KY|KU@dsp;%9IbXygpzrSPvy=+ z4k;9#Sh6DNEaUuree?Fz>#5}(y&GwtDc`YPuQ+TzJQ8h?DSSxwRm!}1aJrH0=F!xa z=iMg3hnfy+tVJ5Hg>b1^3m$OAnW#VF4p@>8pziErWN~I;5IWMB6lTf#E^qT15pH^R zWfwR$UYlgUGywJB%ATgJA54*ulc&S3S0YaX`8^@YqSgUfGj7#{GO42_ZBnB@dEca-&kAupNzOd{11rRqpdWBAeytL?7Y`3?95SXixVF?!#)(i! zM#5{G>EcTr^s#XhbCCMdHKDes>`K$NB5xwWQWw;#xZ@~g_rIm zl?a6W0z5Y7AZibj&L$k6{-l*5n&+W(hED{G6#*^Zu(zA25U|!y9e6%iBsjtPL+6m! zAOl*1gv3iBcbBEs%-}-yV(sRXJFrLb7u{;v)Vhsi68E7dQxhEgUid zS3hzTdH}VYHnk+J0J9aml#d`&i;OeOe3>tk!o@h}-b8%$PlV0+_W5-Pi08K!jpq7l z`!hf~9palGhxCr(#A3u;@DB21b`XKmBJu1(COuPom46v7Joj+fTHpM8>PV;6PXY7df7B%W?Qs3~YtKSMOPT!NnuNWbj{m17LFMk^aVw033k}hO z;f-WB-STgm1S{j_IIBI7CNcEw1J`eN>$T&5(oMjZJ?^Zdy#rn&js4{?x zqcX1}a$tt<^kOE2W>m2p(||IIp50Lru8=q9E?h+gPCEExmECGLe)TJ>m45@w?G}m9 z!)%kV=r>K3gZzzW!d|95bR5rz?WRf@{{77A*g!r&z&J+WQCR>|l>b#88nhg<&lOyV ztW zz-s@139B^=S&s+YhU~I-r9Cyk_wg0FYV`{8z(S`d}bvXQoT69ft0i157 ze|I&(F8LzICH*3?2f?-ixG8%>W3=`-{cXM-dl;r+W)t(=@L7@~xZxz7qPhCPUah)O zuz_S*^sxDJd-9eOK?4_%H!hB_&(D!2#VyA8_|UA4kHs>zMSYDR;A|9haV0pgc-s#` z8{_D%Oo_HuscMkKQJ=mzQtyWoaEoY7?0$|^ev#B7?{sVyO; z!(&-;KgfycQ?bBK@Sw?wA?1X6)ues z7j@XR8Jvdxy6poBlQsVMbTLcoW-fUIOIH|a^>XCW^`k`W=E~*m9C;zvpNFkfKg>AD zQLok+Ehk>4_-gFL2qSq!(xQRCVXE^1G<)b+klG_ppscoFAYE%ZO1=&xP7@<(_ArC# z(yt9-LxO>=PNVroNzKu$qR>(o-#50Xom=%u!)XvX`MG3&6^YpwhSNuo1S9q!?~#`c zoS1gq521I+r~u?-MP+T+YO}&nbnls1)+Dhw&9_SlwSF68E7Bmsym~w3`?<6lT(Z;J zI=MmCN8Gc}YJQKRD1Ini&r=wv3ogSbqlU6sR5UHDq{vRpjiuEEH*i&q5lSXTWT;q8 z%5kcqh^0+&^bBClZMfG!?rS;k59btvx~W31T+o$HYn2{KK>_>OBz2&iDV(Oqti%HG z{1bvgU=vJCZg*5=0!=t~f(}DbrMJ`9`m)2te;-@nfk+*4|)YWg?-{7`SHJ@Qmx?yM!8dZHsleHs^#R zciA$`EjH47wBMmEoJjgh zzofyTBO|8cPTRrYL-2ioXjnGb7FU^`u3b0JcnK9{}`;qF*2Z4z)^X}n-}Hpm7wy@JIHIB=BFWSy3` zzJ8ycb@!e;O}+TAzAR8p_$g}I9`LKoIb5O}o~EncoZx9g;8SMSnpto%=#Dh%6^Uen z)bydV`4fbgi1Ze=h%`c&e^8JI$&3uvGYan(=dJ#7M-puD1};HZI;X~f$on6tobS6u zYrl1e=fLzv@MmiKN4JdW-?g--g1G{ME|S-VA$(84*E|MV2YOKED}GfyBYYCT^k*#I z$1_JQT|}pp^#O;%l9G{<&FLe8m3~t+}eB*e6up9uuG@3y>WzEYuK{jRM9bwZv;Ki z$k1%9kr@HFiK`@5{0qN|v^h@I9HTTZEijlXQL+HN{keUS$T?9Tf~k4n1ij9b4QY5b zz0mMSn&mT!K(X$MbYW?U`!cAMB}cw?`0Kl`()p!BQ_1iGg_68eb6&w%?pMPm&V5U#3JPuKrlqF(@2ep&tDl zR{~MtXeGW{)tl`SUq{)KpeanmiV@)a%_OOU8J!_h(P^cvP9K1fRe-Y>vP`nyyJ<+3 zEF92Nl&avR`wi$d({vvcyBl09Pg-Z)>^8gs=@e-89KvB zJB1JT)o3T?umHuc@artN+aXwd$jEj~FxHh9*z?a^enEE#up>BKAO!~+>zD0FL>`{+ z*k$t?@{amq8Jd66qd8hG+D>2=5s?3t`U?;ed4Ogo9yyE)PcR};R3x#BSR^q=8CjMR zJ}feH`JM0U&qG^#te`AAkC5zJsMlF@!2(vaxD7pJx1e|j|L|KJp(8&Hf!c%-F&JWi zzTmv*8d+|9%~s_u_`sLh)^&%IR-qMb7cczLUfu8*qPQu`++C}pbG-dy!SB13!a#LA zsQ)qIQn~-1OKR5VXALsI+ko(Y-Ui0MryQ{wGKg|;L+_v+(@qIt*~(TF#XvDGk{mfg zEL2V5rTpyY4e2C8t=~JKYW-2$Ds)$susmppxGx{P$aW?1W!tBvyfPRW4|o|5(zCic z-#^%(Qi|o)@_3hkWjm3BGL zt!gxBtc}h6MMqX={5L{(k0)%ZDqpFUtZR+4ca}@l6gmk{2#x!h(!F&Ov{ar83ZXzI z#%e+jF7_=B0#?Sz<1}EkR@pKsN8e81R?nJ69-ObIetTmq>2cvz?GO zNp2h3yW=rdyc(Ud&!(&0$!{(|U=!p9=5k;RSzTgF<%Cho7_#s33Df920+CoTh6lT9 zrAw}LpN?G*qSss!K5KARJ4$%)VS#C@bWAthwGw__+Dzf4Z0cb!uUx+c>5PD}#MttOAH4zz9A{cD7C>r|-)Fb2zt0WpPGAEbg7o4b@ zgY(HbJckmlsdv?IS3bV#?&1TmowBg1&T5X&u0z)+J5i*cwz;Yr;dWuG3kyYrg9rp> ztg~s)%C(Kc>%LY^58WdX#0Gh3nw+Qt)w!*!i$aZG@{(-OP75QG}%X-K@3$glCCU}*yf!{G?ays27G*zu~U}+sr>Bq$EaDtfFs%h z*y8w&^ZVaaj|_hYqJpJ0q6nN9OXtmUk_5=7G&E6qCTbQ)dU~WLNB$cqcAF1CzAqfY z$;}G8&3SoS2UCeUAH3r0$JfPgawSmml*l{}A`KbbY5t-#58JonlMmrJ;52F~v6P?LccX%g)Ta%8yBW=zIz!a2KkH+fJ>zS2$`1nZv?C7_jSpJu})Oo*oD8v_dV#R^G zdAT5Yo;13mSHgsFx@`6z#y(#%^yy>->E)OsjHC(9TC)h?qf5$owBe%X+D+CmZ-h89 z*(B~zaz-ACErJh z`6MD%=eKVu6PgG?HLl;&OOObl(opSj3|xDM(%l9}K_mHsUCoV=xq2A$!#ZjECqoj~ zrUL$j2*U;IjU(?hM$WPEf-g)g!RmKP(Nms}fs8@zlvfC{EwIOhnle@n59D%Wv%)Z} z#W4d~XmUM2WhwTURl&f^g}1 ziDBf^w1LineOldQ?fPHfzy`+W+vH*OfG>Bs6VP!*L-s3})tftvdABmN=+zKIkOwic zMbSMWk0^-x!*?E}won+_+f0MxaaM*+!#IUM!b$|6u)*)6n^_9(rj;zkzr)^*Z01B< z@bP}>s~}29nAu1xv0zBsL1r{B{QiWTQZA-l8F`BTn#v{cz1PFV;u5C3$u?Iy1X-e| z{KQVl zpjQ8F`1?N_k+-BYW3#~0NdnkL{vXgyJ_ku_V`D?Re?162BfF$Q=>dbhO%FvX>hl2^ zHB<(CdLNYZbW8hfE-G@LdZC#>zD+Czi^?AG~66l?Z|kQWb8;m)lHvi6DM)5C^JI#Ucw6kz27>Hj?=A+ z$-IJ}4-a4yOh5>T`KUFCNQ;1-EqPabkjLA8?CLlghv9s@^T+H}wtOvY8F&<;z@zvd z)#TqtAq;#bTidz*`$Qu6BtYrm0ygU%mz2R^wV`~M)Tgny75xdQAfWl7H;V8B70jl>XfzoxRFVqt%3hY_ib=G-$)by5BZ7Tj( zxiC8{`yvH*;|ey<)gUNTgXI;%WVEm~IiU z10+wm+>d=wseM*D-Dz92@TMPe+FHZ%NH+u$sazS( z6e?L}@O5O8*^rI2giTLN>?c02J)cmCgsXaos3kItF^rXV_jK_FcTd7vAE>#KccHOY z2O+%DcLE@AL^wps^hJbu@4;gtR7fx16DdLP1rj{Ac+>`hrl5Rd;<_5GdMiHAR3j!;kZ6 z7T=Vo!Df96pI_>UmEZrEl`ein9=8RShgrb#5dY7A@xT9lME~B@@>KX2!T>al3bO2z z!vaK1Sg_bvkkH&u)a4L{r664XqoCG(3>GMH*uNPDb>+3~+>al<;tenSdx5J43!1IZ z>E}G$&(Bj+-##c`aljx($tU;iGF_A9%k{s|p{ zit1bdFfDs#^k(9r=l)gVN^y;t3o@Bs^f#w=DfqUd8t^%43HYA9pc|FL!z%rV5XfM_ zo$iMVMgwVsVhX8kj~`r!()q*fi5FAvk(Ao!j+Q`UrigiDm0ZoJ=eo$&T=c=JoLv4s zG#B#HpoDs;$Dwnal1mQ1Wfa|Et%^0`hLVd3Mv?UX=;?lh^%DjtteU-n+DNB4>Oqsy z+9OVCbObkz;zBM1v=#UqH<@cyH2%^4+JGV#BfWuM`*yk-vu$GS6xBM}K@LaT3B^M> z%_GC)(0?`kP{|}X<`To<7g`v|qCV#+wEfo2qw}5`8^xAsneo$v zU0u-@{)POLP&}{4fDN%uZfE$Q^(8(TO*Sd9bDmAfoSGVcB2T9)YwtHE@M*H6Mv;4> zGi6&Z0%J_49n2rb*vyjVvANz@RO(@LJ8Wy+!TUAQJ!If_rs2}q7u0LQzLPBHz-9v9 zY-aX)=DV-Y1)3%!N@wpd25&T4cb6h7^8ykwixd(g$Da6Smzdc>t+hGg<>4J3{M|V= z5{NVc!7s36cSLG1p{co!>NitcIgJA#<7Q!EPha=w*ST02{*2cab_IM$V7%G@v(Eok zKQjHhf$u55DFbAOzMU^xPAj2FMnghSm<`SUqP)@_#1j;VtBDyO|D1W=I_4y{M0186 zqq6(1mn%a84t&UfzT|};Bnr`$+~z!_+jre$TyUOMZSs17xI}&?xmL{>gegx^9g>R6 z)c{+g(ecB7=LeVXFmTahjeMkc_Q1K#Xbd%&u4+5yBMko)Nhtf$+>}O26vBEm`b4B zrdv*hM5ok^4ee}O^ZDDee@aN&foD4WFaQ9SF zp;6)vD`Vft(y&3#j-=ZGq~ACx61ldBa-NPZi7q{T*la#akSp3_FhixZYFdBx zMY;MJCPUG1W-r?T^O4{_V`vI3YI8-`8VX8vv9W>!jD$antQb%uo+3?XTN|r*-5g6- z+Yi<#{^xc|97Ro$3owKvfhq5Q6gU4;yAu5!K?;_?Bgl*9WKoW?>Px!!qi<`Cm@b)# z5rnUT_@D#>DDI=f5slV}Nz#2}x5{qRrFAlz^y(6r?!#rYAU`cPf0d4ZH+aL{(knZv6!k>jl)C0zkqY46 zq+Y$}V}{TgyPA9o87OF&Q-?j)OA30)Pv6v^Rve~Nz4NG9=V3?M7V-K)WRQ=+*4{EC zATxk18wW$HM`yW-y6qMx5|eGyt`e3qz#woP&$_BOWHGxeFGcRT11<*W+mU3mB){ig z{&A*A^|MmN!nTOd%{*2@j_1+%IHOO0d)TGgAb&%?2~*q@52tlrr~WgfKSIhKd#E`dr$fKHOp3#aT{ioG=%lho5xSrQng&0MFNdvVn5fmK z7^WFmKa%stMo)50_|$5;D*YZo$S@A=WS0=)IG$h?KuV_e90a#b;E}lrabu$5w-Hk2 zOxgz;8S(q=x9j`k^e*`IvM@&~XZCXPiHl*oN|%h1k0_)(Y=F zB2YksqJ^pf0Q&iU5j$U)JcPmZ6-!jRWXtF3R3+oRaxOqZ5^@)psZ9f{rx{kjV#mZ9 zGB6~x@ftSII8i9wM8=VKUcPp)%6em;sNyU2yBFjI+pa}8qQnev>(O08Al5VjI@0np zgaIT+83Y^8@g;I^4&kMdu;kx#M>B@#V+VD(P{WM7kEg%=v(oDKarsID4A?zj&LjBG z-{`*^n)1Kd-)P)(R&Di_veTuaA8Cs_@FWH#^dj&}h!c_C;+X=!_c1hvA3Z)GdZO*J&IxNYoRZkHZ>HR6b-lg3fOQjc4HHCQqObLO*I6S#t4qV2B`v7ni=P~9Bv_y3*xi8F8ZW~3N8W1)H50He z4;)+$3!a}aY7c|~%5x7wst+zmkgb*VzSZWO=NcPLE%nZNn=w{flzR=weHa4A{_gbL z#!`W&-a+l(VD-Mm%90si{gN{_;58VA@fBTk?{!^gLzbq3^wNCk@-x=FUdcC&;^ZF9 zr*1)NXcV7zV;?k9bkbC6 zezJWFJO&?O@)&|9I3CQ5?$}D!HToua&Aw;d(;pt<2<=>>+uK+jQX1lp(rA;FW24+r zvZ*iuC_`-24H{1T97yqRxXjyM_hn1$wV_Eg%1oDSuL&`(KCIpKDsJpAbLbu zuyuAvBu_QHBR(u*@Qy!p`dZvuVOLO{&U#%UqjSMTDPf+f+Rd7t*8mW>I?P(&K-rHL zkk8sbVydUv0&!)qfp?vi#nFZnSzV;OhZcW*{DIP>31YRhQzIuRNu!O!rd|XiaFekAF zzQchvKIueh_*A>lVvYI9No$QnQA$XPNPc7i^xoLWZ4{YwpOj(dY;YL_cLOe-=w*QR z2oi}}t90f^9z5?{a=Mp>6{R|8WN3wC4QWBW8@oX;vw~2GlbFa>nDU*nzt&o}=}cVb zkenKOutlO>igcD6@~zUZjbhtICK^lH0u4upos&@J0B`ymCUYlLQ*c(cX{>e1s}AJR z)N75F90}0R_JKW#d(F$O)8F>BAv?}uIgq<}dcd7sf0dRD-*Gs~JGD^G8ZPT}DI7pc zjA05(u~k;1lW^#;A+52j%^AqCeW?nG1Vt`$>;h`vqI2+^>NvW%9$11GE3$LTaN^h< zg$g|#h`5D0OeOooJCzh%gkc8WmH9^~n(ltNytr+WL?pvy`kXId?`2MOXkfsiLtWOiJK< zK-Rhaxt3i#j`1#VZ@LyaK%=P?(1V~`FSNUG`9+xNbX#InV?(`5#HFm*aELLy7S@mmOGJ4@aJ$@_jH+i++I2hv0(#3Hwm zQ5r4RjLGYvq788DMzZ(vusFcy^$QF55@VLphi^LJ9CB@#bdD^dd)*F}Fx)Qsw+&>t z4c)wFCi`pN*Jwq}2kA%`;?EvsAGUHbwaCK@$fDI9?pChJxJNnaZaW;fY; z$JROcM3ofYFY6KZ5`Q2YU=fs0x7>d_e#-79-x+)j-Wkt(9QA8*TLz5a|6}YeqvC4X zbm2g78gJa)-95OwyF(zjdxE>WTjTB?+}+(ZxO?F6&V27#GtaE=ob#ji+P`-1)m2?} z)qP(AMqRGNjuY$1gya2{9X&lNcoK!Lj~wkGb(0Xvq}XEGT*Tt|6-9*lgAvOe13Oar zK%}d)$UlIpz~7fft|^uTag-Dy2>_KyC!T~yktEljL{FAvQ$ZC|TLn~94yU4RFQFno zqvb83QRryWIWDZ8ffDG|v$6(SdPh=&o1-FK!TwS0XBPRO9?c-*7Wx)cqo6o$n7o-g z$ViOV67q_wyc?fN`iqP+2C|Gj6jKvKB@hnZ5tA)g3fmPP8cvQrCR;c~8}}|y5xXIw z1QqIg7)2wdCY6PERaT5$1Vgm^=PMOjHNrdOf8WC@Sy!6kKKC%W&lTo>;C59@8?#Tp z0~`DQETGsZ%Y8N=fc({SuHUgoHCMGQYjaiC!2Y0;iWjQ&B|>4uzSI&|_kjTN77em; zFz|c+fr5Lz5dIE3NLLbCmtOdAVUxSQov)MJkH4S)9vv}$DK8<<+Yth1<^vXy$f^a! zk#zz4j&Y;?ckHP~$7l$cO`8_oCLYQ=J%6; z%K-`aOU(k-%kqyzhSR3z`1V7rkJ#q^0*}!x=v=>>O@87mXPdG1=kR2s@fGA6r<*rU zAlp2>Ix}rJ@_GH3K3)E+x9LzCZ~Khlw$^bO%10+2@bxH^c1=BJy?^xzmhFT213JvO)|l8>9Nfb2C7iGP@3;bN3}hE9zZ-y>GmeHRcFBK&&^Ju z#l4@?8VpcpTNjoV(N4t%VL5)Jkf+z26STz z0#>@E*TFc4hl(-q;lE)2*#n|5tl*h3+>4J`M9MG~*!zVlb^J0O*CTq-BiW$H2ylOd zb5W1`CB!8Y*c8d97B%Cuo0Ie*E`Gy;4SN_G3DHcF22bpia1kbG+)G6@Ak$yH5`QyE zqso^IxGnh2+}EFKB)lt5X3N~~B3XXSI2I=T@-~u|S_V2&U{UPK4ErOEO(uFb6*?s~ z53jw?qqI;P8Vrn0@Emax&(njCT_KX?U}iO3X0DNuJMf zf`Edy8)Y|)DP31z{PO1DlV@Lc6w|ec8D4B!)idjOY6o*iZ?lbmzVkO~X*k%9G2gkR z+`6Tl>n|~Re6{52EActbfw82EI`x}`%i|!yJaY>T@c;pa+0W;Hc%QA$m57l2zrnHp z?S1yyQTv~84Bg~E;8-G1UmB-xttRsP#I<=POYBXHLLZgTn`s96XX15+WkIF3YeH-0 z<@j@zps(*c_%<_vS=?YHz}AF5g1053haV`HD4U2qtDv`)G=+r`QK|8kxJK~U?y3`9 zwN=TUF;So#(o+IW>2FkZ@UlyEK^SorOXw9y*8m>kg-_6SA^?uC;zSBn{^ymX!iz5) zLQg|mXt@#pk^XW`ca^vHQj+s3=|hKwoxBD}k7IoA`1mU^#^h2!|cwJCD5* zgXI`N6n~#>ug^Idh$iEo1N^w$=1%G0uv|Tok72QtVGygVL~YQIuEkECjLZK#)Z80K`5GifLE~%(?Qk55uYqpso7t7uCd7iUb?qP8R?BG0$Yqqu zjU2aL_(q?a%3N!^BfufMjKQJAe%;+GV$BSkEa$xUDoZnR>E)nf#{M}ZaDHFoX}K{> z<6>!@uMIYV)^|qM8+-j7*M&oi?))!3zrI>91f^C}b7vTEqTvPgP6hj$v@W+G= zvgRc2^E_pZTY6Ftnj{P0SqOFXb8Y$P8V({RGzA$r zQhWIi!XhR6O-}!$XYp4IjP`%ReT4rz82h(_lG-OROAYxGjO_V0rWM~fVmo;OHOhG7BpAy7fcN(zM)~MM_!Er9WwKgajGv9O zPK|fIJ-kkJeQ_t;52c9Mj7m$f*g(FaNaMR%B1S&-t z!m&pW;pMOd65tf=2pCHioo+>ZjXQ;}&-l^WWaJvrsBObLX9Sa2$)826%_-Z-jdOP5 zs&n=*5?`W|e(V-#GkhDyy!`+O5voipvQ2sIb#Ug{EYWf0EXI~FjzzPrVqsxls*z3e zaBwLcM&?8Qi{dkB1&GV58OXJcl1)KfY2&SvD|Xu5G7m+dAEJMBK3_R>(!*^vWdKbv z))4C|@ts=1R7zwu=pE{Kh2WOV!se zANK31cs8y)Mf~-Zr6)3{o2_Eq!2Bf^W%zC@p0jdM^0e}?9RuGhVB#oUATgI(cZ2Z9 zFQbR}i%?#PhntuZ)~MMxYX`1LULz~!E59w`J|Lvk6pryRLVUsG%(f=Tp#w@hAhMw% zuJRAM$Rv+I_GPqn^r&^Z_Lk#!Kb*F;`iIa>3J2A~xKc?&Z85YQr44nLzplRbU_(bV zzq?atZ1V?Z)N6yH!>g|v!zq{5Ye@%GsDFb^yf6MNh&9>)V7}+an$VG~H7HY3DO1vc z-u#O2HUya1_{AC{e}hDXKa!0u>gbDfobz5uas)T`gPcrSW*0Wf>^*(=KQ?Y7b(dCT zT-O*?SuK-fqeiPTZ;4Fb&mBQ-()xLSoG!j}0z#UwMvH^<8Cu9Qnda&1$8tfeF@KWJ zQOn0wZj{#(b_M!oxsM0w&a-XsM{Chpvl`p)ln04FD2^p?VS+i2=fy*Pf3+oMW#qt) zL;M8y+7X+G1tXz6#A^#^&Rx`fY$zQoVI# zRt`*Lf#9$QMG!6|(merlb zsiMHgYmUu4s48L^4Tm@wO`>N8TfYM7v@MbJj`{H)I8Cv{R;k5iwX$+D=byFEY$lxH znMbg7@>3VtH=%C+-v^->Qw(O$}5k_e`+`Q3l5BgPz*vPcY7fPj2EKdBh!%) z$oOQLS`69eVFA?ime2({dq}M;4m;3KpVmN0SdslwJgkonldX^Pf&yEcV3|aA#u&KF z`^L+rq1n=yZ zF#MiCtd>+&5(9<5vvn0Ml3iG4;Z!`}Q+i%Ir^t`ZL`M7R02JT%sZdHwqp0;!H4R;N zvJmA`&*KTvPMmf6NbHZrKpBvAF zY^0gRgBG8@+r`xn;4jr@b)R*g?ii+-qZcl2ZEsGS!l^c7G?I^epgQ;h*>AZ8H$!n4 z3!^1V#w_?iPb3yOr77w;?kS%W{g-$xhsZ?G^p4SxJCe1+FbNrIQP>pf)EURfm*Z|c zJ{QBxx!9%l>A!&Ae<^bGXNe%KK1Xu@b0o?CS-=rV?4YL2x<0v8reH!pL z5>eS1D7nH6GU&E+*D$B1B6A`?q^x$cPp3Cgz0+xsG5pIXbx+oE`s!1TV}3Egz2!Vx zeHo+hWr>TqCg@X)bMWgxaVRd@#IF+fpd^)PpOS<1=HVP@D=)Wi;(c-l~u{C8YVBv;W!a0Jsb8pO)P z>sOp96aal|Qy0 z0+oU%D>TF*lWvwn&S?KKbpzhIRT`nA8k_sk9Rt;4IE&Atr-ZJG)`;ED%g&4if>Klb zM*1VZZGK(UCFMUvI8y%-;Y=axP<)DTg8wDLLHiWpFyhI#SDFl@XjvMYan#fei&c2{ zw@GB`f2&?#G@nj3(tUS3&m`t@ff#9?R|>pqA{@9iIdyM&qa!12$F-#OryF9iP_ ztp8$$@SkUuEKOKl;1c$Spj%2>7QV}ddtQmCQi>QkY7bTTD7XW9f17(`2^Zbh3I<+h z?25%qIh(nnl+KK7%Y@C4I;b$*0=a57smoXCwY%S!95!>Htqczac)D`>x75qdOP|a3 z7;J){7rmrk7Fh=1IX5e}`CAl(s{#NgN6bA!LKcQE{WMc~xPGU1oK;X=flTU3ISnJS|Fvz*u~P~=xQb?#S2$*26t z0JyTuzbRziT7$%p?33@bsK03nM0z|eKG+dX{>+sX36r38KT)}Jj<17D%4tVlfj5TJVX8bfTIC)jn>cDMA&Pz2jOi#g!_3b-jNeS8 zsUJH%!3mhl*po6DGBhZTvzp^Y3yiZytlR*U`%g&RnRuEBD?F%*e@3vbsOad2TI8-o z{t4svq?y!$^}PUXT3pq`c1W%vT$`?!nP{d+sP5z7sr(Ls(ClAA2a8Hu2qQfh3x4MnLRMlHN{A3tv1HIG-l4m-Y) z0ib&%m1YeWc64U5kY+bLT){Ba!2CtgO%<`i6xSrIsk>>xop?{hpV0CkXKuMuomxgV zMbE)$yjz~Z=-fg~2`r}82zr^(XElq1GyN>js6g<5w zl`P_eO=`!@i95LXM33tD_qvUPDQ>-il~`yutgq@3J@7u?#6B&EJ869VbK<2EnAK-O za!Mt4j%Wai5~<8(sAhgMCLq;ye}mWzaimNTBXwdOAmSXz*eF}iCe%8jQAo1vy&}ce zvTjiJgzhV+M>Ax^N3He-)s=L`i{3RvI4F7aT(TSUgeLgHDsm0tmej`~#6hLjB{25_%pRV_HF5=2fP#*OO`sfbe1_-hzY!J>Y3w_U_dB7}cQxfws3TLN5 z1QwO3JJc|VMTLt5Q7@JqQ^`6cg!g^kmQHgIyp42vedRtdpy8<&Zb~11uOx9rucJZN z%QRhG7B1X$UD)Zf*CW8OBCZWgBjaLCbsJTM9*Xf_^(OVK5OB70{e3-}0cga*VX41C z@Z`C_;Q{uAQd#m)f9-e^SDLv-U(6r!{1fFL40j1GFt&egC__`2L$v3Q;??glGMKGI zu=X^<@oH+$#8brWXast{dr#b%&qBFiHI`=p{B7mIQ6@0UK<_<3G8QD@3Hvs3l zzP%%wJMJOl5Hn*?2kjTpDG9Jcwxl?NL(XeFmnG>+nWZeIrWXOV3yIeF$P9vf9 z@hRX98)3`Jkh?uL6zofAwZ#Br;%t>swxbSyOA|?>EK1O`=AE&7`xz2F-0L<*nH_vD zN`liIsU7AqGPGoh#6H~Xj8F&+;&`b`Yidn>WdF783$_NyN3<;jh6KAzyln*L0>%p& z3A;Q%?BhK{^!4rb2?q36)g5IB<>eQTbo&6ra|{avMy z(ljU15Dl2ycZlpUd#4v_lVds0GDP&3YvI>S94>zDkjgv1{X<}0c>FCDT%|Yi>_k-s$+t|RNQu8`?&0(vW%u2D?%KAoxI6mx%WBMKSSq4drjo>vKcq@D8iw63V9vOQ~ z2Vd&Sqwae;<{JTp<|0Wh^0-b}5(hZK!QN0;M_SJO)Vp*6z=wEDWtGA21LusxLn{mZ z5__$R=({!3QiTsE4}|U^=}lZ-7%ge*q?#IT?jNkCUJ6SFVQ(CCq^?f_QYpx z+V>j0>TVcetJM<5wdjc-#l{zPX(x>)VGhMZl<-hbQO0@aP|IrkvQWtRNUe<`Y6DXp zvGO|{6Ln$264xP-B8(A#^r1H4npqPH%Zfn$Xim}>$>$e+rs+t3N{sW2enAIq+qg$f zwDbIsbcrSgc41-9lEv|H{)|B#v?l$-&~7^^RHk{2jM2g*z!u^}lC8t2i&(Fri&(wA zw9`-`p)WcA4y&rbIb8Uoft5$)wkZBa5@NRysuF!olq1}kDLtcy^^SY;DeL|Tc7XyPw`h*c%nJz!4r z({mJYVUca&!Gt+buMmxJ!A=${A-qLG#pety>gn5qS9#rhe<@c18XLCcXbAFV94WK5 zWT@Sv{qL~|y63QZSl#_6dQ@D?cqZ=7-9r$z!rV~kh;7?z(LO2Rp7Tch*X6x5G(m-l zre5c8IpRRuGwR)s-Ta&h-+WiYgkSgIi?A*F-8f+75W3`slHG;i$vm;GfAj`K&QI&U z*X4ljJb^j+f)@9huJ>7IC$H{zUH`peO}a4l$oUD_7ynO8lmC7G`p?*;`VChjElBN3 z)o+SQuYQTBXoOZU!SmA~#rX7>?m1`PCYjX@b64rBg(q|4yW%JRLFm1D!BKY$9U)^g z&a^Zyiwo|f?6gnLybtshkQ^7|Lc7JHuiJ;eV#WPe2nK=HFbi3>$M51M?54U#6W`v5 z&!%fQ0y%$|N|kzymERdq?-Jn>vz2BMx?M;I&vE4SKsTXhp199?;&z~?W()O(-0q$# zp#qZOlD+lLnNL2w>@)}6Ee7NK}^4bqytA2Mg@k zJ#PXMpRXmUXaEv1pYnq62BHk9^sam7s>UO6mZxD%%?8}Ogd{I6;TI5A0V>cWs$N1R zc(O+6JRP}ZFVfC|&ao0bJv;6e6B=r68AHsJ_ucYx0}e{p?c;?Ck3Fg458? z&H=HyGxz;ce^TY4)kg^lG|R_*^eRkH*(*Br*XOqwg+FHysk%-UXt16GFRWMU?O$$U z^ZEP#K%aRVS#q2Bg1zEprR6aTSr~SNh@7hR-%;!KSqB=MAl~ljqEe^2hv2qICE+X- zu7J233D#b~_5ii#y*C=E7NG{+21^Hn(944oWKjwza=xmr@(IE?%?$u;dLpzQg*{i% zPD!nVhve_N(TbObK)g^e^TRrVc3gwj&f{tYe2f<&l!0&dYtK$;&*yH5?-nCDbV24bA36g+3=hZZ-Iz3W+&|-Y=vk@v|^VR~^8+_bDY1{zY_o*^e-yOd+ zEQ*6d*%$F_syS{P=Z>oPwOYn_M1*yw-r?0hosCF3M=Z>?tOjc|rTo*_)9;6^8W<;nh1!u<4GE-=*HB3BrZu zS6P$6vUtu2Zeb@rmyO$!*aO=T3x+=CL#(vD?#X`o|Gv~zv5L%5evbOX=Th^3CSm@Y zm!y3D$?pL2t2F6ptAECoK=dQc`w|V1q-&Z;TEUXTzf)NPK7!4@Yc$tST+r>Bq*DA?DQ$k00OgjP!LJqOSI9B5?vWi~{$V5{CrPw=_vuE& zc3npn9cK&G`Q++G_M%Fy6^+?OJ#;` zMQ{dxE8>3y8r^&gv`RT?HZ-2?PY^@hhgF!Qi-#oNdqFU^}m+>KlbwykzqG zsnT`EugfjsuTiqdAN(i6rOs!?oM{iXi{C7D>Jt4-sfCB3_+7H-MTTkF@67Dz9D&So zB069b9qF_N`X;E_rGdiMkuJbZHvvW`wml$fzz zezVXr%Z8irAo?fMs4d^CmLlkf15jH=t~u-PKcqLDF2p%kpR?@$!A0!ftBmUZa1m>j zY=sq8@>3>T&X>m~aOVYuaA>1aaEsaFDGfn?M6-sn3Os%FzJm_)QidwgrC`nA_j;bZ z@XdI8f4#;01=|5f>JROLCCV2`3<#MCMZqs%-yhiA*)hUDVJg})!rDt!43UtQrC}}< z6&`Ac9s=Z9uGJo(1uts8ux}k+lepbnSKL`s<}k@QRk|e!$+U6zOs6*C*Xpk7Vs642 zFvb^hpKEutjPmPb#YCplEeiw~@kC$6i(S0>M`u|sjSbOzrPvL&MlvSi$*fit#CnNS zpaIy|IyCd0%~=AQTZT9jY)9fC_F=Vd@P6J8cMT%1IQpc~!JNujNobNw9hNRXV~#tG zpe0oSn&IZ9)E%i&wH9(~9ca??&QYmo<~71B@@W(;H3@!twMo(NjVY#(0_H}-KJ(8S zO~U989$k>X?o{a9(A6526LVxu;;W7$n~v6oS1F8}2GX@~2d|p#jESWO`BAW)x?34S z)$<$z$e8Vjf*}2Qb{3pL!|+qxtaPXcn2XhYY&C&LP_x5Wn(DhjsElz&-@775K8$EB zrqy<66C@`0q;wPY4#|NP!%Wvdtu*EsY-mJ83WLP;Kua@|szaVBRAb7x1-a%<+zL+( zNgK5?lDaGlwro_RhpvUGv^Lf-F~r)x*K0SP!qRA3JfqWurr@<0BqP$w!pmH&enF0g zq83O;P5@>o@sx5H%Z`aTghHLQg+d9sk5BC`L#<}g2@SXWOEBVz!@<|Y6Ym7R(;zO{ zlXf#thi8-^HgL?2Zn`?>2H(j$qXf6@AZ0DY6ijnYcg2)_RXv5Cj-JN|eE1#xC;YyztJ~GObc6T?SC#t^?S(DC1t4g4zh4&34we~4|64l&KUTpSReS33z zmruLkhvzkFH+lj4Vdh{lYFah+$Fsttax?Vfo zK6DI^~bn;lLTeqqIVAbKNhNtWO`vRtmDxdWwUHJqq^zu7?uY#G&azVgBZmKkDqV^YE zVR-5jTru^>)ADT@$T2u5;JVTk`ht}pUs;`Y9d7ib1;PvrK6Ir%GV;huWMM{=%!b;& z{sM!_1tt}eld1v&zq>-8o}yny_*?;w`gLQl*OPdkk9WDBkz7D@9aOXn0=kfz5tVzc z(bppXbSU_yU>*W#u8%E8nw_(}eFy|G|F1Y-;a&Z~rOyhEyi?4HpCo%(VBN2==3JHy756&bTd?Ye9pI08Hmrec*_jPZr!QR;4z5Z?04fZ#5FKO$(+83$dAv2-MbkxoCZmapNW>S4 z+C~+_62s1!Z70^Hu*Jw*(iD{yO7T~MG>P}8g&X{POOw_8!x2k>4B8QHRU3wC7;q1t zrPUR?M8(#FcF?us)aDTWt~Psu8YkEA@edENWEJk?9Fi|zcx1nPA^d0i;=d+A{%8N9 zkq6dYbz$LXol}Q`kx2@MDbVcK7h?Df5Yo>g}C+fjgXii36qeI45 zyQ3Xm_ld0a)${)LE8C0BKAE5W0|d~Ur`@mao{X1mImJK6H=tYGV~2cuOvcv@QgO zjsz~dG4Y;AI1P4p%^37|cGY;_$6?u@a4@T#uALY*w|C_jHtY5nGpD=xzIPk2^f$LY zp<{L(?sDqBB}Z&ITzfHW?hO32-z98yhuUh~^8%du@V<}u{d5(EcT(s>!U{lYy+^Tg zkBW%#STbUnS3UjY|ikCW%5s{_8IE0kFTJE`1V% zI$oD9C}d;qW~j@w4@wkcj@34f6#*Qso+FE3X>zy)^Yk-?U|k@S;`Qz!lV;VZMLLGg zAjb9m806~L48b}=Zgpkegmq(SVT+Ssp}j$=NIKtKWb)TPc*M=Sakb;JR&@JrV-bRK z!=7Rjj#Zc!>!zY-LAH!t8WRYxgBBiiBn3V|zKP3tbQ4Cq|3z6%XsXF~_8pjIUqEk! z$H5>mM2`|XO7U9bMTpv5`Vu(bHgMo_OS?F)QCYD~EGk*8tFE)DsHU-IB22`UlA_Ld zO5lK8RCs&DSXojX6QzZ$%U)Pq5vQ|27DXs1zgKAXBUzoUq|A<}Bma~iCT)y1d2!AX z{nk>$#z(}`MucDZ<7BQAw*!rr^!Gy;`=%kYuykkj!ex|mFd%S%-@+ne!?n80#n?xO z2uNLp7^qFj@yg-BOW{e{0BW5$;yfYU42_%1&>bL$*!_H5}>vVb&`zOEa z4iR3CL$mi8^>oFCiyNV}!z7b74<(5jV2#{+^Cey0_-ty@aFL^X{`Chc?)(i1g$dUL}L<(0F!cXzHg2Y@Mr-!xFgtq z(kMvhw{5IA$>@(u4mM+BUah84EpJh%>t8WcKFxUW5G_$@Jqu9%^?*dOW9M>*XmieL z*ahi}vT!j5NCtj2$mOG<-v$SBXYiP{%MgD407coNlG`lf)kX-nry7P-qnN*ALf;w) z@HI|cHDM9EI7^=)TS;gElSAg#04^L7`;C^OV+R`n>0j~j!36oi?Yumk!&Esa{|<36 zcigFy)^Y6|%t?Xv_1ILFR3KY=JA>(3)c}JRQHY>18-8{%^@}G^8wvlq*Ln9knandx zZ+L?gj!kL!98>ao5?`8B@#5nwYub)!K@-Qqw4A1rTp~@ol3D*!^;u~Qi>I!LC0UVF zZ`=5KznSu}>=pC*050Z(s-c#%xd-aT{`Oj0+mN{2E?w^zVT!P5qlR*?GHiwFq)wom zv^A!Gd-cHph0_??9oe;6+{kD@w#)^|Z}(teEreb=3d`i@qyljR0)2{L*1%5}QSjp5 zC38ED&=#CdpTQQE7;&az*z1Sjqkn5=&7ArwB=62SHosHq+dY`V*Ch)GnO$}U;% z!vad~s=}YCz4{u#KBP&3ROd4VDl?&fjAxV7T+nedqKHgnU-2e3bDLK%i4Qe1WarH@ zw_DIWY{vOmGVo7=W!`BA*xviKxyBJ(eyO=i>I4Hw^voVd0^&2RY52-@!xiJa{7vF> zH&XKc5UM07Wq%eP7M|BQ2jYxHFCO*i7M8JKQLwGNcg#x#6SCaFVIm_VQnR1lEK8af zrHW@n^^TNb#xECNIR?`UITjMNN9^;BY&-E9Z4F~GUe(M6m%Nc&O$uh#MWGzbMLq8h{aWU<%rX{a~ZmTl~0J)h?cN?^fPEq<`nK02aPfC;l8DXC=L#?A&6 zQgcLDpj-swc#lK~+e^T)quMo3qUo}QmC}fc)HRig4H)$yDAv=4>h!L$Id#(<;K9Lv zz8PJFVHce*Hn}fVlrHP4-#eSu8Df4%kiO|vQqqhWA6O#@6JpsJQdD<-7GMl3Gt!Em zlaVMS`296;QzXF)2}KCMK4UG3^|%7bIXFE^n(&r`|J_n{8!ha$QJhQJq=pe|amVia z%m5k4aBAZyLl_I);_up8l)9alWl0h+R}htyaz_mVMs)b`8tO-+q)smR`W6KRZ$D5k zGf;>}N_Js@O$pSuXP>wy%({K;>>Ny*P0^>an+EDDuwP~l+nD~26g>A3II9Usue&-SxG8C*nG;pO4w+tmF2=X!LR#b}IANg5(p!9@wm z%S8#%3#CXjs!>9lRiWLxH#_-D<6kiU?I^Zp5819IQg%&E%v-S_M8ck-wvv8o*V=EG z@M1}bKl4!LB~_=xIRZzdq_ANj_;wH}#K^WS;TxJ$6GoHe<)s%lz4k%t1rcJqrh2M# zQQOs#$_PBpN-|jCJOr1|fnyIWD!DbTd0&pAZopW)y^}VXaRAZ-BZLx=>^d=#3Y#NN{1Ha z75NUw`W^Kukc(>|Qck!s+%W#SO+s|dsOqtWC$(t?d9-QL%P$OA@-=B!5GTCwGsH10 z8W$mU87jZUNBnnCqD0(rToXZ|$7aI7KTB2>A1xCLoD0>b5z59cMxvlMCpD4wV*`cN z6_d>#X(*SNK&qeQ2Q5cN#A@0Z^346jKaEnym&grJmClfPD&g4(vXR}mcb%zj0qT&O&pmDNG=SIX#Ti=H<6TcO83A^jJ9jvKViUdMuApy9<$W@H^Sg01#bHT6ez`v@PHd{0cA_MF z)hsIgdf2%)`Am>D1n5rbA@OQq;z8N+t{r@5ZSLt@(fJu<<0D2>Sa5w%k}2bRQSKNi zGT-X4yd1ug3XI>K@zG~N5w1>R#VcEedwNFDzMX=bg6Z(F458~;AQvKdNZbU0l<$Xt z@v_7ojzbNRkFiYdkyLJc#BI6}47U4D8uRhnWz_z=2wtj=(mOU#HQ8z=+Vc0YI)4Z; ziLGXTM3N|D{Tyi-021CItaPX%`#No2Ezs?SO|R`b?eY4V7jI7FoNm zl8#7ia~1HNJ54y%Y;&A{jEk4iect%_?L5)Kn8A9d(FRx`D8oMbvGk47$;DzNM zsU6ITF^4eSc@nWRg{vlW5idOhDtziW>v}OQBV@uY4jvv#xo~3rc#5OUG{;Ad0-aeb zLRQ`vDovIee7&okSvv~|U@gJtM^UbqpU~tGX8LMXL;|(vSA--4gj5btJg!ahfUngc0aPe++y?SarCu%6< zPZ#X+i)Vz!<5ed7h~$pQTbTBzD7X8j^c-wbB zt33aWNJy#cUz^$g%k2IwiP)h~u8f)#Oyaq)u;ub?27JcfkI3S0S$r`JcbDcRusM7U)r5P%oAUdv zYxKj=kk1Hl1WA29+UXy#hF3CB1xPk8q7h3jt1^>)ZaGTvu@6mzUu0FlVK5KjS} z5LQZx8XDf2u!YSX2}F!HzV_k;EE=h)6(P=<)>(?9Cfo+lRqLIZeY}CSyDMS}qP#kv zfl9zgbpuV6QP2;wdM419s!ebJj2#99)B5aH4j|4Ul;fdz6h zk6Qhcc^`kDQ06PWpiur&T;>avUC0xlVvafp50P_YI zCwGxWyRvtkIw*Ob;g^&$U!8?6<@;*2zqo22>I+>7joY%(6;R|R>|MC_~Oo~S%P%su_@I3#(-P})^5~| zQp4tW#H8srZ0AK1eVre&en{D`0nlBlO^F3<2HQ`*JA)Vi&!2r=9T!&0N_i9GS2!9hCM zALxsd8Jv!%qwOQ?EBBl{+IQ($?s)TOz*>@9rOs7pCB(byADW$G02T9R?dSSuJJ9p* z%^g2y3r#)J#HXL%$=Z?hGKWcz!w*U}sD$&3V!*e@R{rc+a-P(L>FWyX!0=!FvZXjy zCK)#LhpC+GfSK3(N5~}>c*0E!$$_Ny7^O{E{HzT(jzV@j`i5hgt?lW|6RpJ2piFuj zJwz`2O=zqX%u$n-QW;93Kqxzya;bNA=sk(lzD%shOL3_F3*Rj70?2YPHXAnHHY`?P zMYau;p{QdVxQa?TpNy5)q?iIbKo3e5Yl3K$6-Iz`$V99kKpTC7jz#cC+oovU*7fhL zp?wP1Iz+#npv6#Bc)#@TWk{{@*8s3+$g*k3=4ZcEtDmLNoH~tAm#g}wfu6+U>N3Q` zZ3U>gf9Ki~lrT_np%4!7{+9SOrT>PI3Ga-jX`G zfvr2fZ>&y0@XOeC;&2hqHp!kjrTw-=ZBz`~z=ValJX1)L+!0rAy%)aqlnt@Rdv$uE zKgk)FXjUieNnUyQs}#1y-0edw3xP;DR}9FGgnFygIV7#2AkbDzzF9}|v7A_}CQ-A_ zU;o|8^TLK~!lIj5nQN;@o#g9WpUEco;1#?Hpi7ZCQ_BkS4XOz|A0I#54XyOhOug6Z zFvg_8!U~-Jq^MD?M_g8_%|m_HoPWgp8)Z@11bIXH!E|zpT;ip9dxFFaq`GX(Ttw6I zf;B|ixqvawgN(oA^=}AKC^9jSAbwmZjFhYc2}uAfid#--2OW}ckm7mN@R?{hKrUPo z%ABI$^InuMaCmq>R5B)znuUm3?2t^EjFOr}YlNDG5+zPo*wmX4h+RT;qpcjCNTv)T zq9zH5R{@cs#7PAX?+Z}fq)VZ=^HY;Jp`oOu1LJx|P|`9&hXKN-=7gagRhoN)#8Sv< zDZ}Y2QYa%7D^k7b9$}&5q@vQV-vT?81)e5AKx#SA@Ea%;AR`|)D=LbCN}}dNbW>zJ zp(BMtEh7rzp(g1|AXQEd9X1v=mHK>Wp(ZOysc=SesHBLp&GF~2Bffh$2+EMyEg{RG zq=XwNmEw#ka$EPhP3)i81l{2Vm?T(dr?;#~dfsr&g3U~jG_@Z41y1}1QN|2)U>&hp zHd%X+4iiv1PAX)=-}P`u|4MawmO@F(4wa=Q$nsci8w}#G=OR! zDJs`Z+nAk8VzzSz1MMxg;F|R-j&uuK&MDi2%d19@V>oqN-XG`P-)(BtqIS&e zg|tY%Wge`tB;2=v@(R197-g4IVFyN1$?;1vGi z)+^Qm&oHE8$h!&4OYPnH^|8CN1@mF&t3C2#584wGUyR-rlmHyzHp)ow1PkJ`COKRQ zIlfUMy=!1l<^h`@g-#|_%`iqWT*baiCshKZtEMnBTrnmdGEqsUp`>3#ZjkJ}zVZ73 zaK>IWUV3I64tw#>Ea541^81is1ODs_et;3(bb>=TYPp$oWvg5qjrX|H1bzM!M7X|! ze%#hy)mLWECm`3gC03wdR030TJQHQyOb5=c)C?&dw=*_07=9J#e^1D z1ie%34%Y{i5=+okOH;6qhq0@k-yr0WXxXHyOL7@>X*z_NgCCq@n_(CnC(9E82M4LQPD{Pnd9cl} z)jClWw{S*WtAUwwmC6_KEScYMCjYAwD)iw1f})$)XtW+vCQh+^fJm;m?eV;3TAfL; zwvMTjPQCSEwAu`Qc>%9{)CCv4ZAg~uvZaJy#^y9mTUMB!;BQk0%nEji!;(1e+Bgp@ zQ+-VLFbu7xET;5VS|G2yl1o9E#tlikc7rrRmHK^YnLyMDzhU`+Pk+&&51E(;iGghH zk(I#OqaoDDPRC}JGyaDYeDcAsDJEph69HNzPYOd{51v)pMr*i-TD=u6afKK1NKkak>7~}h&L4jx&RK;s`(V*4s6&5hJLfpjD6A}6?T^mNx zJcL4s!;;}SsYp(m^o+Y6na+^ckQ-dPV9gOZc%lx2z(q$2*Pd8~jHPjYHq{6(&_$u8 za=$`8uZ|bdO@|x#^Mjgl;LOd^XG0%pCVO1y@D5tAIp;tSvqlr6U+y=S#FQbHnD2wA z1r&uJDALILLFS`a^-Gsvmgg#ELIrEW`D(1mm@$ zJQ%j#n08voAOG5hEHs}D*mSybXB{{#*hke7mRk+OrhVVmC5)7|k-Gb3+-i_bZ6}A5 z-6rvsOF~gR6(B!Lat=%$<&*da@WdoerR!Cs<1wmQhCw%GwT!4IBAN`Q-{s>n(EYL$ z2EXI$fPx21={7OWAp0Ynz!Y!rc65y;MVJz38cz`4zc4(|0*zS$u_OMRTxl;bo@}=w zuG|$1j+M|DGlwHDmL(gCRB4_!MVVGT;)Xy))P^bQROZdeGGRmh{A@j7{FfvGPcdPQQXsHYCCG3Y$;xgD?|2>n~Is|LKd@7@DFpWcb$EGS8f(MwZIIUWE z3*mU21_l$NW1@CCSei6F_w!MJw5vcKHp}ysuYlCIHL%pu6D{5LKj?J}`*{KOpDu{j zKCqakzO4WJx6~~LHsst&=%3f?)Et1WM zX+bqwc_%8W8zm=6eQiq>%J)`kIb|nvZ3%Wq-7qBAw)Uaz-Q*V(uxlcod-_<67Xe|$ zjIwBt3825JcS8Z9Cpq1ywK2{^zE|NP-IR#G44iu?9AG9S&?X6|}+Yo%PEs|mdhur#Kw#}kjkuWw$mV&NI-+s8Y1 zfmG-z)fbMSC(AjrbZ%hwLe6eIYnY?+jMLJrKzfF8gUz#Vz z-`j0A;(4WcVyffz-!q+8abxK>{r?wZ?-(9=w{7uuN1cwHif!9AJGRlWZC7mDwr$%^ zCmq}DB%QwXZanww_qpfn`@O!^{I50Fm}8FL{GJUZQZMo8Z@MYu-dL}sdm|<7sv`E} zn$eurJejC?-zKO+!?cGSI)zW*2lq__XpZD-IL8w05UCY!Xin^jXU@X)ia2|Y);%rx z^UMbuz+fabZ%oYIV&iV-M=XUPFy{s^)&*NIY-=#b_O=%4zLsr&HadhGNw6;U&w3@F z?DAZ!l`3=*fV^95#*^$&d|PZ%>{Wxh?G1)FS9fDTpEj#HYffONPQz{iSLk{J;?4?A z=nI?;+io8L1ahi`@_h=rsMiX^aay>oJiT*3uJX?xSQo@Ol?JiyrEo^2_$7o;_#t`z zzc0|QlMHR*@5tS@sO6@Yx(4mVw<^|oh*lp&B^<9rxe7attwi`%xj8lU=G{tk_psDl zibO+JSc*@HKXt}^qg)R`1zhTK#@ud&;IpWmnSAAkz*&3r!xzU7V8LNw*TN5#i(Y`U ztr39GMi|t@rOy2wJTz;=C#y9u6gO*P_qIi>opM=#+;i$Wa~yYx?b`Zo#ObnT$jQ)= zV#}1dCtETr_!gs0I9jyx%i;bmQ82!I>#1+uBigaL-`S4!vrKEKyMs`UG(nu+HaNv> zLwegf!e9v8HF3E1?R{`ah=8cZiwK0%yj<9fIdLtim`aQ+4%pd3CxhYlyADW6dn=QCTz7d0!A zT@|`7>=wobnd}(!%XdGq=?H38$@}PSh_`Cw0y~?>?Q_{7@s~=3Qq>?Nn$vF*)Zhrr z8+Tc35WI`0gY24=oUIPiN>%q*Z9dPeRM-c!K&M(!>{C}`e_K4OlhY2wX)10Ls~nAN zhU!w`fpjyEcJFM#zMfzC$v+*7Xk~Tle>8mD^xa$4s-J@ud}n3jMt3^YZ(-w>r5e0< zeg-KoB)CK@Sos|7bOVY>Ym<`PN_zdIU2p_4Vve%t0zS=ngVMA`3u~l16d7m^)Ga82ezCE-#|_Yj}+Y(upDyK z?-I9CIwaje48>3`as-LFuXM7_ao@BfU3K2*rX*yx9U8>meU=6YB32x?>K_5J1$R4) zH&%7Ak~r)@v~0{wN%VSUMG>IC>U*-pPZ*f?+A2d-xIR@at+9YIyQVgq5L+2c zozXY<`J8?A`5L?*L?U0!hMQ>m@^v9CHkRYcbzw!CE`QF~1Tk$OB-G;=6u6SeF53nc zZ_t}B*#et4s_o-lamLs1m~f6hizuy2LwU9rF^8h6PxyZ}sni5IBD6tk&k^nw#TUFt zzFw!-X?|P`6!2GT^#;lE=nz#usgI5=;RW4_o;TGR;?@+RSM|1bWW77ln94O2$CO5z z=*+4Qa+28h$lSy>8fmI7=D?4&^%qeKXp*DPQ_Op3p_}nTrR@oa=HwjA;h-Y^kPNMf zrHj(skIFO(osn|g1G-yP$)ml?ut1i@e5#+Hnpv}m2FtKu&d55^9T)W6N6}laH>cL_ zfiF6LNz^$$8ak)V5BUuD)GJ#J3g7ww*&Sqg;I_KU5{ba?2aPqAU0TvhK z#{PmE9cJ<(YSXB%G`;VQ_)i+Accbw^>`*`xRb1<+V8bP|(2NY!LLx*_qMznkO+3gx;91|%1O{hS&vWZn)Rzj_gwuQs!)Ktq zs*+KxPU}->R-|K_nT@Jt-{1cp{y9+r#RmoQNc;U?;qi?BG`57O{FS+a_5lT>z2S%q z(JqY~3?`f}rWmP1m;aRmJaBBWF1sc2(zcOj@^JY@-+Kfg$vW=_N?e(uCnBS2Lu7H9 z@do97W^pz8eSEw?8emisu^M)Lc5%lTfFNH+T4gLgvN)74Q&k#_0v-!>K@$aQ!ZWaS z8I|R-$coIckQ$wjz6Xy+ZcUB471^ZyQXzM0vt9qyW}9`dlr{OH<}o}GVPku*Wa%7B zlqU;30%6TFZBHfFI^WAAb5>krj)(bdaVC0Pp-lSi0mN8~Xiw7dJTZigjap&(Ln;yZ zSHYON+@N)krm6yzo@0DDIK-j!IbEtJc+IsQekQz5AETgiNR%#;cC_-rh^E$4(gUr4Zm_?K9#UK%ndzrJ;+qr29aXP3-6E2aQVspn(%$g-ayinZ){ zzBSpX4k+6TpXRc=|LU;M$MKaIv}$mpU0oTj$Y&q){Y;LIs4KRy6qzE@+Dc^7Kfw&a z5kj&`D)7lws=Dx=s_!57X}He;RzA13qFKaV=T?H!-D$I{yeZ%aId7MaP>bUF97Y2i^FkN2W|)xBlN$|T_uv^TeasHzKFo)_RC($ZJCiTw@-coA z(`1&%%NMoaS}ji~k8dUxKB>@Geq#4W-+~de22s_+-g^{;n8FNMk-8IZ0odd2p@f@a z9GioLJbjFtqtI{u5N{{rnfauFq;;mNwyONodCj#e?Z7$n?v9Mn$!PlcLN2CkzB( zupTdahLr{6eC-?BY<)41^}Ydrr`+EL?_Yss8PD+1^{RC`8l44Ead-#B@u98`>Y_T~ zo&i@ZN$^#GGH4S}kYr^p%vACw!(n|B-|6#341#X2x?Qu@7aH9FXA({&u#lGX%5Hc2 zvH>5D6Gf8_JkEvli8<3hv}r3o94L(1J+%WsibBcw&NYOAoXa-5gT9oF?HC`4&Vbpe z$;{>s!6OY%rsl{LL0?J{E^~AyrY*T^5ZgBz!1?{G8Pbon85e>#@<|%bjDSx$ z(E@h!k$ssbiAPCdct`hbdFyxc`fihq!-*c(lym4Ar@l|x4Qa>Uhg2LrIiHkU_NaL3 zA`Dp0JZ?vN%Aah>8?%*-=^{sX`Wof-GZTyWtt&h7)DTSE6mvdEv8W*E>|RICvD}p} zEU+;c*-J~0+b?4(l1kxPr0%Uu8Rg`kSKk;rnvfB1Flv7Fd&J2CKpoaQ8`B~3>TdEq zrj6%3uc6pRLtk6un|`>+tn-B06;&oqcqV(d;)v!vfr&wc4QasMh*dSIrw$vdv%?M8 zN6T^+JV)y+fg!7f&)a8K!Re?_=SPigOSnHD)6GkAk2+);r}y(uVmegCbzu(W)2#9> zZf$=jxXNsECKfw<71dX5EvkV;2VU7=okkNbAV4vRM!!nu6*W~&%=J^R;G)&f+)njF ztpLOsXh(#;o+IQKFA7e=acG099o))Sxy7gjYN!^lY{PMADfBQ@4(KCPI_OgW0PFCj zw_1q&136*)C7luaEM3sleCb`*!~hE~OZwIjFqE;D;n@nHZ_Q=qj%0@d(qIy>4>mi| z51Z_b%>|x3C2iXw*qONoj<~JWS*qRXO(wsbNCM$bj{uYmV?L^jq;Rc@%5x*@f_jeO zrj>NdVQkqX*%~b>8Eu3Yl25{mCYk3u1s>$i!5@5N`-cr#Su8`F7R%o~Y5 zBnfF@issVF+|iS^Gv5|MoYx7S&LNzJX&cNj%BVJop)<^(Tcpz&bW)R@pj1$_Aoh+P z(@rZ@XA3%SBB?Mdw%LQS%~-H-gKbUhnoJr_@=9&mDcpxGZsmOCQo_~rwBjUxWt0++ z#4R=NPIbPO*!t#Na^Eh>B6w9|=KMphra0iwr~f?jdA$!B1VLy1>wiD<|0Asq^m(>m zPEbSA1t!U$H=~)ftEfZ-7YSEUbtOFnv>IYg%8 zay`FhOnbPFr{3QmZ&Ce6HNNE&Ail8G*@w-Lj3kJaqJ# z_q=N1OI4En0IDwb?6XTJOPku2&>JLmgOf2TxPYD)EzillG|$=xSQVZv*2~4$bv{R| z&Wm3mrXtR%d$$sV^2V~tJbecnvQgJtidtHPS65<=8}1$)>q(J#5~|}`A|nLP6kZ#2 zpM=#D<7%BxI)#4I_E5y}KY;1lQol_f)i;R9T`j*Ke&J2kN}&g~6#poax74y!t#AlB zs;sdNsHZ6^oAc-RNOTcO1|Ckf{*Y7JWwW%tUsDUcfJMq|Wscyl=-!gFf;N5{5 zx9)JAggs$zNw)R;0yd6^euBBw`xSK+g)+lE7(G$%Q$H%O6s{7E49EsF(?A~6bAfz- z_`tzP>q6qm=7yZz{N?5L%nIRoK>YY8VeErgTV@Jyz{~=rX`;5J@u}DN!}FvRfyh3) zN*A=nIyGSJ%4=+5L_oMLrU@SGFcJ6?#d-?{H#10RE6Po7OB}Qv29L@Uy*&VJG$i4W z$@&tEI7!${m?Ed72$P2#2} zj$l0Im_9VZBm^@>&LlQ^6o!M=DdNtdlsnQrHd@$5;PJ{NY8BC-MB5tw@i#yE=34B> z0;v8TL7O!HchAA`?`3KbVPa7b@FCnvH)Nj_G$w`I93ov_fF6-0U3u9A$70&{)Ap+s zjzyw;gOEF7ZP(-1E}>Caf<7}x+&^+y)?!lRnRb7!1^#pj`1-sd5JsQs`bo)Bqx3>Z~&2({%5b@@gKtvT4TGn5wgc2=6lb<^`B$ z8|UZunyeeG+Tnh6%OzNrVpegK)f4zxQ~Z=FOm3A@+E2OGV&CX;^S)Y8|?o3@sW!}S8L(oUi`iN-N!3F z$vtFa8mGFTsQ{?A!1isT%VV9B&+U6e3G+>XyU<(2erL&;b_A}qTT7X^7?Wd*rW-G| zs^(aRQ&ql~y89hG+h`vhK(PqEYbYW>wMsOesScH~L&t)+2D- zG-Vp?jDsNtJsKfqib|AO&BrY|SnqxuEGZp?^`b4lZ+CZJZNT_8!>)=-v(2dGq6gIt zq7!N;Dt3#ack%w9t|gC2POF(aLh(E?swj=r!-khkbbSk(g9Y4!!rMu#N#7wijs+`Y zSD-BdZ1Qq%c+(aJKLNo6plpo+`JbYZEb9vaB55~xQHhA?yQr6{!76)Tx(^{efyGz> zH)UERU&#ne_rMfGJo&vv-2!#u5bS@r|qeHPr^dXpF-zp~~r$p<(Qk zwIXQARnCGz=#BL_$@`9Q_}V_lCb9?|C|Oh##NqJQsrg33(UjyW`M&vR2}a0MIEnr(flOyg65= z>%VWUu6g`7HRj?L=7IILN{ieDX-oEW5gT@DgJ>de3tNe$!#W>|;Q@5aSz{glBD|x+ z)mu!~AHWT&G~8re-!x5UPdh8VrZlFHAFsmJ^ro{{>;8qR*1K)E0%{!og{roKLoQ}^ z#-Np;Ri`oCO?6)1Gr%)tydpN%b* zLQ#+-O!mh~j+4nbJawQ-`gScryYey2YP=9xW^0RhKy18ttcrlztN2|fjk$^zyG&Qu z3Pl>-!7uTXCpM|!9Hw5zKq3a4&Y%mhk-avF!+j4hI7GTjhKZ@{%pbL=T;fCw?c^2i z^|*a1)_Rt?7hjwdqcyFLPU+&;&t$`q2d<6cIQhfCTpJ@`sy)Eu!`M0g(t5#9mZM!L{4CGr=oqN98!OQ81o5=3~{ z1;(44nYYR?Y}w1I^ZrG6Us~Gse8zVixB4zUQnzst_9vlf7s9;F&H%~@3dl5IzuPzL z=(=SSPahmA#rF@$dzhcZq2OX%aXMYwmFu$ci^q%OGV0Ps_C9E=5-2*&;`EO2K~>ju z9{y*uN{BsH4sU1Lag$za2?Tk^68}?<59~kkZA@eZvw3JKI1g|=X>yHQ2?S`}O3P7d z$|sOX_?}Id#75^5oWy#IOs3A&CVm(nfIs0oB!p7Mbj^k2+|ZR3VwRq@@Mnob)H*=@ zMk{ZiPpo;0s=wtUll2xmAgq~dEOO{On3}x_yZ`*W!nU-zO+gn9CFsKW z-(nNl{;3i9C|&=B_uz|lIWI#82xR;J6#emuN){;*OPCNzdL-5aiXRDer$a)$p;_il z{AVW9hX@D{BLEP1lfb&Bft6&oNbLM^e+Aldx_^C}jm-t|j8+7(XPu`2C;j(&U41g( z9%w3Q%S@%lIYf^$AiBqh!c^+)3s);Pi0%>b7u{q1Ns8+)x(B_-;@$$cxeXMX!4DCn?nH<|?Blls~}qsj|8$l|RBnga(E*jsG;Lwu?# z36OByHDAL2J#i)lTl#SSS<+}M40gAvnDfcxcim!rQtxK{Xg2q0wIsdR=|OHaVujg2 z{H@WrWcEUnmzD()eg~^T8SK}f8LMUmQRf*~tf*?(e}O&JlhgxUPr_`zVcq}q42K*R zwzd5wPZD+l_7q(8g$?-76LuhvB!$||Xg;~=(83TxBa8}L=S-WSA*?uRg*vIhbPH7p zN22?iY+ctLSk7~zW=&Dv6aUSUS07(@N0wMWuXFrkl?2#>o$9@>6cPP&S`IBW-K^e& zsnN=~&b9d`64e^%}KW$ftkhp>9Xqu9yAX=Uk`++1b1fG|xs6UMcEAR`>SK4YNbnMze|8HZSJ`!CU3 zOwXD)jm$SFn>(0sRk{z33aodS^8HPVprOy+q&?KPT<#HCydGRDsh#Org8=;D3CFQL zCKsHf*ilPhId#xiql_Y_=!zVLtOzq%)WU%Uhf?8%li0_^kO+65pMMLFK5ul3H3U_r z8fdBbe+&6w{dak)X@A2}NBWrYoX&`D|AGxeN*}T`;{P>-R1gdeYa=Q`Yv@6`pksO9UGuWNGJdFl9<3J8YC|oZR0ie2Ik~(NTgsHT*j%z>YfX38 z3*QI+8S5d$N91BWEf6Yjj(9?WAkEhGci2v|y~_%^t%;PL^if1POW6zT!W&9nMKv7u zw_;qyJeD;_$W)%KLo>&C`Hc#S%L3ytG3Aw8Va+?kuOUfVbx~QmdA^#+wkz^X5Ncxm zx(%?lNVTA)PZd_R71y!%PuGz3;h}Kx70UbVSz|Xk#c`t%OmI7`u5@qp=$-+*CJKV) zCcSWa3W;!n!|{MKEX50TG_zylO}_|f*OvU7-!i#tf%{blraQ^h`t7shn)&g1 zaWHmC$0L|A3X7K8Xw}77^r8FlPTEgKoLEv>6&UHNk;fqq;(ET~D&;yY^K~UQ6tlX( zaO`i%T6{V*c3#oldX1s`h=*(jTIiztrOB1nzQ7SQRkN#bnxlx*CVNURu6EOndQ_I| z4Yfg8?C-&ub!2SGtVO35ya8%0ts$v2nv#VSCl;Asba^tofa5n@PHr=-6eKu}Bo7zY z{_ih0;kayZP)%9|909pCuYygTrOJGtyrgL$YZvVBh6R5i=sFV=_@IT@>O3`^v^iRP z!FMa%Qo)N8TqbD>1c_r95TD`vhLZl;v-u-bK|M65@EPkpv)bi@A>Zb83?OM+0Zz6= zftXM5b6{1ZqBi)aGGZCTeB`Hq7yOPp@Qfsa`I7hGxI2~tXFOHkez`++#Git{`i%Wi zPexUV^GJXkNaO}`XsG$Mh>CZsLBAsvOXgv1!&0b25Kuz_;xU}O6xw{cW>EU}Y4|Fq zHTl6K=az``=?@YD63U%g!uQb~X6EY`Wjvyl*Pm<>1LXb82se}mhEcf~5-AmYNyvK( zb$@~DPUN;#yoWd(+R4)Vai_hKZb{?%&J<(?NH)#m8J8g9-^V8)KzIIRr-ZDsv3|^S zuPjP|{yj>X<4R1g)SKc#R)^;-ac>KuUj^Kd`0_! zJ_B4izygzwgr4>F^)*M{XAo0_s$mYiMb5wu3a47 zOM5pq-K3t&n(wlUI#-$|a}9b@jI8L^>QfSQ3ePW_)^59);CW z;uuU$I{Wtm{L+c)uOOMVF;+IWO6r8Ajz-H98PfI=%qnynx2ToL{_0iHR0BpP;?03A zEP0gkf_U!?6)?#ehxcw0kwrt1aPVXUj6V|Aiin*uQ#8~+1ivsLF?iFZzqz^{zm(ttx~jz#F{MT*Vb(+wOc+?vLuJ}fb^+$Ys!QTpI2P!3JlqNr zo)jI>B0p|2pU&*|9-f{r3%(;Zzv4H6KN1^l>Ps|#=q^z-<3*F5O%{zl+uNR|O>_3U zen4m!HsGnvea$}b${PJLaYwT7A`1Q+OXel8buFA4r~Kf#mif!7JzvFEKl~OcX|c;b zhUzBJV=JbjTLhF6qD1Od@a8#>xvrA?Mj`{2Y7wohk_d+X?frjbJBx#|osUOrVnRS) zP2_*oH`xAttt;y)FDfAMr8dD2L5C=z{kj8ZgDiRppcI3kfir~RRtA32%aUnpif2D1 zYJK`zK)?^QPS_A2ABEEv=>>H)$Q(Bq z93Um@virHj<-lc%u?RzFHaQY5NbQtQ;NTbbGxe_qIGtLhIvX#F*6BFPSFgbQ(gRN; zmWt~LgUIWZu|@SeJiX1MfXW$)nc%7pq96;e9Owu(P>xefA(=qg0p2r7?Q#25t#}TJ z?vC=*eq$pf@PMdRENR9Xs<0nikKr|*BAgP58K%qr4PwVolH*+gqjG>tg9}BTbezxb zcW<8+y+=OY#!vZVZYmHKBEJfo^;x?B3rL?|5DBEd%`IbBL!tRepp{t^nOa_-Od`RW z#IST5H)ARQ!e^dQ?|N$QmoBr0eIItR^p#v(Luxp?W^%TTP^eM-L*8L<;%>6Xlyj2C zc8li3os;e!3hSP4JU;|7xxwj|2u%MGPBW~6x006sX~A&Zs9l_gUJ2d)o>H=5 zcep`D!a$YGe{DRbB+Uc^wmYuQw!@`?YFjK_nWxDm{AB~BWsCdkz&U&c>F&!G$eAPx^!1D=l6NbkL0+QPB=7|UM~HANFvW)JJqSfs!)eMj5onT*p-pCCO#bACS~eZ)2p~e2TVWIr z^G>>eFf@MX!D87SxLhm6?m_k9o<~(DibwyU^!m51)K@|rfeKW_|2=?=`h`9MN7HlRx?Ki*Au1@x<`RfJ?BWP6sl;;=sq8T*t*j zy;1A2zFY2O&+ct%cC4lfKU%;M?I>!fVl7=D$+oLhqroqBw@hC9BwiKEhVx)J@gMi1 z3N9W17?k@I0%zIHUqfExsYkX4>kGGh!(A=9hLSms9K?2NW@8T?eO{^pccixv!r}p# z?4jMq@spb&2?Zlny&NyicW@2 zJ?d9AqEW8?`46WS3E6hufTdZ>35{-9p{(OzESUBMcZJ=3;S-J}KvenOdKJ+E@I~15 zw=wWk1bKq5V#hzm0`D9O-%upf!T>ea#Tv^!urz6#?6Sn-n<$prXq`~#*Z0g zER;*S3YT2NC+}?9opZ*M+hF}%Mp}%g$Q4eV^rS@tQkEbxz?I-r!Tk(*Les&S%v}D6 zKat!5vPJSXWwQDm7hUQ*)Du_B#9THpO@n19l#)Mv)|T z4Y33+^#D#oc>UcgCTWlH8BNnYG}bG)9o0bRSH+rL&#aID9F2%-_^l!4`kFay654Oi z)oBjst;X3EC-PNNyVAHJD@ya1CVwABplygD{FSK*^Is23_J2hx`6yX|p6CEygmX1Y zbSPtL=04anddXYcET$o*JxQU&_~DyN1q5vutJEjwz1h2<)R(`Q+RQzr+9o0rnN8Oo zInTV;kE_>zwHCw1;8cHUHdau!`!i@)#I=Qag=2A^yPh@P<8^7RJNNjl8(UUdE?8}P zjXIUf2(a)_)q4c6K+Vm^A>Sn~l>W~GmZ;2jZwPiA?msK;KP{O|!edQk9EayE92ruc zv>Ck#Ejn&}>oRt*xD@@z0XbpyEhiZ(fp0tJgu9Xnpo@PaKOD;|B^(f^_%kH>v)@?U z)oXrL6DaD=lR8Th<(>D>pHG`QcXyP{quCg$N~t;#j=JdPIdC2q6 zr7BR=9en~yt+L^=vV=1_#u?>&`_(_9?v~ry4gbKi!6%e^%5NfCV0#I-yc+|@eUK+s zReHbTC33IGZ4CkuCWpwc&fN|6i13*X=+8K&y=bTFxCb7!We;|?W_R%Evr`JF4mJfJ zbOd1Z9X2RJ`$T){CK;Vtj9RYQupZn`?BoH=zkUSrNvDpQuIDZ|`!PUS#q;}lSfNU3 zc|Xd|$#jDaqGcmMtF~#<9;P&EvHyan*Vd+tdgR^*i5`v6?O_+!0G%1;a#lxZsZb96Lpl zO`=77i`hrrXjoMzmPvI!8X&c+gd4)HI&M?^_p&SHu+HrSm7NEuvnBky@8N$$`jh^v z=#o`zlu;BxU_(M!N$I2%6N~b%1xB@&Y2_xva1ks?%8RAvjsY$yy5HM5Uu!K(q)N+6 zW`CEJIAMwiW?8x&^<+6+InDUwG``)hre1%-s7DqSK>3{}#0(+u9XxH3v6iUV6&Cvn z6!DG7%DZ$E*57NS0n}N(Nh6A@uVfoULlEJ#(i|91>(aw?0Uo;B<`Uc_jaNHX5aJC| zmo{>3Qk6P`{2t0WC3nqHx@bIGyUXJOoTo$dLEqM~pqzkYEjY`3yIn_KjoZxi zC9E#WoWJ~ynGl=^->i~Byv#N}V1su_Ob)w6b+t~?EzUJj3V8bROJ#k?W8xJ5%a(Q9 z)??YF=XA}ax14I*{+ek4p)m(-c|s#CxUAVzR7UdLaN?3}hQVmnUX`EH$ zIr>>-;DM64JH`n13YHOOW734+_$_L;=5gCHRCi1N~HaXjYpyt?d z(TQjMNfX(h1Y+Z+E|c z)tt@}8S721b5Jokh5oeQl-Pqd}t&*Xl6(ILa6x#l1V2E` zu6?0&GdO1S;!2fTz0oY=75UOA%Ze@V0{*Tz;?ya|?)*UB`kZx~b=>vw`g@{87LThYzd#IvLYz zZbdjR5gx{+BXRs7OxgC7(HOk@*xMuF6WMLB@JK#^i}(wXh%b_8!#3(n=t#P~k<=Mx z@t^zNql8uRe7>uPvbdH5rKODu{(Y0F48unhYH3!bm_HhFbxO6u9}ZpO(p_>PY%{n_ z3LTOX$p@}Tp)uwi6?T#PPuieseenj5ARjM1$TdZZt}%9Rp=Y}`%|!C z`x5nLqP>ll{9ybPe5yec_^-&B&P~b1gh~dnP;77@t<;wyRnI~tT^fug;a5wUYDxwGl1-8Jl{l~bLOxs2Xhx-RzKAk&E6IpiM-!P} zq`|S>%InmJ#Z+OCibec6i+^A?FI7Zyyo%2B$TJoaOUXzhJpNYup$7F#t`Nvy00@@v z6dO7LkLvk|iIk$#d2hENhVwbmF(GL9FyRJ+qC&_NLo=;?_KtZEyZt# z(+?+~{)yi3c3mGE1qFv3C1QcSM;E!PU3W z+KCO9>i-ZASogq9e<53V7X*AnnR@rrct`&w9<-{b2ifXGP`lY!%ur_(f}H0DS{eHey98_D zKIV7~68O3CA%NhnYqDFqhZcOa5{-6pW88*?H1Z?I;DnkhXD!UKlXz{flitHqx@Y=_ z4L;|Y_nGWO1u8Qj1=Jg#+%cj#<5K++BD3Skb*Gy(^(DKrK7GkC!p}^nV=O{w$5G^N z1Uzsfx>uh_t+7Ck1%(FLD&x&Z< zLHXjAb8B`H-vOW)XnO35A-@7G4JlA>Y;BuBuU9gX5^Jrf-lH&R%$@q1vTit{F!djD}GVdaz@lUSrY+Z~uC{EZgP)E1Y zgMk678YX}aX}^p=#f*dIuq$y{eT{nb-#KK2Xw;A$EfPyMtd6w)As)1!7@w3TO$*|3 z`=a$Y_`DgYawbz>3J*+CwUHT5md6u!sg)`%F#OUB`EH`6W*ePx24 zPoZfZ2$?i7uNrP1AIn?mqU}W>R2L)ZEg%6kYashIW-A<*;vk9#9VcRRM)KbajO0(;$gqX#( zv+k99y_uT+{&;QY_o*$251j8M!FWXwwTGE8@D10A&b^PRHV+aU+1thdSqhwpmMYSY zQweILZmQlW53dqN$Evv0jtjqq&?XO7raEn?BS2z}@dogXlbtJvnRTZbZ!^~r5M81d zrW;QQO_3`3mUDSfk~j|9@S7x~eM?@Gf)=RP%5q|Hlyf&S<8JBCtE3b0ns7ak_Arz2 zJU6Nolr)=Y7Bv)cZ`mLS?8t4Ef!i(Gm$a)PlqQqfl!>ls<99X7AZnt4F?BSNBGyuA zP4Ky<$_kU7PrUb9pnh|tXQ;LT!p@tqO{lf%Nlw-#VCu!}xv+7TaOSwTE4b3`xMo*K zrjVblbC6kF*&?Z}o@*?$cDh6|B9lgXDgD~hn8qCHawUcjU+4ct` z;K(eu>tSreH%v8OJvavE8i-{+3bgMAPlin!%1$f0&XM2we4+Qbd`-fZe`M35naHDA z-n%C;0(T{5TK0RKrx4%eBH^auy946u`INa+v|YVV$D^pco&1Acy|d_D%At*6F(k<0 z!mA3>VE4)IRAPSe#2Y-Y$zQySp(PcD%SlC(a90Bbpq)s#WCW5_IuuEIz!wFJ~j1^1SlV zQIeEwC8JpdxZ!+oLyYS~?b5-3E744tH>(ZaX=@S#Xl+k<2m> zDyOJOn zet-wF^JI$j2H9Ccv=nhX_QPl`+8JK#PjzZ%=n1{J2&%-NNWk*B4$jdR=tkzroClOY z63d^Lt%QEPloqtF^V~)5ml}dDRFeR6fJ3f#-Pjpm9G08&pji_Dm}d3VwOUjrT3}iL zhL1zBKggpNGw@&Y48Kd3mbSpm#GLz(no&FZKgmsKj;c73qFKZ|tJ>2qyhCMQ&%659 zOwZiEo%mw<1;PIC^jEj950r_GUH(CcKldv2gU#6r{AAhINHEe+Bhpw0XP}BbDpZSD zp-rRMsfaO}$vaN~wlp7d8G>Ru@YHP_B3bqH`tuuN<4;`V z7^xVl632cjnKG%cnA1GGt35sca*T|xaroXCXt^Eo@#&W@e`|L+@V%9$L3j29NKE+O z{3ZW7>#~(WJ4lL19|+dv^p#1STMTN}pQ(OAHcN(uw29tF=nk*6u2XG!rtEQC6W!i4=4xt&(l*&kcPm?=3&-5l#c>}f zxpX)@%}M0VWJkRhFJ3#;g=7~w+iRyCC0D#9lW`b8RMbAS-}I3X;n;;;5^+z`_b^FEPyp>D%D>Q55A*-CzbB^aHLnD@?lm>&43b6O|=HO zzy=M2>nG6M=?N z!Bg1crr~u$|18KO4Aj4enSL!ppxB=ORK{%o~tdyu!lkeU? z7j@m%rCVGruA#lzK8R3*<^c~NG}6nKtzm-7M$x6ymCp>@&_MyW0)_7z*wj z?69Bfq$@XDeXw0i@r&_hVZDub+O{l7j?GTK{nPYr8{c(3TNdfnD@)l{tuj-5hX0xX zDEiIXgd1Po>T&HD%&CksDmUUeaGtP*JO^-e22ZW(M7rT@HLCJ~v8~tp07aJ_f?HP) zdl<@OL#3*cx066}Qx{L3xEfiZPqFk{>ecNKBL#g3Tio7IO7_0usM?k7=;u~$lQ#Nx zw8KTe5q`@YSQ)6qVee*X%jc#A(JiY)LFV9Kq2uZl3U4hA>eQ4InN1gOJjjaktG>n| zFYRJxHe3S{CWR#Q(X$TzVn0HYQ5z;%Tp0bU&K_9-;v8r?TIAbRn#J6rugV|IPceyC!)!5o z1t;}LT$zt@)qY=mfpZNaeZx9I1NyXbQ_=3_-wA!QFah%gj|ICV6K2!WF0o=a@QF(P zvwH>w{fdyUJQgksP3Hp>x+ry-_@rwDUWK1_VKacdcr9989Clj zQBgYr&aoOTxk#9ns*TdH|H=%o5zo9h1$oOC1*_lJ3>sDqe7V7rWqrjTaVmjtp>$4I zyx!{JYJEDtp4IBBIXbrp@a!M1srcJ*?^?rKTho>?#7RB&kECzBaKGuMGhupbBPF)b$DTr@7fml)4+_o>oIx-TGV3~eu7leCT+DmQ|IFn2I& zY`hScJTB|pC%E*^uu-kQuyF{FHEBSDR|*oO_0mSvOt>SBsYVT?lV4uGWTQ3El)FN_ zJZJz(yYJGMSMOqE?CfjHZRtnjq_Y&Y(*=pXB=6yKB2M6VpDlR87QxMx*OyKVoEe{x z_jCcva?J9INIP>P?l6imA`Gv>`0&30f)ZghNS5wj9MG8tMB2o0-+`+|^$Z-p@#BXo zImVDq3>bw7Ck(;_qlCVx{SJQlLTNXjEXPWyzKE>0ILo`oLq3dnvPY3JT$?iCXqJ4) z7%yTBN;qOND zy6Y9XJe%4>0+XWi=6eJ`LCZL;Bu?Olax3>8J8oB8J5L~gs{W@1O7l77)wsT@2or)M z&_Behgxyh3_(Kd{MkA#@bit=+uRelR#AMl#wtj>WgP~_%g4fikG=uuNdx>OQ96Lv9og^6o6d6Rtg1!BNc|AuIdX)kfQ&&|r76$nG({ZW%jf0Q0t< zu@%{UpQ|-iFrnS#mp@{`AnSo^5o4yIe5*6c&3;gh$=$%*P)-s`hsRJ~O@gS@ zKV&LH9Iuo1J?(0Y?OAwyC1f_oHW%}>&N3o;wdpn9y}+pkZCYn&OGlklWrldXKyE-R z6=KT1nPp(kZ1G%0`p2zkzymgeBs{BP3iKf@pGcBAHBX9&@cpE%RTJDPOMz7}bBN3k zk<@bBy(l2Fm$2JRkl1--RnD@Q0sRBId;jtmglkIjrMrDyO9^oBSBC`F|Mu=io}$ zZGRu`*y-4|ZQHhOCmpL}cWm3XJGR-eZ9DnRb@pDTYOnV_-*c)`sr)_X$o<^U7@u)n zRwwW)&##~ARM7IH{-|36t*R<<|rEue4Tc>W)E1m^#q^R8L_ z6(!@HB9&u1w3iK7TZ;s2C=AYq{--v;<1~wf8`sP{`D7D8e-Qsxv=@TE zCkCm4Tbi!c47|NUPBn{5^*-nKZ(k7im=r=Cno#$Eq;C~K*-X#)rgRTz{cBoxQ8{-M zdmijoCrXDKo;W!yk@eW5w=h?tI^v-pS-UDYH}OxWXSU$Wt*{BQX_0|~HN<1ao1>6v zmS|AZtDT8M_yN^&MMk9q9&}?_QPiX(fOE&_Vk49*&~2(>;n=uuXEOuthYxgVh6 z8x@F@S1J+jk6>?FLw)0slys2(L3>KhKE!vP87{Im%43?pa1pJP9+skO#lRg9L};T! zl3!yQSMhCn#2z(}39)1D+~{5Dky=?C#%5nqjbinsd&bgnjKP&4ag#c+x+f5S+XKwn zy_bRCQ?t-e#Lw^mZMC+8P*8mgqx>r?#!v_fw?fyGkP4M#tjb5v-v0Lxucz4IkL$%! z5MhB$ycN8SouR`yr^OGb$Bo0NSxcvFiFI@HfnWL&54Mo{Pv70zikTbQO&wq?_4D}~U*gSF za~v8i_vksmcE|g4_g^u9&Yrt}`|eEt<+}?A&ksie`0mJg0lvGk?am|f^-HIf<_xN9 zla5sjmgopt@Pfk}$Pmuqgk6GD+{o=%d^gF4?Pt2HX7_DNk5U$uVHnKHkVK-=f2B>L z@f&TI-v+$c)F0(YbPryXm}_iRG2_iUvE$gS8BAVzw!6~tB}IAs6%+~jmk{`kMjk&D z7P)f1jG)`~8qt2=5gj*>un`cF9Fw^J@0zv?*H~1BdYNx6_ z{LxDi;p}C#uWi(?TIlH%3G4R-J*eE-muv5(AMJSHvkOIQ5C84Ed;QyYw<pu79~4EkGlR|vQ84Xi9e zM|Hs|&Iviz69`(ORy2!==h{``yKNzfj8nw*K;-vNTyq3-?`w$Xap_$;jUazF;%axB9Y>V#-w0<@IFaX!NKL!mO3t&igf?R|CT1YR+FGt5Vi$ej z?%3XD5ovPGRS!mLuQx2V87-LfK4Jn0KtU-~PFv{C=h7W|e1TSL z!&yJzfBOa)M@cNvdL(N!Eh4N{{1`@I25sBb5M3rWV-iAJ#}S?nxjw*}>=WSTa_(fT z^%KRF23OKxs|`>MXy5<0?M}NA@nHGCY<1J8em*fNrj$p83~Jp37MM!VpF2pw1LBpY4IbJ2=v^ zY!h}>M17GlWwKO9N1t(661|%ZyQ5_>w4J`8+030#GR)j=ae2PKG6;BiWqpfoTB}hSP#q=s7=T_2{p{C$H$cEMyu$*L^Ghx z!@P&9JQEb{t;*P&^whQ>vdX6mf>(~syfBPKY3hA!m>@h3!F6R*tUHd&{eXBBbQ2yC zVgPffKzEreSK>f&!4mn=az&NQeFcXt1~K4_^cxF<@LjqtVwA;VKlJ>6&Y|o$H@PbT zZ&ZH&tLl#R-_h?ZRc(80Rlsh_(&OqBo1+fk<6$mB;g&orEJ8n5LOK%4;<7B8Pb}2& ztmmt1(Y$K!IAf^}azCKIhI!S2k^x2PyrYke6@ZLI2wMX&ow7?OW1)o9#+pn^V`ko( z$XucSdcFVg?Pw<2n15*ZSeP;5hT}Fh)|FYjnP7hDPqGR+`tlP}Gs2PQ^xAkN1xsKnC#1VHU8@JtIzlw&4%)55o zw9lxMEeCDhN@Px-;}`^Zb=K;#_CIhDmbtGZ2S9$dPucb-RYt%K!$(vVs17}Qce&MxJ1>Y;d= z;5V9DO-k0oXSQYo_!vf6by-N!Fnv8>=t4l`fn-TtNC(I(n|Df@x!y0ATovR{!J81j zng5Xvc^!kbfBjIc%QaFIRU4E=wO6cRn=hanrgj~l9ZV#I$aAY$PCz08%K#*ITrJzp zg4A0oRNgep`$QAGX2tQoO(@7R!jcM&POVK9wmE1+6g_lZSR`xqBSZHL)2q*l88PzRr1rMGaNiT1NK-k}9!PZ&zR zJ(>sNG8Ftf^|#E`)W5frZ>dkm@HG^Dg@s>mZ4cys)bk|~dMT~O<5yp#JQd)BOCzy!4{HfgL-DjYomRX0CQ|uyf!=hJCR^;Fib`$21o&y&8 z?g+z(>O(bE#xbqZnNM^z53ION2nK6E4ly)6GR~se_Y{fO~1>kPX=MMkaAw4 zBItLVK+23Q`~Hti6dr{N3<=;~Z~%BKA^K++@IN3(jQ<^q`U@q7!8>o)(O9i8Q!W8S zU)n_=t!QNQi?EC|8AVSDtADLF>f+S3WhG1WU6V`?3I82vNaSbSO@?Jbm6mHlTjtfo zOSS$_-%sFe#O4NxJvH!_y5vFJ^~r6#2r?Z`F5*vP>pYTq&2m^a-HTH)oZ zrS!=|#j<{bg+Sz9hGi!oM}(&sQTLG;ULiCs|Nbuc@47Dd{vqdWh=GguK8Q=ac%lK- z)KvuMtI==CPquUy8~+uHlJcl(*EN>Na-uIjsvM2Qy^ei46Fiqjf)0)n=0A-(7l&;V zL>Tpy=cvIp?+YjGjfwnnyN;69%V4?qQ6iq!NSczrqz0u$Kl}$)`ba`CH_r3r zBH_oqf7^=*rMc*FyS+A!NTF^mFQ|2Zi%hrO;YyZh5-<<68E>ox?53>Gq>ddIi_Nu` zcFiDAwPmgUBTpal0#rf`sIlh%$D1$9-%C_VTlRB*^>3;9J_tl*ea!qr$&q zG)S?LgKp}N+Uh#MP zf?R*o#yoCK2f%!o063?y_Pl5pTy~drBME+IA9X&XR`X`~7sMLRb+>X{%=T{OmdXz` z1TNJzpJ@nHx{3=!Vh#i^p8Y`2w((rb6vOI+S0gu;>7T^poUA*?O98lI3 zH)8PqTOUL=9ze~l4_Xp0T`ix&mJwh6Dvi|?aV{-J@;$+kgXO%D`$J5r*INIZz3dOu zSM{GA*BnSUl9c@JpJo@XR|fHHvG{=$nwloli@T zXBO3p*6#R${xf&?xYnSB3MeoMKr8Wo9KrlIQ$_bL8X~W-+lnhCun~2x1nL6XjF6(D z;J$ETd;kK9kqk?}D@EX6JVe%4E0!CG=ffHX##oDdzBlW4up#a>aFVji{_`&L!%eTL zE@u9pKVQHNkiOeEZ$|`IuxZ8q%0o3~8;WaSFwFlHWp$YBd!VrUQ_EQPk%4BX**ZDH zMXOt8hT(M5hxc=|!Sph@9(yds&XOwUr`Gkm(o$TF2kE%#qoGSLbp-3>-I$75LhlTn zu()8mQIpyV+9z?hF4?|G?+EBSQPho2PxR~Y?XIB2gU2QW zu9TX-g5rrTQ%VKg5=dYb^#GYQ$f1~t^*Bz!`ee*i%A`A^V0CQXdUSox{PMyha5ReW zuAN11@%0qXhN|j^ayl_xednxt^qE|>pw)+nL@!lPt5FygoUG$~Gn~4y4ynW-{9%^M z0mi@%G_$?bejec~e$=5lKWJqXf7Rf8%2#4qd7z^^H>JMYE55kvYtED)I;slg}okyQVBaE`j89 z&#@?lH=wL-L)HMif<&eFhcwHkGoLUs(|A1*YLVrw$h~e|d6SrcJ=gC{OD!8$Sn}ht z(;6`Qdv}4Fpia`%8Tmy#JkGT(YaBlx=xOKqRVnix4!O82A(0#*;F5kCHqV*0o>vEO z@XcMCS4t)G4^iK!&uMYuHhhZ}aTBuz3k~+DxM_yhC&TOjQu4_{%(rXA@Iu$N%~kPp zT*3=|Lw?Q`SHEYsUu2N%7pv7l{CvPu2SIae9{Jy|{2B|?D0gFE&N8oz7xdo*K1F`PbfUVZySjtAa=P-5L z%g1utlQp}VB^x&RNL=u$%Pg3XgXD{d0tW}OcVyqgSktBh&Gi$9Ew$Hy($Q{QhE!R= zQtQT!Ac4e&g@7y;{agyd&^D15utIqfh@akq>tWS*RZx<3^4}p(#34vur#v7zHu5vQhSi_Oc zM)dZ`uXaqfBkw)M_#a&7gXb$~C-#EP zk#|yCBTa>rl_Co3+`uXZgr7oK`xIcWh~;!bU5Nj@|KSWaMZ}0KWbq$#$T9iV2yxFR zVg~#c&LWytsY9q%ypVE`d--r;xmR`ooNh-OExI`RJQa@5=5zkgDIXE$QKs+$) zj72cKH{Kf*fyKIH#~b;@uzMmG}#5`lH*CI{fsk&!K7 z#A^x3?i3Y%@63W9c5TeEG@Byu4GV9{x$aGU-iCNq9O{Ays6sc3XMJmb)H|Ap$=T`# zaSxirky#%#!gRyDWL!6=NYh|cL>u9`@s{UDO{HelA2U9=FWPd^;<|sgYnrvdscr;| z(sC#Sy_KC-f4j*o=mS|fQ8sDEY^~o)&Kv&JoSEUJN2M(8s75+3^7F7my|Cn zUc;S?Wp{>)etvf+YpRScc`RHbCZ(lqDsR@veK0E2GWZ0h(qh>i`dvPxaZ)Sv;;BMH zyGdmDfUFM3iI%28`Wtm1ufmmwueEIU6?zNMD)ni>7jee+l6ndt_8;{@J<*-BH$>7z zwnoFtHR?6gF#jVUn(IF6IPdbsPFu^8%*|^km0j~`{#5GZuM&DZ_=AlNvl#==u7+6n zi-@1n)AC2G=Pf)%c3zFD9xdIUkgQMW8_u$Jk<>h@u$I~py8dl09WlP0I)xq-G9>Ht z%K3BE2?E*XUz)qyP$17P{J_p)BRJUD*|U z{eBNle|&Rw7USodPjkiD`d~h7>j)DBY(+4ePxBs~b5477c)YIt1tEcAjiO*O6p4k~ zb;YnZ5R7H-*BHPRbRwJ}q#udrKm%1r*{8)EXOa?Jhco_WKTa`T$^dG27II0$Fo1L* z{|qnR)HW-#h$GFOzM@ptm~O-He0x|t#GtVRSG9U!W1*I1-zbShn?YsI-dYrSmp60N z=$s))i5X&cu(saJ%B4q>p~hTv>eZRBD_8!9RYjZ~Zs+Cpy^1FAXJ|{uJts9|P)pk& zw?~6D$NtD%X0_>`U&0?${WHy$+%L(+d^9|B3)RBAGQ0cVq}O*=_R68vwG$;LccPQ4 z!a)NwJOTNVNh6G>F@pqcX}3ou)X8QKWo;)oNV6Vh)7T{^&;jm)M;zMaja4N05MGE7 zyW&rz`OqioP4x^seap*ABY)0tPT) z+YB~11<@Z`hh#}DQmk6V$&?&1=qT&`9k6+CMP9*UO0PpvQE$*zrPQrasF-kIS35pn z-)qYa;Lf1=5cY+=3%8AX9V?l2KJ1fBQaRL+?DZsOjUB73hGKdKzKJ zKxgpnm`QxDC6g>U+2!7m(}3tAcA#ycxxs5@)zdKTxY1=0Yftl?)Aq`t*NJ z_u_S^(Idgdq88d;Bi$riLvcrK2b_ubQVh);IqeO2tVX8#R8G>*g!RI#VYo z&qKsAbz5v9KQn!%@*i^h@gs+UWfR24l^EXw?fZa!nF!G#e>ziix5WII&-0T>$0JQZz$>cY86tSXDOPCDNazz9bk93C_gy2_S48g> zO!S1DMw-8X?&Gw$B^m*oF-F}X90{Wrh4vVgst2^IW>X_QWz0CtQ)B|p30%}~-_ZWqE)i6a{8z*)NzK{;TOF0p zS~|I?THkSh)pmoUQ8tOANC!kEhKazppaA zrrOk}zm6*O$@6`j=|s`z&y-k%YUF3JkAKlzn%$70J&_Pzn%>Ew3GT?MP|HN ze=^2e5>J~}Ax3NMzH(CV@__}X9>i{+se3K5!Aq5C?AlKyo!lw*p~0mFK%!I@<#Netz@d+twXVyY-_e+1$FEh z2tn1u2jaG|L)tvo*`Fr1_)gRl%&BW7z6z|{-C#TO<6?Y6@HJ}SV9;9Q5Y=YuVcq$1 z9fQE_qu?{FBm{KM=S+KMT{>=twHY%|Hm0)X0-41=1#Mgm)qX0+;N@Sc{MEo2Hsceb znY*H_TZ5LCIc#GG9`jc2eYAh7kY_BmLL^VaVglt>mT1~1bkUB#;o^swt9<8(zOZn% zE}^N?=W#PX(tm`WDOf|z@=eJJ0dU6HfRqmw#}*AyyyEjBXKOjL8|OM5qhNCp#= zY!cW2<;|ZR04r@iljI4mnw14wHpfnuS-XI&QQirl7rYWR@8}!vfKeqprNEU_36+gJ zH(5Vmq-qHy6k=QxXcGNJ$GD+6@3@#m%zfP{c*NFwNgK^!tYx|x!OXzf2dRH|ZXMIr z81!uis5;p&isZm?oVjAs%T#`NJ%>pSTQkv_G*Sy4b!WupiKb$*pAejb@bZLL>Ppp9 z>g3G9xFOo%=aHndV0r6lxQ?-ctx-8$WY$I_-tr_TDC!Cx*Q|cBghg3_( z;?18>bm^A>`v{bLpNo*gH@e}7{5~%gz?VNSegc)nCx*X#f_z^nm$Tm_U-!%sS)$<` z!dEeWbZqqtJaC$>cnWXF+c&V}QADC)n9RtGaj`;!~e1ahs0K{rjS)LlG}yD{F_N3hVr zjyQUo=CK|>SDQ#r|>Ky-2_K}N4# zaCrxj8bIdxZTK-a%C4{FwjiN^)3Yq9H57i9r_$JBiWvGq%wiKiN2fsJx)m?pkGNY9 zhN1eKvBgzNFemzVt|x{^9;B0XMy=Q>)Z=Hc(vIa)@~~G51^h@6UO_jBg%o1W$=D8+ z5&hzUBM?G>5aihf zk%w+~h*fdE5Z^sB0!@Cz#s{*VdbNwXLs5iJ3HthjA4jrC$p>CyvYe)@Dka%+#;MBP z=ceh4)+CpC%37#=^pfM&)BdJzMap+qsGfV`W8Om!+zd0)Ou;x?R=DT z&FlO(+?k*F9mt_l9 zRn$wLs1|TLatf+K$!jzVSjgv>yQIjI?2I3@_v{TmxF2TuW2b)tU+F(%-`bxH(&0B9 z3`QrTb00VDe~|>n}L}E)ss>D7)sIsK;?CfWQjA@La5jy;B+}xS*Fy#{SEb z3&({btQ@4te!kS4>!?lt1gHoi2@iT-6C3ml?Q}kbvdXr|kycjSlK9IlyW^yCK)0x4 z1mGWr_KuDcCuVV7`}5CHuL(G^2rr<`@qgff{~bV70`Q6iPTi& z-?ELFqX~I1MX+FCBVnasF##0paahfeB*5x?CA0{;&GJ$PF!IT%mj_Klv~x^7>&4oo zVx3YM4$V0X2I-Xo6s)?f`bEfO`|Xp-2H~{DVnbmAy-&u4bV(kzD}jNUj`|C!IJiI2 zkK)r8am{hQq{+&Km5Z6C#*4N}o}6iPFrwi(D9esg$N}xSl*xGX_Vgg{BETx{BmEu( zaCkEMv68SR{uucfG;7=P{1^Xr(?b0p?A186K&~>&GS#{vxb&;`dN4Bk*nA<7&1ZSF z7%I5&6#NcAZq|B1<7GgJ_7RM6f3(^o&%Vqy1A=`n>-R*9Ad~5`Q>UMuvG_@d8wEpc z1@K46E4$&Y8Y~#{bgkJO41s&^b-ZC#-biilHMBLbGP^DiufKIaa072$WSR1HdDd?? zH+kXQn(TtlEu=WkX0t~P*K1Jb0{1G4bJ<5Vc@U>!=W5O z9QhX{aY3pqBX^D4h#Yr7o)w0?q>WLeZ4!Q5$xOf4m3%yVdqFsa@WOc5)Z5glXg159 zr~W}5(YKHnr}ek_I9BUvy}wEns4L)gVa9q?ok{orx=7^eCWdHXn(35d`*o}r3LJ{d z?_ewbdnAvRI0I4l06q!5X*k+5s_`ldCh;r_;)UeG|6Zp98*KH*v_~PQiXp{@`w)I$ z&(gHU72kD^him2prxS`&b>=C8MWIY4Ohfx8g~LM9VW8^#ltbUFX{v?+Wo>1h4dlY- z$2Z`&-#iy}PGaxdYyMbH_vX(`1n#gWL{)NS(B*AIpu0)wurx}MR1^lWF5&G5W#OmL zs^BO{0+E?@Ewh;f%+j?K4JgP|P>xdD)nSY799b;ClCn%u!&Ec-sSJ4yZAmJvO-{fk z#O~aIjnUNdXFOrKQ&+nU&2Ej>8QFw=IOWv4|FQ0Y#SsCH189uC0Rkcz|NP=qoh@yC z{Og;_Qn~u8Prys>e;yz5jq-P(%bJpuA6xUPTCkcl3|hJ$mcY$&t0bG@+N7O%1JGN` zeS$JSr%Q3pKppe#Ean#(>hu~-EuxS(bHmEhWye$YkIOFmujjYrt#9jn?2aZ5)X|ci z72CY1SHp;dl>wY!EvoC`!o3i42f|=(1wEhL>l4(iCA+>jk`v_`3U#Hs#2(iIgYn+< z74D@3B?R;{%B-D(A-cCR!`(-TaQw5 zs*u^uA8ArBa{aWJk;OKO-W$wSuc{{*J;To={_=_lBaQbaqxDhccEQ>|G-#JRHhgWk zxtm>Q)MZ_X{8=kuGJo2h9nS-kHK0dXP1bhBP9LBS%U7UM7FTPE4bJn37aZ@^Vnca{ zOoNGO-5x&7p_L?0J{>nv;tpx6uCBG4CI?;y*NT&p(poeevhYlnS%{VxrmXCRKMqLc zF_^^_-0;^Y3L8Rs`_qHbb%D>{43qlTT(fGgY_3!;M%9mcTC7c$Nj9Mj;KTXr{SHpH zQ*Wpk6c-zPb2HVhd0s(2loKG;GaPrs#L1d)=tIGGY9ey5b2_COr;}bF0a~e2Vv2RGK^jCm{@1dLXZy{&q#k$C;uC$akbp76G4A}n5Q)Uf46&H~Lyji!cethDZrbjd?~9bf zk2n$zyWhMh$=cFRS46M$UPYBe8Q6G(n0AiKB74CHzNM7(Jr~_1 z|LJ;&a{E2kC~Y4<-aEU5nxxh94_Fdw%06-EJ~82~(*v4%UkW2iJYrrHn^bk3oGhv5 zOb8#<$#-a82NerW5D1-wZj-EJB3sfN{En#yMaefI(qDo!IHA577o70C&&tT2$1D~p z$y~OZKLake%leMMml4cy1M)mJ zuuRr%h|ziA9^Ky0kVDD4{l;_URFl4?=D(*3Gr0Q^4_ofB#<~5#qy8fMQ&DXI?Ynzd z?1m{r<0t|>0E=L;Fi!Mm$25g!G`_K|V;n?$jkYAuN^S&!+A|vxO&7paoKW7Nn7dns zh6Qgd#~zP|TzDLM+{Z)i@|C2q>}^`un}uw8+)soOoaA)dU{nH_MJAJ4uRi>-Fm zElsavfVB-%s2WokG%ySzbd;AuE%$qq6~VwC_yt;Q`N9^GK`{UT;bW3{xrXc*iStKP zQ7=+cwjp?kFK~;5vlS?d=LSbna(goyWm5e8m54>m%?8;EtI)p!E6}{@&|M* zFkQY~0`<4aG~dm+;*II@+Q}v{<#uqcSiK^tzbo+ctJQeCO3Zza(yksqv%WiD0De)J zhVYkNk2FN2X3Xqo>Ay!DyLmjw9!bgf&wJbBcg)}@`xEyXXKIwBrz|2 zn%OVln_q4i&y1WLZ{UaeHO@})j8Z+HVRkYw zhVmyneEk$}xQ>qyLo0V-eBz}XdGb5XB3zCXCcu05#8BV0(nb<+4h4 z5aI&#J4oHbOihz2`sw^Z!$Pz7WeFa82U}JoW#lbOFoX@qFcfS>nuB znHUna5kdWdBe;qr@^+hs5FkSs58_p$Z1{y|a>-VR>kUUmW5e2h z*V#(?ju~O*k#cnxFzi#J5SMqxO*+0x1(By*yRqzD(_?B_(2a8*CX095rBpo@t3oI& zzw$8XX>6|@B>&tjo+iUt+opbySFu}QqO`OgMrixe4Vm&+fT}M~4MiDd5J}i7#|sXa zDKcmYm3lEIvmiWA9tX06aFAJji7f`&`DYWgIf`6b^fdX; zl>eLvk8@b=Z&75WkJMfb&RS93c7Obn)S^}-79njOrzHHW&^dF|WEq_sTl%n(Csso1 zUU=cR_ON{`gi#h)XC~*I_(YH`$pSx4+&fDYN9AE5tQH4u*|pypL%i$e7&f*vC$^5g z48CL?NN*B-@?-|b*I~_`tqDPC1ur3AXYl=g#QRRfu)*{xojtxyR5~SO#v~UFSI!v! zj=o%Z?U72dSLwXuKr=P*Bh~LhDgHc_Ugoyrv}mSKvp&yvqK*JEp`o4d&aR-F%FF8h z{xE+n<^khLO{pas@#)8yg^!CZ=L`0RwCp_F@XIQGA+d_kY^Z|L@AxmdEM88$HB5|{#)hHSe3zsR^`3!s#EyV z(PCT%lJRQlcid12|6Hd^^g)~M5rmF?xr|qhH0Nd%O57ZG0i^!gRwOm;2xD!w7%trL zl{ehCb*uQz@_i)~`A;(d%u)BSncnw3n8jClMGX4&jz^*UE7g@7 zda5iL1>;g2BAcQkpU*#yNoHN>ULKoJ)-D>DcM`hSICR&TI~YjQFM*V&s+v{I!b!9@ z#~;o&w1n`xzMmrZnxhgO|CCsyGr9+hYI_+ibG+dGvwS^4thMJ1uF?CgiaD%Y8DqeK zV^9G41pX0&_}rO!vSdyA+7id8)%t$Pf-G?IUDfWhym0s*Zwm-7% z5mttv7bF2Joaf!n&+2Y{{Ev)a>O9*-0-!?u|M$%Ozl#-+xmR9OL--7`8OLz^0X+zY z6slxo+_RprY6*R)Bm+mu>VkEAK@e>VMu;hSc)QT~NPdrg&&f<_x|;Ub#b17c7tS&B z?P4K&Wu>9R-0k-UZw3F?>x0|3Cr2bf_Cw-hA-bqwNG1&w6Uo>E$ol6(%4oS-hiHuldRK5*^q>v01~W&?^C3kGa-GpB=rTFSYR7CJ4R zym}3y`%m0E3|5jFi*sr}SbofpyD0x~8Ar*6Feht33J9Gjop+(6CsZcHr_4HQA#q-y8P+Xzvb8@+wRTAvNE=uLLQ5O3Pf@j&sX&U)AJ@sIh4$Og?dro# zSwnjLVB*+cTG~js$8y6_+lkBuDk@`|tYYrxm$q1)Qsnx4SPvin@!+J{dUCsw+MeV~ zTzqEx-a5pZa@Flhpm-Vnb3msh!ljDKUWJBB?I|UiRS#K1uT;rllKlvglf`S7~*qyt$P3Lq8}Fb{s3-uy*Gi4<^0Lj;3j%ZtZfT!g5ow zoR@D?RFvkH3k@@)dId0puAmPD73CK1erpC64~}e)5XOBD15u@VO-nY4iBznmr^H0_3RG2d zP>N)zTlit0#54>ToNpNZj2eMho-;rgqGW5&@OFb@-_J{@{DB>ZZ3Vr$OW%ISyL{8! zdyVSSNw8A6x)g`eVN=h-F1Pi|E=(W!D1pop9Hn41-6!oUvGCn>j00bu+g8cp7PThBff?*Gw|GOeEgQjH z{V6sqZz0M0+l-sJcMHJ>s|%G_;p+V+pf<>o6el;K9_`ran83LOG5IQA7f&FDCk)5~ zlK7T)Vhn0xRI+l6(q^RKPx7!r^048UGDj@=_DI6BUiKrEvKZ(^V_JwqOzl%{5NNzg z2M|iB6(z+V0e&$Fv=CudtU-(wMNzCK?`W1G(W>x_9uieK2j$danSPET*bO*?qKa2lUj7)7&Fsk5sUhZo$l~gLa9=zjci4cOZvA^)(xc*gF{Eks2p#? z@6I4{S5vLObNm!`2{gg#-UCB)G1ec^bLeCEK|s_d{i4mlXl%66=VOx!*zU}?+Vu|( zbstdRsM%b&Dl-dNgj%Z|FnPy)Yh3O>Epj2QSSYFsw*q64hiyoJ6do$DU05-xYkX?C zcUQ$k%0x2uTFa1_m8qZ?jN$?*ZNeH>U4&YdA*7JxN5GMg(jrItoxv#T$-cxrEpsPz%oF-=Orqk%sj`4k`gvNku?p<0KPm zuACRUl850NB>6^vr#b+vR*M7dc@z=~BWi5CKJD`IIhXIBcX#N1qLrt|1BKCEtu=c< zGPsyBC#gd+=y*nI!fO3E!cHqUIBk6lHfURS#Mv%w>Z8df+wMu3rkWjTxFPPnTj;R7 zqQl5*zgDv$$GoRW<@IM<+B*6{#IxkH95r2p0HJywL=>IBgbRD3{G}$gN5nOzZ4=e( zJlb5{C8^{`=uWWV#FuOoS(>n(SrDCS*VzBXOcdJ=<}P`r6E43Dtye&O+*z%w#g1Q> zKKz2|Jd8hD&7-wbFH4BuSK5~<7u?GwO*h*EVY8H z2!XKBQa6mw_;g>(3Ph@1PXY=5ZcC$V?r$4svAUdSI!w-{vph9&@`cCxAY3d7#7oBk zU=vMAPJp7yPG;-;p9OXy{w!0B<+{b^BfJHjnaC4X@6P;E88FU8_X!hKBcfLpGEl15S4@cYNe&VSy#QqR2$KqVkK(IrzeuTLFtR{n(J3EHBrwsXf zd0OIo&bxJU*%XY$B4Wd>(Iv2TjsRXRbI75}C+^lRfR}6iU%Xse058|+i6}7nAtE4; z(7wZnZe0WM6IkC7(sIJ<->x0R($RQa28`k}X#TTn0)$4}X|GSM)q1yQvGnnKnPaWF zN1efSR*p==(v@8~iDAHg^431z)`({wb@&K6)BT3Q%}Dn|?4Nt!G+HQYA5U$KJQ8;r zmq#%A*8Mc8d72ifL`gv>N+z(R+&a&(jJyM2Drgo3h_AbHgCe7s_m2}2%4=X@-8c@J z7K&!d(Gy!~8!E5dKrRG&cYtdLQycYW?4;qNoLPkPLMs|w!Q3pE#zQn@l@oD9T?Gb2 zeWrA;%^wP|z|9mp2m>;oN?4xI4Up|v17tf$HKhkfL-su)a>Ldj9urup!c#riq?<&; zGoFg%Uqw}wg5tqd>pV<&>bsru2i*hth?s?+Iq8d?;t694`r!&?xLVO>#axTBj^s3~ zj1gA#8uy|C$}4H@x#673lVV*~D+|#m$~&j!*O>7K_EKBI1`mo8r_ICH@2kKm=|*I>WmQDzQca8xHWKca<(D7$XEX0VGu3BPS;99G&jMQ15?pQO}T`Rh&SB}E3P9>6#f;Z0@Y{T*?AH?_`7p=Yh z#oRD*qEN~HmzGtl*ehlb3t6qkB(Rw(#mo6O374z|~SkPB4D_v~glMsf4Eu3f~h!0}l0T=I>;EO`7bf*`ywnY5hmldAqdG zSJr-3%~ID5Dr1L(y8{O-VtwsI39?%J{~u%L09@I&?)&bfW81dvq+{F0ifyygLC5ac zwylnhj%{~rCvRn+ea?M%SKa$wRZ^9j^BZHX)EaZ(!~dt>SDO6Q7``pB#9qme&f=$a zMiqAM2;9%_5m4FrJ7FaU<-{&C;BK{QVL)1psM$C*w_v;f3aa9qtL7;AHU3?|18SCJ z$ZR_HMEil@5{HRp zN@2qM2-`5;CEvjuKIsa`fL*eOs2`iSrz4v*tR+C9Q=)v)Lg;UCN>=%uForzkd7VqE4R2-%Hz|)*PBS&0Yf0KpHcEa@&Uo>^~*5`ZYi$&XM1o^Oi~c+Tz3cn$h-^_($gp= zx!>oCN251n6=yG6Ch`0P1EVV-4kc;&(dd!OiVtaK#!ouqHp*z)UAKuo!T z!kwYe9YF8SNPE7ThND?zMkG{07jdoki(#%3D@p?2?uIMXsLC~3yR?haA%D1}rze%+ z^v`bbXJU5*WtB3I5jNR6pew~by3eXuNi?Se;@iw$N;tRF|%G32Y94G<5XPZSJqkXGri7IUSAIenuk(1LoZ+cMZ zZ59LpJ@hgu$?QzMF&-F00u_w3sMJL-={zpki9IKyj{|R1g`yV_+))qP!LUow`(PVs3PR?Od85Dz zdQZh<=zn5PAGELMf8CFp2*#J%BLRYG5797stf(C@i(2GgRMsK*y^1kxB(8l)P05*0FMib;c$QT+j?tyJm3h>g_bB;U6lGwSZM z!U=abG6MQ218jg`S{wV|OCEEsns}9sAqqBs6^I3ncxu!mTgUg?kesp+H2pRe!wL^a zVqzty)1R2$AAH*1W0Ce`f^`rYJ*=T z*UIJ}ft7#kKGQGpPOOxSl*bg{Eg7Tsm$zE0sc2Xowhh*SB@KgG(Mx2F><05&sK&K_{VPR>Oj7-yimU(_ma9hK z7q<`mCHv|cssQi`m=()u)Z2u zP~nSUe~&G!BJq-lq1xpFEfCl#HMXqb9-gvgpZx5}pZO`+ys)^)98u}0mc>i7_zt)d zME%xiTa{*;Bk#G}-*2>bWP7y4O(#fy7zv!9ZW1)JEMxGrn_bkx;50X52t3QR4+MCj z&a94r)TJzCzxWqmi}pU|#XL=@%tNk1F5O5WjN}Ow zgM0W#Zc<}nIJOw(PQ2CS_b+yze4OGAMA)XX=Z_1qv8xlG#*SMvYd!U}!!C#e|?*0c@`S$#g5E|o~MEC`l zm>wu#D5Al$jWoKqVF02H_sPlTQ-=>VF?*9XovMXsXrP+dAE268N-#~o;4B|{H_+oG z!Lo3Hch=y(Y3Y#-R1<^4DsKghqD1y!D&M+F`XIiQ*t^ZQ?FVpo(vf78Fz#2i1=#Nahq zX!ZWBCWiEvni%}TUut5@EhL^FYGSb;YGOct)7i>N^}p1_@^>DI;EeQ(qx^apJqU#w zq;G-#CJV#yztzMxOG3@i*=t1qr6#7405JJ-?Dn^sSmnRf#E!yUEFWse4F*try+#vFmxM0)Fx%n;7Clz9Ww}iKyFwif8@`3mT72dO<=X`a#T;&s0N>* zYJOfx&TWGfUYSaX@}M0f3=e%^ldk&8{lg>7c$3?)FNdU^Bn^|M!bcFO-`UyI91@em zf00V8G9c38pXPv$ZVMo4Y_!J-?`svY3#c|}J@1R4X~sEr7QHu=gjC=iu zaAz&dm?j)BUxoL7tG55QS5xI*f+509x*F*121qLO>!YIK#o5^*q6w+F_be&6NaU{C@=5e7I=BAN7jqdiTDh$RbTn>NxP=?}a&PQ0IP&-lJsth#P) zM?}`aFUK@7ZFTi@(JdQR-HPQ5WK8 z=^TpVGUlssPnde=@o2DBL&Re99Qqv%hoyzmzR=+#P5&DdO*>`Gb;refpQX zlA;kS%iFGn1}|--14kZ`!~%7aEi}4@RG)|FleRtak(tn1XKPnYn^TBmP zX6`?)H0j(g0#&w|-&Je_=XgepTOzA-G&5PNG|hT?q9))^+3&s&&*{c$Vb2BaFQq%;(q2sf5l0JOd$`sEpBuf<1#`25 zNFqC*rPFlnXUfa=_}KYE;7Vh-O?@&3-#f%wY-t1(gzhr%WuSxBQ#{@RSmDrzz|Upfy{ znsH|7@{7Y%{7oQ3G!gT!`+unNeVcZy!vjv%Gr(7g_@ArPfAijQ{5wSd_f{BmB{5MI zS_zG66T(tEhOIM3!>CKkFkVPTt8B|gW5Bgx&BoOXNE&5Geb_{}5XYLN{h~d4X6kmf z>U02{t~0WJb-sV9hNJj_dbVqiaqp-x_zH_}e4#-2I(E(Xs$)Kkc)%%& z0DAX_V7?XKo^N>;HOIU&Kiw-&w{(e#3+{4~ZM2k@mZho#_O!A@J={0rNajF_HR|MOpDbQ^OwXFaEm{kD+t7LM*2s3_Oy&n z*~G75Pd53n!`aq(-y>n!ZGc=d0SBclu7md;5G$iaO)Dt9S?|Z2Vq?96O5j$UtD|4Z zqn+)hmgI5ttN)UJ81a&7+vz4)m4tpV?B+r0j{Y7*;b7Mnp3h|Id27vGd|3i(H1uPFo3yz%d+}pUtQ)h z?M*mq2l|-KwA_t^suqK)mU6R|%6}eim368<(LUTF$wIp??4^n{qD({DTWtTdFWyj) zJY2%PCjR{&(oQyy&Jj7l!PWNv3=OdSd!|+%Rzgt(<|~t0Ag5}om_qak#4u7xfr`Zw zc7F?3pk3rkCAWZDE5S#6@BJywQCTrP?YIT;K&57JgrKp$A_@2|Y>>K6ipt=3wFP1L`$D0!0x znN?%es8nxoXQ(Y6y?xyb#R=1E?&16=zFS~vzfEOsS&w!=XWXD#>#Dit8`bLY!+M#_ zpebA@nsBCsIqL@W#eP3cP-=Zt!Y{~A(z>QwW3dS*i5_RL1c^1$yd2iu1>06V8p?|} z)y4;Ngq?Jwbt|geKszj5u1uoBc>X7+~%6EjN6$_(+ zfL#s-&HyPI+IsR3t*WeInMk6~iY^(70k$kzL0~&#*`a>98fvSuG@x27;y87Ox2D0O zxwLl3KR%OF%_Uv6NH1$Pb1`z)DR7*)<#{}dfQEOdl3k)P5EiBy`~%h^SR6JQiy2n4 zoN}l{qZbA2nEGlF5wq1?#94Gg6()lH$WobT&%(p31E-A)F|KZwV1%%qV{{sq1)OU# zLXe)3b#5^@%_>M-7vrxti5Mfo__!_Nnb$mD+;l!40g+)2|1pDd|@* z&Di=#%8qZ`DY)2YF!!Qgh1y}tTcPrd!7kzW`Y4c0d2VtULh`nmE3?TA)I;a$f%4&j z4}`&#;C@?hlI5dv9pWKsqh$-pd3-W7Lf+|jlypH4pH3_44f}ZFF6Be~1Xi@`$&A=W zQX0Ob+}g`r;<0Q)uwJ5IU6v7zo>(Mm!WspZ5weF_C7{$DHw!x0A|sIGQ(?j+chU7+ zWoSPjtLuxA!-v|AX+m%)nL;$dyDsV4jXr^wfwp9GAj+c#T0z~ z&ld{UKP;*3=UqGeElM!B*bD5~ffCq}`BgeaY)vOn94`Le1+SD@`CM@mT_+Ejj^Hl% zCDErxD)8U2{9r-)vue(MI|1t_Vj?sh*$Z@hD}=LKO}E98ryR^OO*W`IH7wTf=($n@ zqv#S*NAacQvR}?25IG?lvGA!VzW*!PI z*BNRRPC(CAx59uiFRO+Ps{MT8Z1Eg?v=#0!xCnXsNT~Y&^4~+2SD6WY7QlH)_5V39 zar|2pO!@CfPor#!Af*zu6f_$a!faic`;pHz3CtE_CgaK1%k$YkEfuzQeB~?*08K?&+1LpQQ?tD%N~zynM#QYd>tIP-d$=OpnSjCk^cl=@?8MeMS?}~+Iq?Ya%1XV znoQk>!hn&clIGP>{EL77vG@43HrNUm%G!5@ptau(KX+PEJsc`D3_~(P%|k*Fo531{^4Nyp6~xXlvpp-JqQi7O18bbRPtJ%;}RVQwyvom zW8h6c836dycF5|lPNs8zcK*pmO}ex~o^?!;hG&s#S}8y&FiChLsd5>;-i7@UR(pwB z_P&)3u9#w?vDK3RzTIYItaN?an{P_RK0ZTR6)qB41q#SA323yoWTSgDMFRI>eBuy6 za6yN4J9PcV$@eWqJ2gZ4+WCZMv1w=|pG8h>Q#2wj9dyC9Is7tYPy`wUGb-PUR5v?b zf<){Jl^J-Z!JUycN_pW5KEx|bIuM^g8ohSvdj}7ve1Mke3`5oLMb$rtNpFEp@Ai>7 z?vwERhIKVRO}kH-;t|IMfN}mBkxa1VkU!JUAIb_9G4;##Th0;t;6IB41E&M@{eT1Y z|AVgaZ*9c?qHCCAWq%79QWdqKVjdEu79}e|l!m64(x`sM3U3F?u;*{0Q2M-dv(H2 zOP)qgO2%}0zIcl1V#y-Igr8_EDPimHvi z00Vc_9-8I!J)P2^bfr3z%ySSgdpE&G2``YkCGmtE3wt*g792{T6#VY9eYr5 zq*$|(E~l5ZLO*#zRW~2iVy*qDrxFwJurZn=zh9d*D$E;a%WU*)O1%hwx7a-Xff^SUN7ltgccwLz9OU~8t}$(|KL~w=t>(h}>D22c z*X1|Y%K{za)V`JVw{)|L^hw){2mz7lrBGS$Yg%*-TIib>u3vG-Dqrd^2s81aVe>gy z3S8aZ{Qd*i$e#ehHFEw5*EpP6nyTqOG&lk6TVX6sGfvMX?9Baf!J3QvL+>Vtm-~t7 zTNI)$@$3csHnU;i5Bng_2e>K;BYoR_JmS!#s#v1m-3D<23EhbPm;ssAS0vl#IUR!P z>{H)#WW_aP`L9Fll94w6Ap{!e{Myu`8LElBjFpaifntnOE{xc{oR>0)QN%)i5b;FF z&0<%ciNN;dxcJo_U9u9O^ju#ouQYD)edxEZQza(}V8 z8i8kYsu0MLDs&q`y6gvam1;vus^9r=sqO%|B$9G#H;%R-zCDz3b?`7`m z`jOaZwYA6XGk5$kS#wd`{90wKWCe>t}Q>#A85J=TKlIy=rR!^Rzf^^RTxv3IB3j5R(Ma65O*vqD4#A$Uc z<&i-V#UvwxtmkM!C3?`y$!Ee%@tv_Lu|@qnL(jRalL}u`%A#t{Th(9b9s_sA6_mT$ zgotC!C3qeUX-JeK2KLo2->yplLiol1ctqf7wM-NugGndaC?OFGl*4|ei*D8Q(8gOz z#XA5lc#C8{sM`2XpRcs@s-Rfp3GLhj-{iUprfgx9Yun8<+@SN^Upz0_(-OCHqNH}y zLSxt;%`-BC8+`&IG3_`sO{7Sj(>`8$GzQAj(Qq>75B304DE zkB&4;P23>WPz)8u^c8L;N7n}UdMU@M@AzFV^%>-bKAluuBo(_gevwvckS%hWTx~g6 zjb1zF=lA(s9r8>L?%+>il?#L6)g8;i^h4S+<)FFz9fP%f$RTLQ5_v)9hG^0uV+UM# z(q+ME4!KvS^+l*1d8zfHbjby+RgX=dnvdZbz;@islN*ah{me-Dfc7X6m>WB3&4{^d zj7i&;>HXW5#A_~p8>p1`&AZA7Nx>y^0tmm@bwYU%Sz|3VaO!m*c@dA?C>qaUK;8*zDDx(b98I6d`G z1u^G!`L~HtHl#k2on&Li`(a{u%fyy54q@?i2N%s}EwtwB040X7OyzqL%Z*eOwqHFl z-h?C#f5b<65Q9rZ{1&A%<5aPxg}VW4ch11klq3Q7?gs__*6)#-gQ{w z#;Z_(TFQk-LyULiO*tRw?K1!%oJUTPigKP4ilX2LD(hzz{zLBfs9OXduo;ydA#>*~@@@55Z1 zRrHHl6Mhd`>RaV-J|(VUz5YBv---d(VS&XbJS=riA16P2f``L&bB7mPg_SjRe-cwL zcjM zOY63dIHJyqjunjPyUm%msNBo>$8z>4&(awU+>PM_UpK;k`pV?(KkDTbfWaIa(!WL8 zB9;I3%FWQPDN#>ThlCJ=Oe+BtFF~b4rUk5(sGy__LlAmhwGyq}QrD>b{R-rfQ6i(o z^j}1QL1TY(Dd#ln^Yj*{vzCL2x0mZZoNs0tQrQ?YK0}0Q0TfZ+gW{&cB^{9!=+M&E^ zBKI0B)Fo(ag*8!-t07oVDMa0XphTBIkVR)0Cr{|ms*dT^r#~$o*s|PIhBs~n!>Y98 zpxHjuilL(#&nUNY(!_m}UAnQU**DLESdK`Y(P7O|opd!32wI11prIjk(Eajw*NJj4dSn^%9|O*>`QA_a8-(g?sB<>lPd zCF1UgtPBvenRrD!>PLH$SDqDjU``~oxM|0I_($6AGRVf+|6i2uKPeC&)JRlcdS@CO zG;mHdz%~q!8u`5{lqJ|Wic{%#{;1)Dt2(`QgH_TU`lVTlIjb*_#gZ3s;2LgycnBn# za!mp?248QVf$0yE7o_2=wr2Ja$C|7C9k6FWh1LALu^8X2r%LbPYMuJ^YZP10)$`{z}Gwgly@e|H=kcIhjEM-_TsCuyd_5a&%EkojXg zbQ>Ts-wKS<@uSJnO1`!~_`Rv?TzxQqGU(9^rRhcI)NZi`N#1z+^b34Irp&`QQ?HUkm2qV6oFo^V7&DK?_bvF{37&InB2nqePAcwMOh#h~%Q&^8 zNJo=~n1!nnJA*8gGTJqZT99?a#4~1A6LB;8Ehffj+`{+;kPh%e;zY{0;L#zJA7K|! z*2v|xmte{pOs{Cmnt`;hpVNq0DMVVnmr0_7R|2?d*zNIX`dX#zBCA2xIFJX}R%M(^ zbG1XsBc&x#wWLLx;-CK!rQ^!!*1G^snID?i#Q&T!|66#0{qITBM|n-@Bd0d8kPc1| z8f4@bu{pGaAYvdfNP&b^_(a0i_cpR})@kw#Zi;Ut?5J5 ztijW+WWGsxbbAS;7+vL(LN74m5ihZ3Zvz7Meovp}7#t3*L@k!6!Ur~uB8&t&@9Bmo z0Rek7uOL7|?;Tb|`3@w5?tUkO1l~m3y5e;0q&wNLiDTLR2jvIN!x})5jb6cnpm6PFJu>(@22P-$3u9aI# z|DZ@4ujg!)N!Dm>a$*h3UZEz+#T=#T>Z7G9I27kK^t+YByG6E_$sn6AEWsa*H`;Kn z3#MGO#@0ai$O1GCi1z6jrEG*Vj%B%k_5cMtVT4_;r`aB9Fb$3+*Y@Evepy)FTZuHv zqmK@hS>ToDbPPa2qw5heC3e%Dc|z><0f#%$)@Kl`Jq{^cE|UdA&xQ%3lSrxF zUkKf6N?+tCR-Agv(Kl$zGSzM^MCJOzEGAQWRd;JJ*medk+dH<_g)G`3_ij0unxb6j z%53sV_ayil$Em{j8fnF%z~Abrbyz?b@I+{Xf#mtw$k5TgSo|2u59}G&-Q_nH5ra8s zNIdUn*~c6dAO!)ubt*uC@HS4RX}w!FQ1VQezh43CYiuR+n0!W_$rX&y!S7C@_TpIl zBULkJsT5B(hbT3&{;%Gof;{3fs>smPN9s)VNPP{8HC3oV!FI8!Ysfznwpi0LQ23QMCWodA}u--=B(=tN9=tm9X_57w_Ut}_~c zy}vv_ZQ`wy=IoS2nMsOV@cOg0OKg+r9St2*GWKooRFX7djXXYa{n|8*Zfjgh=-gTU zsdZX^_Sk5_rQUAmXn~J#*B)hVpLI_Yg#J+`+tRudmIwGn_Y&Q{Mq*rZrrEx*-I@1? zsd%^&g%Oi&0qspM`lC$NBDHUM1zPyz`+abq5!)4)Lawv-BfK;$%(jw-FqItcl1&7b z_Tx%~u&v9mow6epb}i43CP_lR0;qrq9z6Xo^;zY5T84yp zlW@+cCAOZz8^{E>Xi}D}UBQ0@B-M1w4!}^~a$amf z#Zre)_b#-HJ#{+GVcycmW=MhJzf>(4Pni|63LO+ivK^@aj!=z?J-kU&nd9$-v6-~(2+PEs|F4^G@!8P|Cq@A?{Nn#dRP%d zeN&4=Qj}>SV}*}thN@?VPj0S<;>^ka5~l5Bkr9*Qq19(dGZZHthw`+1gm8#(B+acX z(Mx*j%-r!>=N-W2YC7q}2 z3R8v@)?ZqEhZ#KfgD$?pjvshz3pnr%`UB?*sf?G!uW8$?X6|LnOG<-sv&Oz)&l5NA zO+BRy-_p=Day<3Goro1t;3GY2BA30x66}j4>|^j_E^^;{Vf?PNY_$}*LxmPK2t`ct z3-c3tc4GzI;rUfeR)`)r)e@x_+y?S!BX3I`)39@?_Zl^5RiGOqtiQxnk83=XrVu4~I>XJoVbQ1bkCmkIm?g3la12p2 z?O&zFOpUE1wMW)3iZ|ITTwC!PC!b!>IwJ#$S`T`j-&bH&3r!tonvwe)~Ph&X*In4?y!m~32Lo>#N7 zX{e}ZTz<+}%1DCJ z3AAs)mefls|J$(DTR*|Z#bDFRrQO>@bnpg~L@%Xq5EII`0`uQVROE!xvL@Jx+zdzL612JjxTdKpJdBZWh5=l5Ap^m+fOb|L00{^xmi5C~+j+B*G4+%% zG*1vto(yEQ3>aWSKohs_k>m&eK&f?lkKBPz)PezNT6RsSb=(L!jdfU9 z1GbEbvIVhqYENNVvs6GzCZ8~XMYVj!pwetoY0(<(m`!7I$qAKRy&oJ7n)ewtXk6K$ z0bDebPagoIqLU{WYIZjbsSu)@?bFHvC zFihRtzN>Bptx>I?hK=)N>43&MBTU`mBEhUmeG!b>c7C(iOux7~T%}zr;DGPhFi1Cd zn+>~tcL@izQL*2OjdOn~iTZ>Yt!ie|WwKKTkfzeE7tqA}3>~CfvJHpbez2rYWnCAh z&Fa2USEaVdMr}K{DLmP!23RO@&jTm$xVwdJ6YNi8edw!eqO>jxTVZ_|tFu#Cl%=*^ z*o2u}FRVVLUN7%X%Jix0u~oc8hwHpM(MD?&?$>5}n6I-_UOc5f`;Ex^3?H;svTcJs zlIfGwbEbCb3a2{RDGuOKZ5Qs%Hak~Z?4VvR$vInULu(Z8Z(}_Llr$^%0H1`kvntRotu&yondYSFj!MajCw? zH+x(7bn5r!>ZNGJP(zRw z)1${G2&l3=ak&%2ex~wL2G~)Z*aR9lI60NG{bE|4N4FA=fw>MJK?+Xn(>^AjWi5|p zmkl8&r?s}Llw|EoyWVN2;ewioMaV+D0vE?F5$L09f5AA^LEvIdTdggXNirm0|es&yIwW@dvX0jq!R>>yp zlQ?@b9H<}3f?9sgwcr)-27c{P&(K!eT-rlJV#ZKQOmbcJ$dsjmTCm8O{Dco8W zT@ycv88_KQM^!;CF$*5Xs#L2N;f~veZqBc*jRBb$r5mOjWyr^MR%bXmQ;)?d*}ul< zRk-)YbFj0O91dmcb(alECjj&Gkm>2`sO4AlJf4J>ECIUoO!^ zt_o$(#Iv#4^a+LE+c#H^SjhN|)z8zE(?Y})J-yJY(cj-cS~pn;k9-TqT97~sh#sOo zJ+FX_oO4@x9ym& z*UCF^%i!wJ)84j$atjL@&Yt;W7@BLY-NAA4?gG6;V~$fWj~srYwhW8SkkprYjC@O~ zs9at_rzM`pG{F*>()1N+)JngBdS0rElc8fA?h-1)HnsQLPh*c)m42Ak8!9upi9)y{ zm-!i2Z5ieAt(l#%)#YV1Y)GPnIqBW-@RlbjR!R6-_cl$du*%~@LAwa{itkN zer6ten|MR&+t{>|ER+ikPeZ9`)v!YJ3uS##Z_e_SZX54gNkVlMe-ZRX=T}@>;EnJD zU;1?c8svjwyiw~%P-VX0f@k~=*Z?EThVarCLbjIC4mlr=(KyOsYRGZ-B1Y9m(~_j= zY_xGTH>5Gh4{**8~ee>xgl$WLkqE-d%bYP-!#o5NHN-Sd6AgwZ zSy;n5f*&GL&khos==|_h-kAhHNNsO-Vg_t{agn*U28n*IymFFWYw^P$d3Pe1F(0*0 zL<@6Q-S5OqU_3ZT9KgJC=3k5Y;!Zyrewc(3{BU0193;x%cmm#(_Tf~PI2e5an(P5h zJmP_OJ9~ib^^briRad}H(mvYHeYD5^XkUR~Mt$U+@nN?PwA=XTs_El76(1&IA0{82 ztnYtZ2Y%>-G#}0xi39DAM|_xg{P<`({?YUdXmWl5e1zM_M``=8So`P%pJ2jy+N0>B zm%R@Y;6FRj_VFq3KYGvlXukn$&;Rl0`;Vp&KAbEP2R}YK=>2%ahly_=(1hn9ftX5A z@~4u^ouFq}{?S8Gb^e^TDD(F?s@pt8I{=?t}s_mi6({EoN#vP ztp13AhDx{Hv6XO!x>64982sFTkxkCNJMk>4?Ck81IEs;(Wuc0i>t!4*d2@fpYN}T9 zL;}0YQ1V1}(my346chU*I9Zww4$*__5t)_W%?ApIi}5QJz4LbHglvYTnHlOoYmO$} zV+u@~NuMkAw%z3BHLn+#NUqv&D1SLr6oNyQ^)fOtC(DS4>kj;rXQRuOixQ6`Ev7c9 z<=!j%weo!Uy{uE&;&VdZ@t=peq0RCgTPR0SG!x%%tO}r-*v?mJX_e}q&(tK~`G{86 zr>7S;*K3V$7Wr*rmKR6vxbMLWn&90FpIws@3kAH&WuG?&7WzSr})i&5vgyg)O9 zc7R&sd3m8&nv-W>TSjZ&@txGd=R9BS>oUYX-P->U(Tr|X9ms9i8;?AWuk;^=^Xu`6 zUq-gGx~PN$ciFxFqLy8gQ$p0OTt|FvobIa`UMH_(2inSj?Lfd-w34!tMo5>hda1UI z0a@p%U@Urm9tyS^aqN{oVTQS;}r`edVt38?gy-#ADTd0AsG3UN;rt zj?^bgea2+{(9ejK$Ql&YdIkYqb07Fq1JuE8o% zn)NopKp0TnW4pq^^oZ>Px*|as(A-nHM!{^6-GjPl!1T!M>$=Lo^hhqW{1>3w^)}%^ znjlxHFU0&gpjOH4+q%-g)`>6F{CU2tlV7;{yFs=qZ|Z`aqq|3UWq`OrwX1L1`g?s@ zr?~L-M}S777=`Es|~KRD$$H2@kY`~b&Ih@Z>c+xvv*%OtU(3XaUX6awSJki4HuqTQT1 zKY@I@5|fB!2+H=xO1|1-lvv+|j{#*q|KhhB)+HT1bwk$F7j$9Sg8G@LJN!&U4;kgL zGkNHcNbHlV;0BS{h+CUrjlhbX;Bt)3526Rs8vvmxWy|N=JL9yU(BAfR}V&Lr|Z_I*Ye^OhnULi5Irw;On z-R~}fl(wS<53Lv^wBr?yG&L&*wtwc{P2LatW_yGg+O;}lY# z#4$7|S~iU3^-1RG*M{KXWcU?5rd0AJ@y)@M2LR#X`_8R@hV5&Y?QKB&lLG|Rq__cO zS3F~a;KZUsFG5fC1XU!+Ps1hIr4EGPk^5dw4~DT=f& zcjuN|6}VrRof5sr5#2JRl4I_Un5&#h;-fy02*guFzr*uT$>EYN+(WHWM`MPYbts5Q zPY{UYEvF*0<_i&lvG<0!?28fjg-YBVp{JN8cL%=fkfw2CoNXv#7dMbed{d>e+hP)u zy9b|33u3KV5v&|mW!WtR;6$k^fhfxhaUhn7(;iVypPTb{ z3-X{a_V_O1O>DG)|4A3}qXqIY#BXAPpoXiuYC&K4%EYW6+PehZYgf{hA)@P~JR+-e zMBDU4+fvEV=PfJ6VB}kqt2XuM1KX3A7^G9+m_g?|5HZ_Q=^!UKl)u{~pQK-YF6y=P z9njQ9S4KEfcvV)ipB=xI#^}5BVsDyO$%||FTnN9g&5gd;P4{hkhs;CyS&UJqrR~UQ!Q;?hzt$*i4C+=F~ z>~k`57-k|dBDR=i!d>HOATl{A!y2!~n+bPsRW>XrXmw}J0)-49J z;8BJq7jUz|*sG_&?ZK;vjMX_?CiUq4WDmqE4ojAHwk)<^SYoGjG0^ObI4l7#?J)lctQ|yqWCKV4BF9%)|}y8!K5r|hd+T^UH%^gbc}y)DPKIA;z9W__y#2yFIW+}5mm zZ6WSN#-48g;8DAk&o=H|{vAlyfteOBdCk&&2yAPmrMUTb1HA?qmM1NY@TN z`Tlv$qqW_xn*x2_LI_x#scrDf_Cx-?NY|3DsGuw@$02Z0pz@+)MYoFJQCmxI{&16= z_N<0)3IsNrC9AjPaFbjY-hJMJldKo4XHT(k4BQt=eJ?d%2fAq27e?9Jx4UXW9!q^o z{g+m6QE>E}Hg7{7gMCx|xmItLan_wTD?%O{Gfg%%&O8T$-SFHQ4BuoX+3j)NURQ(l zIWK(sUIZqYE}YMv3gCV@ZuW*eTKSaw@3b6qu)oavM-%U#-#CEF?K-@!2S?*QkQ%-T z@~?GCuHKfw`8jXag*;ODw0D0qJDgxThFg zO2DOw@Cz45V& ze2H_pTjnZq>HygC!M!xHh$~sN)A|)Q;GHB$IN4}N17a}t{Qop|9`ICt{{xrJMP-L% zlkA<5O=bfvAz6_*R|CKkW z@~rmi#I@W>uDx9IFUoupBK(?yA~ReXKcAZHS9x7aHPO;L$Jl?}Dyh`F-R1fG>?G4S=?iRMY8vmAQU(y$e&}8kc0(QCa_B6K| z%k`w?CRmIA^eagPcyC$|2$2oF@5@{HQXzDaSuDYhAAGtwgx6$<8D0g@h z`zb@2=Q=S*3eVBkDqy;4^Q^C!%RGB}no?epXQxcd(`?H7k~~H-4Np5M{Up`nM;R~; z%&qsm$!kwx@;6A_P1>73d1&Xu?l}qE#QO#Iy+6K@W44BdWS>(H!$?jnSUd8!+a-hl zc<%2=H+Uw>=efQ5B<<<-qqL{DjLPiOjG63ON1daD)5uWo9dj4U`Rd)lAG@Pkw40;- z@PVCSsoGRUk#D_ad!Ft)7CC&EiRr20j6(%xB1bgZy5eH}ZLlwf#JIw^e zbs6fDE2=)9&u5vVRlV*!wkJK@>y7%NYGF;-QAW(0LrTob_nS`0Z>u}mFIU>nrQR%6 zn|YUeHnfaJ(S8?%ny;y|OgLBYB`dqRaWm z4S$+n559?2dY%?zyfl*f&NwVdpsP9;QFwtP9iI=?QFpTU4E%3d7n*x-K?`5 zF4fRug~?+n*=Y~+c^B7QCC{f%7xPZ9k-%Oh*Cmgl;}N=Btz~m$cb52Wc3R#PW8Tw} zGbUI&+TW(5qU4&)KeKD*sj5V0B(Zle>LN43*mjJ%_{>&p6cz7=3OkTSpQQS{vBF{$ z}K2mzJR~U^NU^(axi^}ld)jyI`6M}s}-p+O+K`S|I!vf8}f8~E4&u-L= z{vFX+tzgO}%otT0m-*!#wt4FAGxE>f+vWo@sXx*%e{7fQBYVX=Js^F5f1m80MY@yI z@ZEI@pB%hmeh# z)2)Q{Tz8YJoXk}^zpave9GOXBqtY{1ACv~ZKeYHck$L{=#T4UMDfw+DfApWs3|(m3 zp|&u0$1aR!Mpnt6=P52B;)?OnLk(=F@)}KWcGr+McqH6OIndLYsK#T$6SRr>vSb5&u^jIHTbiR*6CPtdUoX|%Pd`** zDL&jJDN`phROmP*Wu1_Y;cn@=#?hi=wAfs{^%K?DLAOfDII4gaaPWG9U?rXP&nn#8 zl6xO}zvexV;tJk4k+0jpAn|HX#;(IEc8<|^=+$u3MRi%q=R~G8#~Ssl4-$C1M!WqwHU zJx!N`$^iK@(a8+9qDhzBFR^Lvn|9wb_(dI=kn7|7MzK(kqrEEdXembQy<1(}9U5Qt z!w2`UQ{0wGc>B&Swkh=T;p4{gNxl0|ZnC=a$I#r*Q&{1nL*R>Cr%Kre&dqs+2Xf21 z_GvsD1MimB{GyLmE(*-ni~17NFdb#E-HP)>4i&?*b#e+@+Ri)oQ1RILoq8r)=aV?T zFg8WQKU}qYfx4gZdp#5J&JrKPm>;5J=(6QW>mp_GuyM~DFwxPd+uX3vZLa~1&xBaGoyY<-5YN4 zBr+D+57)4$JE<(Z?r>JA!j$dPO}X*nO;`H0esjg~kgL}j4%`JDkDyNH; zw648*{!i*y<6tq9&2VV%ulI*Ml1JvB6l@yl`pfX=WT~XCiy5{2(>vu){=UXswFokL zD89YtN`g^S+$Y&0* zm$+3P^&A^GxVW&L^85R&sLz`@M{;}BnZJ?Mcg!T`P=JFwrH@Dp+b#lNg+&u|A(GkJ3Vx&H~U;yOYgkUf1)ggqI5uSzxFQC z?nU-TVOps^_>wrq0}dEFiJ5)fQQbFhp4QC$bDYZR5^i>eHS}V2p&wVJ<)n?! zgOgJ)KRfKUuJ?JNx9~Y-?#LGcz{oDpu&B3*>z;yY>+`?I$Lv$ssps&d%DW_(SBhTgS#igCM^stvN z8lSq%Cezj#M3$Lyjg#u5Xo#d+q->m_T(1sG^2COOPHWLy5#rUyHNs#oR9^f$etjOi zAol+_q?B+Az2)Fu@ZPQs4gQeQJ+}mCPe@bUrnUV@C8lqI`EU`t={3`PtbuNqk@<$q z6SK9mwLVY&M9DL?;jf`zcRHbBSF={^xu({(9ml`i|Dm|>XMR$eOnFu!fl?}HlwQu~ z@bhClHWhgeMe#*_eFFm@v}o!B4)6cM?eoaT#I^iVMULLS2Aj?EoJWH^UZu`&3fcRd z<5av0&*x%CO< z>RT6K|EPGzk7+%*cEi$*$vch`zZC6WcwfCrXJayuaqCKZVuJIgftM95T>;K=b+?8l zZ*7_^yMcV z<#lnl)B0?}=Fg`w?0IL?GE1J;T9m|gr?q2;DEUHkhR$-$~=n1}74x=qWZmX5-7HfV zUJ#gL5%2!t0XV4i>pgH#>4VYYX{SK@<}9ZeIS1CsV=QVBwv0}a_cJaYk;d#` z9JlLm?ibbUD%a8uT|sbAsVlE+2$`V9P>^bY@Zjk)p|MXAlH-y&W|)jpT){!54L)+< zpwbA7WAUZ7U2ZRTq#JO*|HH}0#5T76Iyk7*D3Kw|utnt1ce7)IA7ty4Mn*aes&X_6 zDmxF{sMHBL#S+Cnf5d?0lQ&C|h5ci;Z@Qujyiv2*d%vR@c6925)eBUO3Rf9iihuQW zu%NY;uTAfgno6u+9j$jAi2YcFQ{mfDg=<_6Lp_JK~G(q6tUQVy=c_8!vO zmeSr{9#SVf+W?x5nn16!TjSihPT@Pvh`)Tbzqy8>PVZ?DY&rm)?AN z?K#8A*g50w{U&R_$qVkEM@zI~lPPQ&qWG5r{6aDEE<&R^+yY|vyA`Utq)tk1l|6i3 zBUNo$S0Z4q2t}0chRo*?7mkUrr2cU<*8SUeN#IE;g=wKbc?F2>)KIv6}&xOIuDE#~t!} zLee#-)&1I+a_`OEWM#=EN^#!U>wGwyE7fKukh?E+U-}go7e1+UZ~S{{IlOtv*{##Vso&J8Sk?oU(x5q``<^#>!&@X z=nZC8=NkIC;bpguclWLb;`6dRIo|%1o2C8hU;cJDPgA|-o#UP0q`@|m!geVT6Zk*) zlLr4+YnwA7Gj2TNeOG}c;f#s3!De~J`{2n&%jXf_v zH|Ngy-Sitn1volm9XTrnD;XIrEgAlM>8*!rc7yNR1O9-&{rACP@xT6)!c5!T$Uxu1 zQp(I=nLrN+U`GKU{CfoaLig`KHjqtghp#29eGGrJ;=lhB`~s#IDkqk7l4~# z@S-k%5&Q}8w`-ngS$Sf)_gMR!0qmO?36GKCziAHgoV`EfE8%Th4|$Xq960h%JInpo z+V6!wyb1EK3Gu_LogXO-{Ug_DAhg_yL0p1c{*@NdWyJ~%8nczGF#gaWG8pmLS~pfT{? zvjBkte&Vn+z{C&`WTT2A3eW#<-GSehe_)+P#7++uz~M{Ql!kf&icq zuI^Z+9RuEgE}h)UfhBOB-4p}24B)*dYHjmIQamLT`AoiE!+Zn01<+ckK~;g(A@Z#l zR31gLz10+j#(_+dU778}+xV#d{KGatdO;);GkD__NhZaW92rg;bncEf z0wg9NlK?^W|M>>jW*7D2PS;HPD0)4tq$11P*_AsjIuw%F(5887o?r4zerTD&eFQ?W9tC z|3>V@2jr1SiYPM8HGG|}1|d>uCU_8RmfFjv=%JHd3fD}yR z!F=Y3q-76Ak?V$zNEd#1XazKAjB@L9r05DO@v zc$}AWy=DuL><5ttOCKP~Lm8ZP+~mpZfqaC!oC!{X!>U@tc!b^vWpGxFkLnlHwMdIZnim!H07;m|y%khOm%WtLngPi1C@PW}FttH%A47Y2{ zuwWBDDu_oyUVj*G#SV%n@v8IOCgvpYJ2fIN77Rir$)ZH+g_%d(H9)2?pfP@k7x#rA zl2$Z^B3Rm$`6zOLdklob@5Um0aM)L&FdRW;diMVI%X_on`VyOTPDb`y_A@XT!EaG| z$`o%YH~a`V0>ly^$OL}kuzC?N5lS7ej!c~4q z1n>wa%o9Y!VVR;y@lXQ(jkJ3>K2ahbX8g{>aBIT=rLo38ROXRqTHf(68dP~GLBHAI z!7Q@!MiWW#c|9B3_ZUap%0;}P5hh4iVayYMj}5hs}5mk>$I!GO|ECeHU| z7r;C%4WyH+H4D7cxn4n}AxA;zTx*dAh|Wc#N`w4;M2wIEid+1>Y?X`87lFOf*CFDBVicHPj0;jeB z68;<#Xzuq3RPYdUKa@PCHn+J3d6d&{hmj!0R6vpJ&NQ}(9Vov6uQ7tE{_ATyI$=D> z9Me=I$eFq005S=!l3{vpl z>jyGTCr4c`Pd|TqPk%pT9h&VvGnft7Q6Ti7eiZQ<#wT>1D1P)_Jmexim-8kHV%s+` z62uxxD1J0Dcl??j;1OrR6mLoKTKb6J5Rn%(Ns31}ZxEiHEQEVLt^hpZ@+i{> z7;kMSgOWJC)sdHR1GE4l?NLs{Xi)8;@Lrw1Ciw;Mi09PBUr6!fQ1ZmiKRbe-1KvF% zKN$vvHl+TBa032Xz2TQ0ysAxi1OSi5m(oNb9zFCOMgmnYhvPb40iKTVE1}jx@#XmL zo>O#UAaseRb*b|(C?RxFvYjL5*Gk)gXvCB1pNq&OS(K!%R6IEF5>VGOP<#h@&W=wAbtN^n+c1 z$4tcgKt+m&Rtz(7R84XRmLM)12GhcLE0#bjgMAZ~YUu)c!>{l_7e=-)z)0Hurd}>C zXocI|MMJUu0JIZ;1R#bGXG8%R05f>`1|ypwgmHEU{w6af&`eMa{rACPb(vtim6`uq zOTdoKhkNqNb!@FZz#f^v9{m5%_^gp`0}Q^FDuj~KKHP4>PX}=LS}wGIf5;5Ot(XI? zB78x-B>w~;nF7I}=J?HuOhSo^I_wE8{A7oC@^qU627UEU(**T$E4P4kJN)10|@G1Nbv%fKbHa z{WmpIIJ70I`yEBDfgk|1z=E6rMEydVq!(H6eU{N_Z)CRV3CkFcL&rXcdIXhT?>CfEP=oeB%gGJhZik(}o62 zTLF)_UbsGz6b~i2XC2UI!uOB#MB)WSlfo&XlsWR1-81oZG~)W>=kqY$n!Z~Ig{D86 zG=f|los5UkAjUwkZ9%{()n7n3V%wS~k;0*^on;knj*kMoI3ndmT_D9%L@~yMKuOMh zz*;2oV)IlOZzZtMR^gO+PwjmHBqOf(sa-=Rp@`J=OTY_%i02m&@6f=~&Oipqq2y{I zSqYS90Euw_%W7af%z}|1wvFcJt5VBjYX8=OOmr;IX;Dk}Y=ehtQV zb)uF!<-vF>%15g~o}rTpLiXCzH(?}5OHteA5-$ZO=|g(Pt(LFKu9M6C#T*%_ko zeSI-09$Gz;SIiQFoa+9%M~Wwl5~Yy~>+-h&!l!tMc#M~DKHA^FGP{X=Z{fr=1SoO!D5wp_!>{03IJtN@dHVm4IX9Qed1@YD)L$SNI3W#{>3~74{9WDskej;H zza-=g)KC19LU<31vf{F6%Yla7I4&$unkJA5+TRNFA(POSoKKs~o$&x9bwGm7@P-GF zNhtl#m2Rch3jl&8>MtUOVIYVd(N?fM*)-#XfndZ%fPHUB@zB<>JNaK;hylDpAfo`p zgpVgk@f1-8mUmI2HeUgcI7>J?Ns5P742+~Jj6MfsdrUN{mwkZo2>ls~WPuFB?v6k* z;&eIS3o;1-grBqv`-afr%UJliEO_nk-v@_f{t5#bc?R0MyEDhigK-J(hrw@+hZh6jH?XfFTff1O5bs0jc;Q!Vuj1t9VK_phqGU$!+lE(3!HX~w zL@>hL!|;Z}FPL5Bz5Gpzhg59G&(h%cd9L!x|H637FTWz)?gsfnRE|itA%0i9-<&e(3)yj~1*9Bh>~_0u_FY`zk1m z69t4qf*%vTN^<3ak=AMqd8CK$<6nibfoU|PeQRMtXv@X$J+Q01t?HzBNQ=fun*&#Q zM>NUdTWx^2L|X;dMm&D|4SYw{D)1C|9T2v6hX}a3ziPJ$6)236k=+IV$SIPMeZ{{_ GNcMjLnE!kL literal 0 HcmV?d00001 diff --git a/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom new file mode 100644 index 00000000..0dc1aedc --- /dev/null +++ b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.ciyam + AT + 1.4.0 + POM was created from install:install-file + diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml index 8f8b1f6e..063c735d 100644 --- a/lib/org/ciyam/AT/maven-metadata-local.xml +++ b/lib/org/ciyam/AT/maven-metadata-local.xml @@ -3,14 +3,15 @@ org.ciyam AT - 1.3.8 + 1.4.0 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 + 1.4.0 - 20200925114415 + 20221105114346 diff --git a/pom.xml b/pom.xml index eb306420..860cdce5 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ 0.15.10 1.69 ${maven.build.timestamp} - 1.3.8 + 1.4.0 3.6 1.8 2.6 From db2244594836dcd952e97746b2ec529483ddfd98 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 6 Nov 2022 14:52:14 +0000 Subject: [PATCH 051/496] Include API key automatically in publish-auto-update-v5.pl --- tools/publish-auto-update-v5.pl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/publish-auto-update-v5.pl b/tools/publish-auto-update-v5.pl index aad49d4e..f97fe115 100755 --- a/tools/publish-auto-update-v5.pl +++ b/tools/publish-auto-update-v5.pl @@ -4,6 +4,7 @@ use strict; use warnings; use POSIX; use Getopt::Std; +use File::Slurp; sub usage() { die("usage: $0 [-p api-port] dev-private-key [short-commit-hash]\n"); @@ -34,6 +35,8 @@ while () { } close(POM); +my $apikey = read_file('apikey.txt'); + # Do we need to determine commit hash? unless ($commit_hash) { # determine git branch @@ -124,7 +127,7 @@ my $raw_tx = `curl --silent --url http://localhost:${port}/utils/tobase58/${raw_ die("Can't convert raw transaction hex to base58:\n$raw_tx\n") unless $raw_tx =~ m/^\w{300,320}$/; # Roughly 305 to 320 base58 chars printf "\nRaw transaction (base58):\n%s\n", $raw_tx; -my $computed_tx = `curl --silent -X POST --url http://localhost:${port}/arbitrary/compute -d "${raw_tx}"`; +my $computed_tx = `curl --silent -X POST --url http://localhost:${port}/arbitrary/compute -H "X-API-KEY: ${apikey}" -d "${raw_tx}"`; die("Can't compute nonce for transaction:\n$computed_tx\n") unless $computed_tx =~ m/^\w{300,320}$/; # Roughly 300 to 320 base58 chars printf "\nRaw computed transaction (base58):\n%s\n", $computed_tx; From 9255df46cf1d3724fa6484fe532e37d74b07a8ce Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 6 Nov 2022 19:46:12 +0000 Subject: [PATCH 052/496] Script updates to support add/remove dev group admins --- tools/approve-dev-transaction.sh | 97 ++++++++++++++++++++++++++++++++ tools/tx.pl | 7 ++- 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100755 tools/approve-dev-transaction.sh diff --git a/tools/approve-dev-transaction.sh b/tools/approve-dev-transaction.sh new file mode 100755 index 00000000..6b611b59 --- /dev/null +++ b/tools/approve-dev-transaction.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash + +port=12391 +if [ $# -gt 0 -a "$1" = "-t" ]; then + port=62391 +fi + +printf "Searching for auto-update transactions to approve...\n"; + +tx=$( curl --silent --url "http://localhost:${port}/transactions/search?txGroupId=1&txType=ADD_GROUP_ADMIN&txType=REMOVE_GROUP_ADMIN&confirmationStatus=CONFIRMED&limit=1&reverse=true" ); +if fgrep --silent '"approvalStatus":"PENDING"' <<< "${tx}"; then + true +else + echo "Can't find any pending transactions" + exit +fi + +sig=$( perl -n -e 'print $1 if m/"signature":"(\w+)"/' <<< "${tx}" ) +if [ -z "${sig}" ]; then + printf "Can't find transaction signature in JSON:\n%s\n" "${tx}" + exit +fi + +printf "Found transaction %s\n" $sig; + +printf "\nPaste your dev account private key:\n"; +IFS= +read -s privkey +printf "\n" + +# Convert to public key +pubkey=$( curl --silent --url "http://localhost:${port}/utils/publickey" --data @- <<< "${privkey}" ); +if egrep -v --silent '^\w{44,46}$' <<< "${pubkey}"; then + printf "Invalid response from API - was your private key correct?\n%s\n" "${pubkey}" + exit +fi +printf "Your public key: %s\n" ${pubkey} + +# Convert to address +address=$( curl --silent --url "http://localhost:${port}/addresses/convert/${pubkey}" ); +printf "Your address: %s\n" ${address} + +# Grab last reference +lastref=$( curl --silent --url "http://localhost:${port}/addresses/lastreference/{$address}" ); +printf "Your last reference: %s\n" ${lastref} + +# Build GROUP_APPROVAL transaction +timestamp=$( date +%s )000 +tx_json=$( cat < 0; seconds--)); do + if [ "${seconds}" = "1" ]; then + plural="" + fi + printf "\rBroadcasting in %d second%s...(CTRL-C) to abort " $seconds $plural + sleep 1 +done + +printf "\rBroadcasting signed GROUP_APPROVAL transaction... \n" +result=$( curl --silent --url "http://localhost:${port}/transactions/process" --data @- <<< "${signed_tx}" ) +printf "API response:\n%s\n" "${result}" diff --git a/tools/tx.pl b/tools/tx.pl index db6958e2..fe3cd872 100755 --- a/tools/tx.pl +++ b/tools/tx.pl @@ -71,9 +71,14 @@ our %TRANSACTION_TYPES = ( }, add_group_admin => { url => 'groups/addadmin', - required => [qw(groupId member)], + required => [qw(groupId txGroupId member)], key_name => 'ownerPublicKey', }, + remove_group_admin => { + url => 'groups/removeadmin', + required => [qw(groupId txGroupId admin)], + key_name => 'ownerPublicKey', + }, group_approval => { url => 'groups/approval', required => [qw(pendingSignature approval)], From 4e829a2d05819aa8f694e4bcff9667bc16b42ab2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 7 Nov 2022 21:12:34 +0000 Subject: [PATCH 053/496] Bump version to 3.7.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 860cdce5..52c574b0 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.6.4 + 3.7.0 jar true From b0c9ce7482d93f74b8724f392f9f103080d60956 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:19:23 +0000 Subject: [PATCH 054/496] Add blocks minted penalty to Accounts table --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 1174f5c8..33466af4 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -975,6 +975,11 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN receiving_account_info SET DATA TYPE VARBINARY(128)"); break; + case 44: + // Add blocks minted penalty + stmt.execute("ALTER TABLE Accounts ADD blocks_minted_penalty INTEGER NOT NULL DEFAULT 0"); + break; + default: // nothing to do return false; From 617c801cbd7a5790498312a8927e716e8767fc18 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:25:44 +0000 Subject: [PATCH 055/496] Made Block.ExpandedAccount public, and added some more getters. This is needed for upcoming additional validation and unit tests. --- src/main/java/org/qortal/block/Block.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 5e838458..52e3b3ef 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -136,7 +136,7 @@ public class Block { } /** Lazy-instantiated expanded info on block's online accounts. */ - private static class ExpandedAccount { + public static class ExpandedAccount { private final RewardShareData rewardShareData; private final int sharePercent; private final boolean isRecipientAlsoMinter; @@ -169,6 +169,13 @@ public class Block { } } + public Account getMintingAccount() { + return this.mintingAccount; + } + public Account getRecipientAccount() { + return this.recipientAccount; + } + /** * Returns share bin for expanded account. *

From 68a0923582f59f36b2f5f271530cddfb65124dcf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:37:02 +0000 Subject: [PATCH 056/496] Disallow level 0 minters in blocks, and exclude them when minting a new block. The validation is currently set to a feature trigger of height 0, although this will likely be set to a future block, in case there are any cases in the chain's history where this validation may fail (e.g. transfer privs?) --- src/main/java/org/qortal/block/Block.java | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 52e3b3ef..3f130359 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -370,12 +370,24 @@ public class Block { return null; } + int height = parentBlockData.getHeight() + 1; long timestamp = calcTimestamp(parentBlockData, minter.getPublicKey(), minterLevel); long onlineAccountsTimestamp = OnlineAccountsManager.getCurrentOnlineAccountTimestamp(); // Fetch our list of online accounts, removing any that are missing a nonce List onlineAccounts = OnlineAccountsManager.getInstance().getOnlineAccounts(onlineAccountsTimestamp); onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); + + // Remove any online accounts that are level 0 + onlineAccounts.removeIf(a -> { + try { + return Account.getRewardShareEffectiveMintingLevel(repository, a.getPublicKey()) == 0; + } catch (DataException e) { + // Something went wrong, so remove the account + return true; + } + }); + if (onlineAccounts.isEmpty()) { LOGGER.debug("No online accounts - not even our own?"); return null; @@ -442,7 +454,6 @@ public class Block { int transactionCount = 0; byte[] transactionsSignature = null; - int height = parentBlockData.getHeight() + 1; int atCount = 0; long atFees = 0; @@ -1036,6 +1047,15 @@ public class Block { if (onlineRewardShares == null) return ValidationResult.ONLINE_ACCOUNT_UNKNOWN; + // After feature trigger, require all online account minters to be greater than level 0 + if (this.getBlockData().getHeight() >= BlockChain.getInstance().getOnlineAccountMinterLevelValidationHeight()) { + List expandedAccounts = this.getExpandedAccounts(); + for (ExpandedAccount account : expandedAccounts) { + if (account.getMintingAccount().getEffectiveMintingLevel() == 0) + return ValidationResult.ONLINE_ACCOUNTS_INVALID; + } + } + // If block is past a certain age then we simply assume the signatures were correct long signatureRequirementThreshold = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMinLifetime(); if (this.blockData.getTimestamp() < signatureRequirementThreshold) From 1c8a6ce20436153bc10bd3c9e4f3ead2812440fe Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:52:27 +0000 Subject: [PATCH 057/496] When synchronizing, filter out peers that have a recent block with an invalid signer. This avoids the wasted time and consensus confusion causes by syncing and then validation failing. This is significant after the algo has run, as many signers will become invalid. --- .../org/qortal/controller/Controller.java | 22 +++++++++++++++++++ .../org/qortal/controller/Synchronizer.java | 3 +++ 2 files changed, 25 insertions(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index bcd010e8..f2ca853d 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -29,6 +29,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.qortal.account.Account; import org.qortal.api.ApiService; import org.qortal.api.DomainMapService; import org.qortal.api.GatewayService; @@ -756,6 +757,27 @@ public class Controller extends Thread { return peer.isAtLeastVersion(minPeerVersion) == false; }; + public static final Predicate hasInvalidSigner = peer -> { + final List peerChainTipSummaries = peer.getChainTipSummaries(); + if (peerChainTipSummaries == null) { + return true; + } + + try (Repository repository = RepositoryManager.getRepository()) { + for (BlockSummaryData blockSummaryData : peerChainTipSummaries) { + if (Account.getRewardShareEffectiveMintingLevel(repository, blockSummaryData.getMinterPublicKey()) == 0) { + return true; + } + } + } catch (DataException e) { + return true; + } + + // We got this far without encountering invalid or missing summaries, nor was an exception thrown, + // so it is safe to assume that all of this peer's recent blocks had a valid signer. + return false; + }; + private long getRandomRepositoryMaintenanceInterval() { final long minInterval = Settings.getInstance().getRepositoryMaintenanceMinInterval(); final long maxInterval = Settings.getInstance().getRepositoryMaintenanceMaxInterval(); diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index cd9483e9..e3ace9ed 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -247,6 +247,9 @@ public class Synchronizer extends Thread { // Disregard peers that are on the same block as last sync attempt and we didn't like their chain peers.removeIf(Controller.hasInferiorChainTip); + // Disregard peers that have a block with an invalid signer + peers.removeIf(Controller.hasInvalidSigner); + final int peersBeforeComparison = peers.size(); // Request recent block summaries from the remaining peers, and locate our common block with each From 9c3a4d6e371f5ba3325b326a192c67bb95a5ec6c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:55:27 +0000 Subject: [PATCH 058/496] BlockChain.java additions for onlineAccountMinterLevelValidationHeight, which were missing from commit 68a0923 --- src/main/java/org/qortal/block/BlockChain.java | 7 ++++++- src/main/resources/blockchain.json | 3 ++- src/test/resources/test-chain-v2-block-timestamps.json | 3 ++- src/test/resources/test-chain-v2-disable-reference.json | 3 ++- src/test/resources/test-chain-v2-founder-rewards.json | 3 ++- src/test/resources/test-chain-v2-leftover-reward.json | 3 ++- src/test/resources/test-chain-v2-minting.json | 3 ++- src/test/resources/test-chain-v2-qora-holder-extremes.json | 3 ++- .../resources/test-chain-v2-qora-holder-reduction.json | 3 ++- src/test/resources/test-chain-v2-qora-holder.json | 3 ++- src/test/resources/test-chain-v2-reward-levels.json | 3 ++- src/test/resources/test-chain-v2-reward-scaling.json | 3 ++- src/test/resources/test-chain-v2-reward-shares.json | 3 ++- src/test/resources/test-chain-v2.json | 3 ++- 14 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 5e1f44f3..75513e83 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -74,7 +74,8 @@ public class BlockChain { transactionV5Timestamp, transactionV6Timestamp, disableReferenceTimestamp, - increaseOnlineAccountsDifficultyTimestamp; + increaseOnlineAccountsDifficultyTimestamp, + onlineAccountMinterLevelValidationHeight, } // Custom transaction fees @@ -483,6 +484,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.increaseOnlineAccountsDifficultyTimestamp.name()).longValue(); } + public long getOnlineAccountMinterLevelValidationHeight() { + return this.featureTriggers.get(FeatureTrigger.onlineAccountMinterLevelValidationHeight.name()).intValue(); + } + // More complex getters for aspects that change by height or timestamp diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 34671c76..6189ad36 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -80,7 +80,8 @@ "transactionV5Timestamp": 1642176000000, "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 1655222400000, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 4a883bd9..c1ab9db0 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -70,7 +70,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index e8fee5e0..de653a36 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -73,7 +73,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 0, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 17a713a0..5af3b381 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index b57c3195..40310517 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index 60b3cd76..bb12e314 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 2d044687..04ecec99 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 3cf8848e..5b9ecbc4 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -75,7 +75,8 @@ "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, "aggregateSignatureTimestamp": 0, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 93965b76..86aea1b3 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index 06422e71..e39033de 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 6adcd0ac..1170a5a1 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 95324b56..550dca01 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 84c692d5..69f486fb 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, From f50c0c87ddc5e5b57508ac12bc63b429e555088a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 12:03:13 +0000 Subject: [PATCH 059/496] Account repository modifications for blocksMintedPenalty. --- .../org/qortal/data/account/AccountData.java | 14 +++- .../network/message/AccountMessage.java | 6 +- .../qortal/repository/AccountRepository.java | 23 +++-- .../hsqldb/HSQLDBAccountRepository.java | 83 ++++++++++++++++--- 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/qortal/data/account/AccountData.java b/src/main/java/org/qortal/data/account/AccountData.java index 4d662f04..868d1bc1 100644 --- a/src/main/java/org/qortal/data/account/AccountData.java +++ b/src/main/java/org/qortal/data/account/AccountData.java @@ -18,6 +18,7 @@ public class AccountData { protected int level; protected int blocksMinted; protected int blocksMintedAdjustment; + protected int blocksMintedPenalty; // Constructors @@ -25,7 +26,7 @@ public class AccountData { protected AccountData() { } - public AccountData(String address, byte[] reference, byte[] publicKey, int defaultGroupId, int flags, int level, int blocksMinted, int blocksMintedAdjustment) { + public AccountData(String address, byte[] reference, byte[] publicKey, int defaultGroupId, int flags, int level, int blocksMinted, int blocksMintedAdjustment, int blocksMintedPenalty) { this.address = address; this.reference = reference; this.publicKey = publicKey; @@ -34,10 +35,11 @@ public class AccountData { this.level = level; this.blocksMinted = blocksMinted; this.blocksMintedAdjustment = blocksMintedAdjustment; + this.blocksMintedPenalty = blocksMintedPenalty; } public AccountData(String address) { - this(address, null, null, Group.NO_GROUP, 0, 0, 0, 0); + this(address, null, null, Group.NO_GROUP, 0, 0, 0, 0, 0); } // Getters/Setters @@ -102,6 +104,14 @@ public class AccountData { this.blocksMintedAdjustment = blocksMintedAdjustment; } + public int getBlocksMintedPenalty() { + return this.blocksMintedPenalty; + } + + public void setBlocksMintedPenalty(int blocksMintedPenalty) { + this.blocksMintedPenalty = blocksMintedPenalty; + } + // Comparison @Override diff --git a/src/main/java/org/qortal/network/message/AccountMessage.java b/src/main/java/org/qortal/network/message/AccountMessage.java index d22ef879..453862b0 100644 --- a/src/main/java/org/qortal/network/message/AccountMessage.java +++ b/src/main/java/org/qortal/network/message/AccountMessage.java @@ -41,6 +41,8 @@ public class AccountMessage extends Message { bytes.write(Ints.toByteArray(accountData.getBlocksMintedAdjustment())); + bytes.write(Ints.toByteArray(accountData.getBlocksMintedPenalty())); + } catch (IOException e) { throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); } @@ -80,7 +82,9 @@ public class AccountMessage extends Message { int blocksMintedAdjustment = byteBuffer.getInt(); - AccountData accountData = new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment); + int blocksMintedPenalty = byteBuffer.getInt(); + + AccountData accountData = new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty); return new AccountMessage(id, accountData); } diff --git a/src/main/java/org/qortal/repository/AccountRepository.java b/src/main/java/org/qortal/repository/AccountRepository.java index 281f34f1..1175337c 100644 --- a/src/main/java/org/qortal/repository/AccountRepository.java +++ b/src/main/java/org/qortal/repository/AccountRepository.java @@ -1,13 +1,9 @@ package org.qortal.repository; import java.util.List; +import java.util.Set; -import org.qortal.data.account.AccountBalanceData; -import org.qortal.data.account.AccountData; -import org.qortal.data.account.EligibleQoraHolderData; -import org.qortal.data.account.MintingAccountData; -import org.qortal.data.account.QortFromQoraData; -import org.qortal.data.account.RewardShareData; +import org.qortal.data.account.*; public interface AccountRepository { @@ -19,6 +15,9 @@ public interface AccountRepository { /** Returns accounts with any bit set in given mask. */ public List getFlaggedAccounts(int mask) throws DataException; + /** Returns accounts with a blockedMintedPenalty */ + public List getPenaltyAccounts() throws DataException; + /** Returns account's last reference or null if not set or account not found. */ public byte[] getLastReference(String address) throws DataException; @@ -100,6 +99,18 @@ public interface AccountRepository { */ public void modifyMintedBlockCounts(List addresses, int delta) throws DataException; + /** Returns account's block minted penalty count or null if account not found. */ + public Integer getBlocksMintedPenaltyCount(String address) throws DataException; + + /** + * Sets blocks minted penalties for given list of accounts. + * This replaces the existing values rather than modifying them by a delta. + * + * @param accountPenalties + * @throws DataException + */ + public void updateBlocksMintedPenalties(Set accountPenalties) throws DataException; + /** Delete account from repository. */ public void delete(String address) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java index 9fdb0a3f..cb188502 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java @@ -6,15 +6,11 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import org.qortal.asset.Asset; -import org.qortal.data.account.AccountBalanceData; -import org.qortal.data.account.AccountData; -import org.qortal.data.account.EligibleQoraHolderData; -import org.qortal.data.account.MintingAccountData; -import org.qortal.data.account.QortFromQoraData; -import org.qortal.data.account.RewardShareData; +import org.qortal.data.account.*; import org.qortal.repository.AccountRepository; import org.qortal.repository.DataException; @@ -30,7 +26,7 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public AccountData getAccount(String address) throws DataException { - String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment FROM Accounts WHERE account = ?"; + String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty FROM Accounts WHERE account = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, address)) { if (resultSet == null) @@ -43,8 +39,9 @@ public class HSQLDBAccountRepository implements AccountRepository { int level = resultSet.getInt(5); int blocksMinted = resultSet.getInt(6); int blocksMintedAdjustment = resultSet.getInt(7); + int blocksMintedPenalty = resultSet.getInt(8); - return new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment); + return new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty); } catch (SQLException e) { throw new DataException("Unable to fetch account info from repository", e); } @@ -52,7 +49,7 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public List getFlaggedAccounts(int mask) throws DataException { - String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, account FROM Accounts WHERE BITAND(flags, ?) != 0"; + String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty, account FROM Accounts WHERE BITAND(flags, ?) != 0"; List accounts = new ArrayList<>(); @@ -68,9 +65,10 @@ public class HSQLDBAccountRepository implements AccountRepository { int level = resultSet.getInt(5); int blocksMinted = resultSet.getInt(6); int blocksMintedAdjustment = resultSet.getInt(7); - String address = resultSet.getString(8); + int blocksMintedPenalty = resultSet.getInt(8); + String address = resultSet.getString(9); - accounts.add(new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment)); + accounts.add(new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty)); } while (resultSet.next()); return accounts; @@ -79,6 +77,36 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public List getPenaltyAccounts() throws DataException { + String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty, account FROM Accounts WHERE blocks_minted_penalty != 0"; + + List accounts = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return accounts; + + do { + byte[] reference = resultSet.getBytes(1); + byte[] publicKey = resultSet.getBytes(2); + int defaultGroupId = resultSet.getInt(3); + int flags = resultSet.getInt(4); + int level = resultSet.getInt(5); + int blocksMinted = resultSet.getInt(6); + int blocksMintedAdjustment = resultSet.getInt(7); + int blocksMintedPenalty = resultSet.getInt(8); + String address = resultSet.getString(9); + + accounts.add(new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty)); + } while (resultSet.next()); + + return accounts; + } catch (SQLException e) { + throw new DataException("Unable to fetch penalty accounts from repository", e); + } + } + @Override public byte[] getLastReference(String address) throws DataException { String sql = "SELECT reference FROM Accounts WHERE account = ?"; @@ -298,6 +326,39 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public Integer getBlocksMintedPenaltyCount(String address) throws DataException { + String sql = "SELECT blocks_minted_penalty FROM Accounts WHERE account = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, address)) { + if (resultSet == null) + return null; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch account's block minted penalty count from repository", e); + } + } + public void updateBlocksMintedPenalties(Set accountPenalties) throws DataException { + // Nothing to do? + if (accountPenalties == null || accountPenalties.isEmpty()) + return; + + // Map balance changes into SQL bind params, filtering out no-op changes + List updateBlocksMintedPenaltyParams = accountPenalties.stream() + .map(accountPenalty -> new Object[] { accountPenalty.getAddress(), accountPenalty.getBlocksMintedPenalty(), accountPenalty.getBlocksMintedPenalty() }) + .collect(Collectors.toList()); + + // Perform actual balance changes + String sql = "INSERT INTO Accounts (account, blocks_minted_penalty) VALUES (?, ?) " + + "ON DUPLICATE KEY UPDATE blocks_minted_penalty = blocks_minted_penalty + ?"; + try { + this.repository.executeCheckedBatchUpdate(sql, updateBlocksMintedPenaltyParams); + } catch (SQLException e) { + throw new DataException("Unable to set blocks minted penalties in repository", e); + } + } + @Override public void delete(String address) throws DataException { // NOTE: Account balances are deleted automatically by the database thanks to "ON DELETE CASCADE" in AccountBalances' FOREIGN KEY From ab687af4bbb0df936175331f4cf00959ce7884ab Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 12:21:43 +0000 Subject: [PATCH 060/496] Added new db query to fetch a list of all accounts that have created a non-self-share, based on confirmed transactions. This will be used as the input dataset for the self sponsorship algo. --- .../repository/TransactionRepository.java | 9 +++++ .../HSQLDBTransactionRepository.java | 33 ++++++++++++++++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 4fb9bb12..105a317d 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -179,6 +179,15 @@ public interface TransactionRepository { public List getAssetTransfers(long assetId, String address, Integer limit, Integer offset, Boolean reverse) throws DataException; + /** + * Returns list of reward share transaction creators, excluding self shares. + * This uses confirmed transactions only. + * + * @return + * @throws DataException + */ + public List getConfirmedRewardShareCreatorsExcludingSelfShares() throws DataException; + /** * Returns list of transactions pending approval, with optional txGgroupId filtering. *

diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index e3ef13be..a8df1ab5 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -7,11 +7,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.EnumMap; -import java.util.EnumSet; -import java.util.List; -import java.util.Map; +import java.util.*; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -969,6 +965,33 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + public List getConfirmedRewardShareCreatorsExcludingSelfShares() throws DataException { + List rewardShareCreators = new ArrayList<>(); + + String sql = "SELECT account " + + "FROM RewardShareTransactions " + + "JOIN Accounts ON Accounts.public_key = RewardShareTransactions.minter_public_key " + + "JOIN Transactions ON Transactions.signature = RewardShareTransactions.signature " + + "WHERE block_height IS NOT NULL AND RewardShareTransactions.recipient != Accounts.account " + + "GROUP BY account " + + "ORDER BY account"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return rewardShareCreators; + + do { + String address = resultSet.getString(1); + + rewardShareCreators.add(address); + } while (resultSet.next()); + + return rewardShareCreators; + } catch (SQLException e) { + throw new DataException("Unable to fetch reward share creators from repository", e); + } + } + @Override public List getApprovalPendingTransactions(Integer txGroupId, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); From 7003a8274b5fed6c795ac0b4a17bb0cace0dc517 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 15:51:06 +0000 Subject: [PATCH 061/496] Added some API endpoints relating to penalties. Relies on some code not yet committed. --- .../qortal/api/model/AccountPenaltyStats.java | 56 +++++++++++++++++++ .../api/resource/AddressesResource.java | 51 +++++++++++++++++ .../data/account/AccountPenaltyData.java | 52 +++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 src/main/java/org/qortal/api/model/AccountPenaltyStats.java create mode 100644 src/main/java/org/qortal/data/account/AccountPenaltyData.java diff --git a/src/main/java/org/qortal/api/model/AccountPenaltyStats.java b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java new file mode 100644 index 00000000..68c3a6ed --- /dev/null +++ b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java @@ -0,0 +1,56 @@ +package org.qortal.api.model; + +import org.qortal.block.SelfSponsorshipAlgoV1Block; +import org.qortal.data.account.AccountData; +import org.qortal.data.naming.NameData; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import java.util.ArrayList; +import java.util.List; + +@XmlAccessorType(XmlAccessType.FIELD) +public class AccountPenaltyStats { + + public int totalPenalties; + public int maxPenalty; + public int minPenalty; + public String penaltyHash; + + protected AccountPenaltyStats() { + } + + public AccountPenaltyStats(int totalPenalties, int maxPenalty, int minPenalty, String penaltyHash) { + this.totalPenalties = totalPenalties; + this.maxPenalty = maxPenalty; + this.minPenalty = minPenalty; + this.penaltyHash = penaltyHash; + } + + public static AccountPenaltyStats fromAccounts(List accounts) { + int totalPenalties = 0; + Integer maxPenalty = null; + Integer minPenalty = null; + + List addresses = new ArrayList<>(); + for (AccountData accountData : accounts) { + int penalty = accountData.getBlocksMintedPenalty(); + addresses.add(accountData.getAddress()); + totalPenalties++; + + // Penalties are expressed as a negative number, so the min and the max are reversed here + if (maxPenalty == null || penalty < maxPenalty) maxPenalty = penalty; + if (minPenalty == null || penalty > minPenalty) minPenalty = penalty; + } + + String penaltyHash = SelfSponsorshipAlgoV1Block.getHash(addresses); + return new AccountPenaltyStats(totalPenalties, maxPenalty, minPenalty, penaltyHash); + } + + + @Override + public String toString() { + return String.format("totalPenalties: %d, maxPenalty: %d, minPenalty: %d, penaltyHash: %s", totalPenalties, maxPenalty, minPenalty, penaltyHash == null ? "null" : penaltyHash); + } +} diff --git a/src/main/java/org/qortal/api/resource/AddressesResource.java b/src/main/java/org/qortal/api/resource/AddressesResource.java index 468b90a8..79cb6e05 100644 --- a/src/main/java/org/qortal/api/resource/AddressesResource.java +++ b/src/main/java/org/qortal/api/resource/AddressesResource.java @@ -14,6 +14,7 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.*; @@ -27,6 +28,7 @@ import org.qortal.api.ApiErrors; import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; +import org.qortal.api.model.AccountPenaltyStats; import org.qortal.api.model.ApiOnlineAccount; import org.qortal.api.model.RewardShareKeyRequest; import org.qortal.asset.Asset; @@ -34,6 +36,7 @@ import org.qortal.controller.LiteNode; import org.qortal.controller.OnlineAccountsManager; import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountData; +import org.qortal.data.account.AccountPenaltyData; import org.qortal.data.account.RewardShareData; import org.qortal.data.network.OnlineAccountData; import org.qortal.data.network.OnlineAccountLevel; @@ -471,6 +474,54 @@ public class AddressesResource { } } + @GET + @Path("/penalties") + @Operation( + summary = "Get addresses with penalties", + description = "Returns a list of accounts with a blocksMintedPenalty", + responses = { + @ApiResponse( + description = "accounts with penalties", + content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = AccountPenaltyData.class))) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public List getAccountsWithPenalties() { + try (final Repository repository = RepositoryManager.getRepository()) { + + List accounts = repository.getAccountRepository().getPenaltyAccounts(); + List penalties = accounts.stream().map(a -> new AccountPenaltyData(a.getAddress(), a.getBlocksMintedPenalty())).collect(Collectors.toList()); + + return penalties; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/penalties/stats") + @Operation( + summary = "Get stats about current penalties", + responses = { + @ApiResponse( + description = "aggregated stats about accounts with penalties", + content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = AccountPenaltyStats.class))) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public AccountPenaltyStats getPenaltyStats() { + try (final Repository repository = RepositoryManager.getRepository()) { + + List accounts = repository.getAccountRepository().getPenaltyAccounts(); + return AccountPenaltyStats.fromAccounts(accounts); + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @POST @Path("/publicize") @Operation( diff --git a/src/main/java/org/qortal/data/account/AccountPenaltyData.java b/src/main/java/org/qortal/data/account/AccountPenaltyData.java new file mode 100644 index 00000000..61947a5f --- /dev/null +++ b/src/main/java/org/qortal/data/account/AccountPenaltyData.java @@ -0,0 +1,52 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class AccountPenaltyData { + + // Properties + private String address; + private int blocksMintedPenalty; + + // Constructors + + // necessary for JAXB + protected AccountPenaltyData() { + } + + public AccountPenaltyData(String address, int blocksMintedPenalty) { + this.address = address; + this.blocksMintedPenalty = blocksMintedPenalty; + } + + // Getters/Setters + + public String getAddress() { + return this.address; + } + + public int getBlocksMintedPenalty() { + return this.blocksMintedPenalty; + } + + public String toString() { + return String.format("%s has penalty %d", this.address, this.blocksMintedPenalty); + } + + @Override + public boolean equals(Object b) { + if (!(b instanceof AccountPenaltyData)) + return false; + + return this.getAddress().equals(((AccountPenaltyData) b).getAddress()); + } + + @Override + public int hashCode() { + return address.hashCode(); + } + +} From 58e5d325ff3d55c73bf0c3cc5c171961ade4fead Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 16:10:18 +0000 Subject: [PATCH 062/496] Added algo feature triggers to BlockChain.java (at future undecided block height & timestamp) --- src/main/java/org/qortal/block/BlockChain.java | 13 +++++++++++++ src/main/resources/blockchain.json | 2 ++ .../resources/test-chain-v2-block-timestamps.json | 2 ++ .../resources/test-chain-v2-disable-reference.json | 2 ++ .../resources/test-chain-v2-founder-rewards.json | 2 ++ .../resources/test-chain-v2-leftover-reward.json | 2 ++ src/test/resources/test-chain-v2-minting.json | 2 ++ .../test-chain-v2-qora-holder-extremes.json | 2 ++ .../test-chain-v2-qora-holder-reduction.json | 2 ++ src/test/resources/test-chain-v2-qora-holder.json | 2 ++ src/test/resources/test-chain-v2-reward-levels.json | 2 ++ .../resources/test-chain-v2-reward-scaling.json | 2 ++ src/test/resources/test-chain-v2-reward-shares.json | 2 ++ src/test/resources/test-chain-v2.json | 2 ++ 14 files changed, 39 insertions(+) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 75513e83..6182bd1d 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -76,6 +76,7 @@ public class BlockChain { disableReferenceTimestamp, increaseOnlineAccountsDifficultyTimestamp, onlineAccountMinterLevelValidationHeight, + selfSponsorshipAlgoV1Height; } // Custom transaction fees @@ -197,6 +198,9 @@ public class BlockChain { * featureTriggers because unit tests need to set this value via Reflection. */ private long onlineAccountsModulusV2Timestamp; + /** Snapshot timestamp for self sponsorship algo V1 */ + private long selfSponsorshipAlgoV1SnapshotTimestamp; + /** Max reward shares by block height */ public static class MaxRewardSharesByTimestamp { public long timestamp; @@ -357,6 +361,11 @@ public class BlockChain { return this.onlineAccountsModulusV2Timestamp; } + // Self sponsorship algo + public long getSelfSponsorshipAlgoV1SnapshotTimestamp() { + return this.selfSponsorshipAlgoV1SnapshotTimestamp; + } + /** Returns true if approval-needing transaction types require a txGroupId other than NO_GROUP. */ public boolean getRequireGroupForApproval() { return this.requireGroupForApproval; @@ -484,6 +493,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.increaseOnlineAccountsDifficultyTimestamp.name()).longValue(); } + public int getSelfSponsorshipAlgoV1Height() { + return this.featureTriggers.get(FeatureTrigger.selfSponsorshipAlgoV1Height.name()).intValue(); + } + public long getOnlineAccountMinterLevelValidationHeight() { return this.featureTriggers.get(FeatureTrigger.onlineAccountMinterLevelValidationHeight.name()).intValue(); } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 6189ad36..6f60e505 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -24,6 +24,7 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, @@ -82,6 +83,7 @@ "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index c1ab9db0..59d8b273 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -14,6 +14,7 @@ "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -72,6 +73,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index de653a36..3dacf6f1 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -18,6 +18,7 @@ "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -75,6 +76,7 @@ "disableReferenceTimestamp": 0, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 5af3b381..092f51da 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index 40310517..a60e2692 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index bb12e314..ec1dc979 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 04ecec99..d76a4dd1 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 5b9ecbc4..7e57fd46 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -77,6 +78,7 @@ "aggregateSignatureTimestamp": 0, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 86aea1b3..7c7ccf5c 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index e39033de..1aeee763 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 1170a5a1..3c115b8c 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 550dca01..5ba16774 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -18,6 +18,7 @@ "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 69f486fb..40f6c492 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, From 5f0263c0783ddd9d34d56e7ebe1093f7f9a09a3a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 16:14:45 +0000 Subject: [PATCH 063/496] Modifications to block minting for unit tests, in order to solve an NPE and give more options to callers. This shouldn't affect the behaviour of existing tests, other than an NPE being replaced with an assertNotNull(). --- .../org/qortal/controller/BlockMinter.java | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 7e3b4b9e..e2d01147 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -26,9 +26,6 @@ import org.qortal.data.block.CommonBlockData; import org.qortal.data.transaction.TransactionData; import org.qortal.network.Network; import org.qortal.network.Peer; -import org.qortal.network.message.BlockSummariesV2Message; -import org.qortal.network.message.HeightV2Message; -import org.qortal.network.message.Message; import org.qortal.repository.BlockRepository; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -38,6 +35,8 @@ import org.qortal.transaction.Transaction; import org.qortal.utils.Base58; import org.qortal.utils.NTP; +import static org.junit.Assert.assertNotNull; + // Minting new blocks public class BlockMinter extends Thread { @@ -511,6 +510,21 @@ public class BlockMinter extends Thread { PrivateKeyAccount mintingAccount = mintingAndOnlineAccounts[0]; + Block block = mintTestingBlockRetainingTimestamps(repository, mintingAccount); + assertNotNull("Minted block must not be null", block); + + return block; + } + + public static Block mintTestingBlockUnvalidated(Repository repository, PrivateKeyAccount... mintingAndOnlineAccounts) throws DataException { + if (!BlockChain.getInstance().isTestChain()) + throw new DataException("Ignoring attempt to mint testing block for non-test chain!"); + + // Ensure mintingAccount is 'online' so blocks can be minted + OnlineAccountsManager.getInstance().ensureTestingAccountsOnline(mintingAndOnlineAccounts); + + PrivateKeyAccount mintingAccount = mintingAndOnlineAccounts[0]; + return mintTestingBlockRetainingTimestamps(repository, mintingAccount); } @@ -518,6 +532,8 @@ public class BlockMinter extends Thread { BlockData previousBlockData = repository.getBlockRepository().getLastBlock(); Block newBlock = Block.mint(repository, previousBlockData, mintingAccount); + if (newBlock == null) + return null; // Make sure we're the only thread modifying the blockchain ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); From 6ea3c0e6f78bc3539db766cb1aaba7478fa8179c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 16:19:43 +0000 Subject: [PATCH 064/496] Give founder accounts as an effective minting level of 0 if they have a penalty. --- src/main/java/org/qortal/account/Account.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java index c3a25fb6..2b23f91b 100644 --- a/src/main/java/org/qortal/account/Account.java +++ b/src/main/java/org/qortal/account/Account.java @@ -211,7 +211,8 @@ public class Account { if (level != null && level >= BlockChain.getInstance().getMinAccountLevelToMint()) return true; - if (Account.isFounder(accountData.getFlags())) + // Founders can always mint, unless they have a penalty + if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0) return true; return false; @@ -243,7 +244,7 @@ public class Account { if (level != null && level >= BlockChain.getInstance().getMinAccountLevelToRewardShare()) return true; - if (Account.isFounder(accountData.getFlags())) + if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0) return true; return false; @@ -271,7 +272,7 @@ public class Account { /** * Returns 'effective' minting level, or zero if account does not exist/cannot mint. *

- * For founder accounts, this returns "founderEffectiveMintingLevel" from blockchain config. + * For founder accounts with no penalty, this returns "founderEffectiveMintingLevel" from blockchain config. * * @return 0+ * @throws DataException @@ -281,7 +282,8 @@ public class Account { if (accountData == null) return 0; - if (Account.isFounder(accountData.getFlags())) + // Founders are assigned a different effective minting level, as long as they have no penalty + if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0) return BlockChain.getInstance().getFounderEffectiveMintingLevel(); return accountData.getLevel(); From 41cdf665ed9d14c96530143960f785bad16d8436 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 16:53:38 +0000 Subject: [PATCH 065/496] Code tidy --- src/main/java/org/qortal/account/Account.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java index 2b23f91b..6e2fff65 100644 --- a/src/main/java/org/qortal/account/Account.java +++ b/src/main/java/org/qortal/account/Account.java @@ -291,8 +291,6 @@ public class Account { /** * Returns 'effective' minting level, or zero if reward-share does not exist. - *

- * this is being used on src/main/java/org/qortal/api/resource/AddressesResource.java to fulfil the online accounts api call * * @param repository * @param rewardSharePublicKey @@ -311,7 +309,7 @@ public class Account { /** * Returns 'effective' minting level, with a fix for the zero level. *

- * For founder accounts, this returns "founderEffectiveMintingLevel" from blockchain config. + * For founder accounts with no penalty, this returns "founderEffectiveMintingLevel" from blockchain config. * * @param repository * @param rewardSharePublicKey @@ -324,7 +322,7 @@ public class Account { if (rewardShareData == null) return 0; - else if(!rewardShareData.getMinter().equals(rewardShareData.getRecipient()))//the minter is different than the recipient this means sponsorship + else if (!rewardShareData.getMinter().equals(rewardShareData.getRecipient())) // Sponsorship reward share return 0; Account rewardShareMinter = new Account(repository, rewardShareData.getMinter()); From a75fd14e4548fba63eaa40909940ba5662d5e424 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 17:23:43 +0000 Subject: [PATCH 066/496] Added Account method needed for unit tests. --- src/main/java/org/qortal/account/Account.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java index 6e2fff65..2c75dbc0 100644 --- a/src/main/java/org/qortal/account/Account.java +++ b/src/main/java/org/qortal/account/Account.java @@ -223,6 +223,11 @@ public class Account { return this.repository.getAccountRepository().getMintedBlockCount(this.address); } + /** Returns account's blockMintedPenalty or null if account not found in repository. */ + public Integer getBlocksMintedPenalty() throws DataException { + return this.repository.getAccountRepository().getBlocksMintedPenaltyCount(this.address); + } + /** Returns whether account can build reward-shares. *

From 3965f24ab564753fc568704bf46689e9f6adf525 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 19:06:02 +0000 Subject: [PATCH 067/496] Fixed bug --- .../java/org/qortal/api/model/AccountPenaltyStats.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/api/model/AccountPenaltyStats.java b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java index 68c3a6ed..aafe25fc 100644 --- a/src/main/java/org/qortal/api/model/AccountPenaltyStats.java +++ b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java @@ -13,15 +13,15 @@ import java.util.List; @XmlAccessorType(XmlAccessType.FIELD) public class AccountPenaltyStats { - public int totalPenalties; - public int maxPenalty; - public int minPenalty; + public Integer totalPenalties; + public Integer maxPenalty; + public Integer minPenalty; public String penaltyHash; protected AccountPenaltyStats() { } - public AccountPenaltyStats(int totalPenalties, int maxPenalty, int minPenalty, String penaltyHash) { + public AccountPenaltyStats(Integer totalPenalties, Integer maxPenalty, Integer minPenalty, String penaltyHash) { this.totalPenalties = totalPenalties; this.maxPenalty = maxPenalty; this.minPenalty = minPenalty; From 76686eca21df3b9e1e43eb6fde76116321240518 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 27 Nov 2022 12:23:17 +0000 Subject: [PATCH 068/496] Modified reward share creation in test/common/AccountUtils to allow a fee to be specified. --- src/test/java/org/qortal/test/common/AccountUtils.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/qortal/test/common/AccountUtils.java b/src/test/java/org/qortal/test/common/AccountUtils.java index 0d8baae2..c31cd85e 100644 --- a/src/test/java/org/qortal/test/common/AccountUtils.java +++ b/src/test/java/org/qortal/test/common/AccountUtils.java @@ -49,10 +49,10 @@ public class AccountUtils { public static TransactionData createRewardShare(Repository repository, String minter, String recipient, int sharePercent) throws DataException { PrivateKeyAccount mintingAccount = Common.getTestAccount(repository, minter); PrivateKeyAccount recipientAccount = Common.getTestAccount(repository, recipient); - return createRewardShare(repository, mintingAccount, recipientAccount, sharePercent); + return createRewardShare(repository, mintingAccount, recipientAccount, sharePercent, fee); } - public static TransactionData createRewardShare(Repository repository, PrivateKeyAccount mintingAccount, PrivateKeyAccount recipientAccount, int sharePercent) throws DataException { + public static TransactionData createRewardShare(Repository repository, PrivateKeyAccount mintingAccount, PrivateKeyAccount recipientAccount, int sharePercent, long fee) throws DataException { byte[] reference = mintingAccount.getLastReference(); long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1; @@ -78,7 +78,7 @@ public class AccountUtils { } public static byte[] rewardShare(Repository repository, PrivateKeyAccount minterAccount, PrivateKeyAccount recipientAccount, int sharePercent) throws DataException { - TransactionData transactionData = createRewardShare(repository, minterAccount, recipientAccount, sharePercent); + TransactionData transactionData = createRewardShare(repository, minterAccount, recipientAccount, sharePercent, fee); TransactionUtils.signAndMint(repository, transactionData, minterAccount); byte[] rewardSharePrivateKey = minterAccount.getRewardSharePrivateKey(recipientAccount.getPublicKey()); From 5ff7b3df6d68e011a3e1372cc5cf9c5e0cf610c3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 27 Nov 2022 19:59:46 +0000 Subject: [PATCH 069/496] hasInvalidSigner() now only checks the chain tip block, to reduce the amount of unintended side effects that can occur. --- .../java/org/qortal/controller/Controller.java | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index f2ca853d..0a323cb2 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -758,24 +758,15 @@ public class Controller extends Thread { }; public static final Predicate hasInvalidSigner = peer -> { - final List peerChainTipSummaries = peer.getChainTipSummaries(); - if (peerChainTipSummaries == null) { + final BlockSummaryData peerChainTipData = peer.getChainTipData(); + if (peerChainTipData == null) return true; - } try (Repository repository = RepositoryManager.getRepository()) { - for (BlockSummaryData blockSummaryData : peerChainTipSummaries) { - if (Account.getRewardShareEffectiveMintingLevel(repository, blockSummaryData.getMinterPublicKey()) == 0) { - return true; - } - } + return Account.getRewardShareEffectiveMintingLevel(repository, peerChainTipData.getMinterPublicKey()) == 0; } catch (DataException e) { return true; } - - // We got this far without encountering invalid or missing summaries, nor was an exception thrown, - // so it is safe to assume that all of this peer's recent blocks had a valid signer. - return false; }; private long getRandomRepositoryMaintenanceInterval() { From ae991dda4d997892a9857bb7ccfee753e131e90c Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 28 Nov 2022 21:52:37 +0000 Subject: [PATCH 070/496] Fix creatorPublicKey not being unmarshaled when calling POST /at to deploy an AT --- .../data/transaction/DeployAtTransactionData.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java b/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java index 7a2ebdab..fed69cd5 100644 --- a/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java @@ -2,6 +2,7 @@ package org.qortal.data.transaction; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import org.qortal.transaction.Transaction.TransactionType; @@ -90,4 +91,17 @@ public class DeployAtTransactionData extends TransactionData { this.aTAddress = AtAddress; } + // Re-expose creatorPublicKey for this transaction type for JAXB + @XmlElement(name = "creatorPublicKey") + @Schema(name = "creatorPublicKey", description = "AT creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + public byte[] getAtCreatorPublicKey() { + return this.creatorPublicKey; + } + + @XmlElement(name = "creatorPublicKey") + @Schema(name = "creatorPublicKey", description = "AT creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + public void setAtCreatorPublicKey(byte[] creatorPublicKey) { + this.creatorPublicKey = creatorPublicKey; + } + } From 99ba4caf7578338de4c7728ace6261aab661818f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 11:50:58 +0000 Subject: [PATCH 071/496] We definitely can't retroactively validate minter levels, because there are confirmed cases in the chains history where this fails. Set to 999999999 until we have decided on a future block height. --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 6f60e505..126ec7ae 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -82,7 +82,7 @@ "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, - "onlineAccountMinterLevelValidationHeight": 0, + "onlineAccountMinterLevelValidationHeight": 999999999, "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { From f14cc374c6bcd4bdf43f99cde865ce48841487de Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 11:52:51 +0000 Subject: [PATCH 072/496] Include blocksMintedPenalty in effectiveBlocksMinted. This will be zero until the algo runs, so doesn't need a feature trigger. --- src/main/java/org/qortal/block/Block.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 3f130359..bbd62dd3 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1523,7 +1523,7 @@ public class Block { accountData.setBlocksMinted(accountData.getBlocksMinted() + 1); LOGGER.trace(() -> String.format("Block minter %s up to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : ""))); - final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment(); + final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty(); for (int newLevel = maximumLevel; newLevel >= 0; --newLevel) if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) { @@ -1824,7 +1824,7 @@ public class Block { accountData.setBlocksMinted(accountData.getBlocksMinted() - 1); LOGGER.trace(() -> String.format("Block minter %s down to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : ""))); - final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment(); + final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty(); for (int newLevel = maximumLevel; newLevel >= 0; --newLevel) if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) { From f4d20e42f3075d9b3768a8aa697fccdedd93d34b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 11:54:05 +0000 Subject: [PATCH 073/496] Disallow TRANSFER_PRIVS transactions if the sending account has a penalty. Again, there will be no penalties until the algo runs, so it's safe without a feature trigger. --- src/main/java/org/qortal/transaction/Transaction.java | 1 + .../org/qortal/transaction/TransferPrivsTransaction.java | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index 203cc342..f0e9b3f6 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -245,6 +245,7 @@ public abstract class Transaction { ADDRESS_BLOCKED(96), NAME_BLOCKED(97), GROUP_APPROVAL_REQUIRED(98), + ACCOUNT_NOT_TRANSFERABLE(99), INVALID_BUT_OK(999), NOT_YET_RELEASED(1000); diff --git a/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java b/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java index f6a9de68..97e67160 100644 --- a/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java +++ b/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java @@ -67,6 +67,11 @@ public class TransferPrivsTransaction extends Transaction { if (getSender().getConfirmedBalance(Asset.QORT) < this.transferPrivsTransactionData.getFee()) return ValidationResult.NO_BALANCE; + // Check sender doesn't have a blocksMintedPenalty, as these accounts cannot be transferred + AccountData senderAccountData = this.repository.getAccountRepository().getAccount(getSender().getAddress()); + if (senderAccountData == null || senderAccountData.getBlocksMintedPenalty() != 0) + return ValidationResult.ACCOUNT_NOT_TRANSFERABLE; + return ValidationResult.OK; } From eea42b56eecd2a73a0e980c85e39d2cc66f2873d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 18:21:01 +0000 Subject: [PATCH 074/496] Added SelfSponsorshipAlgoV1Block, and call it when processing/orphaning a block at an undecided future height. --- src/main/java/org/qortal/block/Block.java | 6 + .../block/SelfSponsorshipAlgoV1Block.java | 133 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index bbd62dd3..a31c522b 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1461,6 +1461,9 @@ public class Block { if (this.blockData.getHeight() == 212937) // Apply fix for block 212937 Block212937.processFix(this); + + else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) + SelfSponsorshipAlgoV1Block.processAccountPenalties(this); } // We're about to (test-)process a batch of transactions, @@ -1696,6 +1699,9 @@ public class Block { // Revert fix for block 212937 Block212937.orphanFix(this); + else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) + SelfSponsorshipAlgoV1Block.orphanAccountPenalties(this); + // Block rewards, including transaction fees, removed after transactions undone orphanBlockRewards(); diff --git a/src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java b/src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java new file mode 100644 index 00000000..a9a016b6 --- /dev/null +++ b/src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java @@ -0,0 +1,133 @@ +package org.qortal.block; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.account.SelfSponsorshipAlgoV1; +import org.qortal.api.model.AccountPenaltyStats; +import org.qortal.crypto.Crypto; +import org.qortal.data.account.AccountData; +import org.qortal.data.account.AccountPenaltyData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.utils.Base58; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Self Sponsorship AlgoV1 Block + *

* NOTE: performs implicit repository.saveChanges(). */ - public void rebuildLatestAtStates() throws DataException; + public void rebuildLatestAtStates(int maxHeight) throws DataException; /** Returns height of first trimmable AT state. */ diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 04823925..dd0404a8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -603,7 +603,7 @@ public class HSQLDBATRepository implements ATRepository { @Override - public void rebuildLatestAtStates() throws DataException { + public void rebuildLatestAtStates(int maxHeight) throws DataException { // latestATStatesLock is to prevent concurrent updates on LatestATStates // that could result in one process using a partial or empty dataset // because it was in the process of being rebuilt by another thread @@ -624,11 +624,12 @@ public class HSQLDBATRepository implements ATRepository { + "CROSS JOIN LATERAL(" + "SELECT height FROM ATStates " + "WHERE ATStates.AT_address = ATs.AT_address " + + "AND height <= ?" + "ORDER BY AT_address DESC, height DESC LIMIT 1" + ") " + ")"; try { - this.repository.executeCheckedUpdate(insertSql); + this.repository.executeCheckedUpdate(insertSql, maxHeight); } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to populate temporary latest AT states cache in repository", e); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 978ba25e..e2bfc9ef 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -99,7 +99,7 @@ public class HSQLDBDatabasePruning { // It's essential that we rebuild the latest AT states here, as we are using this data in the next query. // Failing to do this will result in important AT states being deleted, rendering the database unusable. - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(endHeight); // Loop through all the LatestATStates and copy them to the new table diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java index 3bfa4e84..8b3de67b 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -23,7 +23,6 @@ import org.qortal.transform.TransformationException; import org.qortal.transform.block.BlockTransformation; import org.qortal.utils.BlockArchiveUtils; import org.qortal.utils.NTP; -import org.qortal.utils.Triple; import java.io.File; import java.io.IOException; @@ -314,9 +313,10 @@ public class BlockArchiveTests extends Common { repository.getBlockRepository().setBlockPruneHeight(901); // Prune the AT states for the archived blocks - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(900); + repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 900); - assertEquals(900-1, numATStatesPruned); + assertEquals(900-2, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state repository.getATRepository().setAtPruneHeight(901); // Now ensure the SQL repository is missing blocks 2 and 900... @@ -563,16 +563,23 @@ public class BlockArchiveTests extends Common { // Trim the first 500 blocks repository.getBlockRepository().trimOldOnlineAccountsSignatures(0, 500); repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(501); + repository.getATRepository().rebuildLatestAtStates(500); repository.getATRepository().trimAtStates(0, 500, 1000); repository.getATRepository().setAtTrimHeight(501); - // Now block 500 should only have the AT state data hash - block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); - atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + // Now block 499 should only have the AT state data hash + List block499AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(499); + atStatesData = repository.getATRepository().getATStateAtHeight(block499AtStatesData.get(0).getATAddress(), 499); assertNotNull(atStatesData.getStateHash()); assertNull(atStatesData.getStateData()); - // ... but block 501 should have the full data + // ... but block 500 should have the full data (due to being retained as the "latest" AT state in the trimmed range + block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // ... and block 501 should also have the full data List block501AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(501); atStatesData = repository.getATRepository().getATStateAtHeight(block501AtStatesData.get(0).getATAddress(), 501); assertNotNull(atStatesData.getStateHash()); @@ -612,9 +619,10 @@ public class BlockArchiveTests extends Common { repository.getBlockRepository().setBlockPruneHeight(501); // Prune the AT states for the archived blocks - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(500); + repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(2, 500); - assertEquals(499, numATStatesPruned); + assertEquals(498, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state repository.getATRepository().setAtPruneHeight(501); // Now ensure the SQL repository is missing blocks 2 and 500... diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java index aa641e71..b60b412c 100644 --- a/src/test/java/org/qortal/test/BootstrapTests.java +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -176,7 +176,8 @@ public class BootstrapTests extends Common { repository.getBlockRepository().setBlockPruneHeight(901); // Prune the AT states for the archived blocks - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(900); + repository.saveChanges(); repository.getATRepository().pruneAtStates(0, 900); repository.getATRepository().setAtPruneHeight(901); diff --git a/src/test/java/org/qortal/test/PruneTests.java b/src/test/java/org/qortal/test/PruneTests.java index 0914d794..5a31146e 100644 --- a/src/test/java/org/qortal/test/PruneTests.java +++ b/src/test/java/org/qortal/test/PruneTests.java @@ -1,16 +1,33 @@ package org.qortal.test; +import com.google.common.hash.HashCode; import org.junit.Before; import org.junit.Test; +import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.block.Block; import org.qortal.controller.BlockMinter; +import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.LitecoinACCTv3; +import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +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; import org.qortal.test.common.AtUtils; +import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; import java.util.ArrayList; import java.util.List; @@ -19,6 +36,13 @@ import static org.junit.Assert.*; public class PruneTests extends Common { + // Constants for test AT (an LTC ACCT) + public static final byte[] litecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); + public static final int tradeTimeout = 20; // blocks + public static final long redeemAmount = 80_40200000L; + public static final long fundingAmount = 123_45600000L; + public static final long litecoinAmount = 864200L; // 0.00864200 LTC + @Before public void beforeTest() throws DataException { Common.useDefaultSettings(); @@ -62,23 +86,32 @@ public class PruneTests extends Common { repository.getBlockRepository().setBlockPruneHeight(6); // Prune AT states for blocks 2-5 + repository.getATRepository().rebuildLatestAtStates(5); + repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 5); - assertEquals(4, numATStatesPruned); + assertEquals(3, numATStatesPruned); repository.getATRepository().setAtPruneHeight(6); - // Make sure that blocks 2-5 are now missing block data and AT states data - for (Integer i=2; i <= 5; i++) { + // Make sure that blocks 2-4 are now missing block data and AT states data + for (Integer i=2; i <= 4; i++) { BlockData blockData = repository.getBlockRepository().fromHeight(i); assertNull(blockData); List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); assertTrue(atStatesDataList.isEmpty()); } - // ... but blocks 6-10 have block data and full AT states data + // Block 5 should have full AT states data even though it was pruned. + // This is because we identified that as the "latest" AT state in that block range + BlockData blockData = repository.getBlockRepository().fromHeight(5); + assertNull(blockData); + List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(5); + assertEquals(1, atStatesDataList.size()); + + // Blocks 6-10 have block data and full AT states data for (Integer i=6; i <= 10; i++) { - BlockData blockData = repository.getBlockRepository().fromHeight(i); + blockData = repository.getBlockRepository().fromHeight(i); assertNotNull(blockData.getSignature()); - List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); + atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); assertNotNull(atStatesDataList); assertFalse(atStatesDataList.isEmpty()); ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(atStatesDataList.get(0).getATAddress(), i); @@ -88,4 +121,102 @@ public class PruneTests extends Common { } } + @Test + public void testPruneSleepingAt() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = Common.getTestAccount(repository, "alice"); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + // Mint enough blocks to take the original DEPLOY_AT past the prune threshold (in this case 20) + Block block = BlockUtils.mintBlocks(repository, 25); + + // Send creator's address to AT, instead of typical partner's address + byte[] messageData = LitecoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); + long txTimestamp = block.getBlockData().getTimestamp(); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress, txTimestamp); + + // AT should process 'cancel' message in next block + BlockUtils.mintBlock(repository); + + // Prune AT states up to block 20 + repository.getATRepository().rebuildLatestAtStates(20); + repository.saveChanges(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 20); + assertEquals(1, numATStatesPruned); // deleted state at heights 2, but state at height 3 remains + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + + // Test orphaning - should be possible because the previous AT state at height 3 is still available + BlockUtils.orphanLastBlock(repository); + } + } + + + // Helper methods for AT testing + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = LitecoinACCTv3.buildQortalAT(tradeAddress, litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-LTC cross-chain trade"; + String description = String.format("Qortal-Litecoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-LTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient, long txTimestamp) throws DataException { + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } } diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index 8ef4c774..8441731f 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -2,29 +2,20 @@ package org.qortal.test.at; import static org.junit.Assert.*; -import java.nio.ByteBuffer; import java.util.List; -import org.ciyam.at.CompilationException; import org.ciyam.at.MachineState; -import org.ciyam.at.OpCode; import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -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; import org.qortal.test.common.AtUtils; import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; import org.qortal.transaction.DeployAtTransaction; public class AtRepositoryTests extends Common { @@ -76,7 +67,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxHeight); repository.getATRepository().trimAtStates(2, maxHeight, 1000); ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); @@ -130,7 +121,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = blockchainHeight; // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxHeight); // COMMIT to check latest AT states persist / TEMPORARY table interaction repository.saveChanges(); @@ -163,8 +154,8 @@ public class AtRepositoryTests extends Common { int maxTrimHeight = blockchainHeight - 4; Integer testHeight = maxTrimHeight + 1; - // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + // Trim AT state data (using a max height of maxTrimHeight + 1, so it is beyond the trimmed range) + repository.getATRepository().rebuildLatestAtStates(maxTrimHeight + 1); repository.saveChanges(); repository.getATRepository().trimAtStates(2, maxTrimHeight, 1000); @@ -333,7 +324,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxHeight); repository.getATRepository().trimAtStates(2, maxHeight, 1000); List atStates = repository.getATRepository().getBlockATStatesAtHeight(testHeight); diff --git a/src/test/java/org/qortal/test/common/BlockUtils.java b/src/test/java/org/qortal/test/common/BlockUtils.java index 3077b65b..ab57dadf 100644 --- a/src/test/java/org/qortal/test/common/BlockUtils.java +++ b/src/test/java/org/qortal/test/common/BlockUtils.java @@ -20,6 +20,15 @@ public class BlockUtils { return BlockMinter.mintTestingBlock(repository, mintingAccount); } + /** Mints multiple blocks using "alice-reward-share" test account, and returns the final block. */ + public static Block mintBlocks(Repository repository, int count) throws DataException { + Block block = null; + for (int i=0; i Date: Sun, 15 Jan 2023 15:51:10 +0000 Subject: [PATCH 135/496] Fixed bug causing initial latestATStates data to be discarded. --- .../java/org/qortal/controller/repository/AtStatesPruner.java | 1 + .../java/org/qortal/controller/repository/AtStatesTrimmer.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index 1faeda98..f06efdb8 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -43,6 +43,7 @@ public class AtStatesPruner implements Runnable { repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index ea56699c..125628f1 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -30,6 +30,7 @@ public class AtStatesTrimmer implements Runnable { repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); From 4c52d6f0fcf1205c7bc7a47faca83845bbf4216c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 15:51:10 +0000 Subject: [PATCH 136/496] Fixed bug causing initial latestATStates data to be discarded. --- .../java/org/qortal/controller/repository/AtStatesPruner.java | 1 + .../java/org/qortal/controller/repository/AtStatesTrimmer.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index bd12f784..064fe0ea 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -42,6 +42,7 @@ public class AtStatesPruner implements Runnable { repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index 69fa347c..6c026385 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -29,6 +29,7 @@ public class AtStatesTrimmer implements Runnable { repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); From 81cf46f5dd3102c1159717a381d2ff42ee44a993 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 16 Jan 2023 20:18:23 +0000 Subject: [PATCH 137/496] Disable block signing on topOnly nodes. Minting rewards are still earned on topOnly for now. --- src/main/java/org/qortal/controller/BlockMinter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index e2d01147..185dd7cd 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -63,8 +63,8 @@ public class BlockMinter extends Thread { public void run() { Thread.currentThread().setName("BlockMinter"); - if (Settings.getInstance().isLite()) { - // Lite nodes do not mint + if (Settings.getInstance().isTopOnly() || Settings.getInstance().isLite()) { + // Top only and lite nodes do not sign blocks return; } if (Settings.getInstance().getWipeUnconfirmedOnStart()) { From 688acd466c902a219afd92eb78b941a94a0acec6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 16 Jan 2023 20:23:43 +0000 Subject: [PATCH 138/496] Set checkpoint to block 1136300 --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index f48958eb..46b4b4f9 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -88,7 +88,7 @@ "chatReferenceTimestamp": 1674316800000 }, "checkpoints": [ - { "height": 1131800, "signature": "EpRam4PLdKzULMp7xNU7XG964AKfioG3g1k7cxwxWXnXspPwnjfF6UncEz4feuSA9mr1vW5d3YQPGruXYjj4vciSh4SPj5iWRxkHRWFeRpQnmVUyaVumuBTwM8nnLKJTdtkZnd6d8Mc5mVFdHs6EwLBTY4HECoRcbo4e4FwkfqVon4M" } + { "height": 1136300, "signature": "3BbwawEF2uN8Ni5ofpJXkukoU8ctAPxYoFB7whq9pKfBnjfZcpfEJT4R95NvBDoTP8WDyWvsUvbfHbcr9qSZuYpSKZjUQTvdFf6eqznHGEwhZApWfvXu6zjGCxYCp65F4jsVYYJjkzbjmkCg5WAwN5voudngA23kMK6PpTNygapCzXt" } ], "genesisInfo": { "version": 4, From 9d81ea7744c2edbf527b5db9a59039977dc9fc9a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 16 Jan 2023 20:26:00 +0000 Subject: [PATCH 139/496] Bump version to 3.8.4 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 7a82ad37..12f8472c 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.3 + 3.8.4 jar true From 64529e8abfb6a60125a634e8829fb74eab412c65 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 18 Jan 2023 19:04:54 +0000 Subject: [PATCH 140/496] Added "reverse" and "includeOnlineSignatures" params to `GET /blocks/range/{height}` endpoint. --- .../org/qortal/api/resource/BlocksResource.java | 17 +++++++++++++---- .../java/org/qortal/test/api/BlockApiTests.java | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index 195b2ca4..15541802 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -634,13 +634,16 @@ public class BlocksResource { @ApiErrors({ ApiError.REPOSITORY_ISSUE }) - public List getBlockRange(@PathParam("height") int height, @Parameter( - ref = "count" - ) @QueryParam("count") int count) { + public List getBlockRange(@PathParam("height") int height, + @Parameter(ref = "count") @QueryParam("count") int count, + @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse, + @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { try (final Repository repository = RepositoryManager.getRepository()) { List blocks = new ArrayList<>(); + boolean shouldReverse = (reverse != null && reverse == true); - for (/* count already set */; count > 0; --count, ++height) { + int i = 0; + while (i < count) { BlockData blockData = repository.getBlockRepository().fromHeight(height); if (blockData == null) { // Not found - try the archive @@ -650,8 +653,14 @@ public class BlocksResource { break; } } + if (includeOnlineSignatures == null || includeOnlineSignatures == false) { + blockData.setOnlineAccountsSignatures(null); + } blocks.add(blockData); + + height = shouldReverse ? height - 1 : height + 1; + i++; } return blocks; diff --git a/src/test/java/org/qortal/test/api/BlockApiTests.java b/src/test/java/org/qortal/test/api/BlockApiTests.java index 47d5318a..23e7b007 100644 --- a/src/test/java/org/qortal/test/api/BlockApiTests.java +++ b/src/test/java/org/qortal/test/api/BlockApiTests.java @@ -84,7 +84,7 @@ public class BlockApiTests extends ApiCommon { @Test public void testGetBlockRange() { - assertNotNull(this.blocksResource.getBlockRange(1, 1)); + assertNotNull(this.blocksResource.getBlockRange(1, 1, false, false)); List testValues = Arrays.asList(null, Integer.valueOf(1)); From 2f7912abce09763f3dd1600828f31bcbecb31909 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 18 Jan 2023 19:30:43 +0000 Subject: [PATCH 141/496] Compute balances for Bitcoin-like coins using unspent outputs. Should fix occasional incorrect balance issue, and speed up loading time. --- .../resource/CrossChainBitcoinResource.java | 2 +- .../resource/CrossChainDigibyteResource.java | 2 +- .../resource/CrossChainDogecoinResource.java | 2 +- .../resource/CrossChainLitecoinResource.java | 2 +- .../resource/CrossChainRavencoinResource.java | 2 +- .../java/org/qortal/crosschain/Bitcoiny.java | 96 ++++++++++++++++--- 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java index 80d19804..dd967451 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java @@ -68,7 +68,7 @@ public class CrossChainBitcoinResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = bitcoin.getWalletBalanceFromTransactions(key58); + Long balance = bitcoin.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java index 57049639..31d51c73 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java @@ -68,7 +68,7 @@ public class CrossChainDigibyteResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = digibyte.getWalletBalanceFromTransactions(key58); + Long balance = digibyte.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java index 189a53d3..28bebfb8 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java @@ -66,7 +66,7 @@ public class CrossChainDogecoinResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = dogecoin.getWalletBalanceFromTransactions(key58); + Long balance = dogecoin.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java index 8ac0f9a0..d12dd94c 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -68,7 +68,7 @@ public class CrossChainLitecoinResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = litecoin.getWalletBalanceFromTransactions(key58); + Long balance = litecoin.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java index 756b0bb5..97550392 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java @@ -68,7 +68,7 @@ public class CrossChainRavencoinResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = ravencoin.getWalletBalanceFromTransactions(key58); + Long balance = ravencoin.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index 350779bc..c08bd91e 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -357,19 +357,33 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @return unspent BTC balance, or null if unable to determine balance */ public Long getWalletBalance(String key58) throws ForeignBlockchainException { - // It's more accurate to calculate the balance from the transactions, rather than asking Bitcoinj - return this.getWalletBalanceFromTransactions(key58); + Long balance = 0L; -// Context.propagate(bitcoinjContext); -// -// Wallet wallet = walletFromDeterministicKey58(key58); -// wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); -// -// Coin balance = wallet.getBalance(); -// if (balance == null) -// return null; -// -// return balance.value; + List allUnspentOutputs = new ArrayList<>(); + Set walletAddresses = this.getWalletAddresses(key58); + for (String address : walletAddresses) { + allUnspentOutputs.addAll(this.getUnspentOutputs(address)); + } + for (TransactionOutput output : allUnspentOutputs) { + if (!output.isAvailableForSpending()) { + continue; + } + balance += output.getValue().value; + } + return balance; + } + + public Long getWalletBalanceFromBitcoinj(String key58) { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); + + Coin balance = wallet.getBalance(); + if (balance == null) + return null; + + return balance.value; } public Long getWalletBalanceFromTransactions(String key58) throws ForeignBlockchainException { @@ -464,6 +478,64 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } + public Set getWalletAddresses(String key58) throws ForeignBlockchainException { + synchronized (this) { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); + + keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + List keys = new ArrayList<>(keyChain.getLeafKeys()); + + Set keySet = new HashSet<>(); + + int unusedCounter = 0; + int ki = 0; + do { + boolean areAllKeysUnused = true; + + for (; ki < keys.size(); ++ki) { + DeterministicKey dKey = keys.get(ki); + + // Check for transactions + Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); + keySet.add(address.toString()); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes = this.getAddressTransactions(script, false); + + if (!historicTransactionHashes.isEmpty()) { + areAllKeysUnused = false; + } + } + + if (areAllKeysUnused) { + // No transactions + if (unusedCounter >= Settings.getInstance().getGapLimit()) { + // ... and we've hit our search limit + break; + } + // We haven't hit our search limit yet so increment the counter and keep looking + unusedCounter += WALLET_KEY_LOOKAHEAD_INCREMENT; + } else { + // Some keys in this batch were used, so reset the counter + unusedCounter = 0; + } + + // Generate some more keys + keys.addAll(generateMoreKeys(keyChain)); + + // Process new keys + } while (true); + + return keySet; + } + } + protected SimpleTransaction convertToSimpleTransaction(BitcoinyTransaction t, Set keySet) { long amount = 0; long total = 0L; From 3c8088e4639f27a207d4224adb4a255700a06efc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 19 Jan 2023 19:56:50 +0000 Subject: [PATCH 142/496] 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 143/496] 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 144/496] 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 145/496] 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 8ad46b6344277a7b6869fc71d205c93280f60412 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 20 Jan 2023 09:58:28 +0000 Subject: [PATCH 146/496] Fixed/removed incorrect comments --- .../test/arbitrary/ArbitraryServiceTests.java | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index f7738c45..acd86eaa 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -117,7 +117,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.OK, service.validate(path)); } @@ -140,7 +139,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); } @@ -151,7 +149,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.MISSING_DATA, service.validate(path)); } @@ -171,7 +168,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); } @@ -181,7 +177,7 @@ public class ArbitraryServiceTests extends Common { byte[] data = new byte[1024]; new Random().nextBytes(data); - // Write the data to several files in a temp path + // Write the data a single file in a temp path Path path = Files.createTempDirectory("testValidateQChatAttachment"); path.toFile().deleteOnExit(); Files.write(Paths.get(path.toString(), "document.pdf"), data, StandardOpenOption.CREATE); @@ -189,7 +185,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.OK, service.validate(path)); } @@ -199,7 +194,7 @@ public class ArbitraryServiceTests extends Common { byte[] data = new byte[1024]; new Random().nextBytes(data); - // Write the data to several files in a temp path + // Write the data a single file in a temp path Path path = Files.createTempDirectory("testValidateInvalidQChatAttachmentFileExtension"); path.toFile().deleteOnExit(); Files.write(Paths.get(path.toString(), "application.exe"), data, StandardOpenOption.CREATE); @@ -207,7 +202,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); } @@ -218,7 +212,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); } @@ -241,7 +234,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); } @@ -260,7 +252,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); } From e31515b4a297283374dd026b61f085732729715b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 20 Jan 2023 10:14:42 +0000 Subject: [PATCH 147/496] Fixed bugs preventing single file GIF repositories and QCHAT attachments from passing validation. --- .../org/qortal/arbitrary/misc/Service.java | 8 +++++ .../test/arbitrary/ArbitraryServiceTests.java | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index dc2deaeb..96934de2 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -24,6 +24,10 @@ public enum Service { // 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.isDirectory()) { @@ -80,6 +84,10 @@ public enum Service { // Custom validation function to require .gif files only, and at least 1 int gifCount = 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.isDirectory()) { diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index acd86eaa..bbd17ab7 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -120,6 +120,24 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.OK, service.validate(path)); } + @Test + public void testValidateSingleFileGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to a single file in a temp path + Path path = Files.createTempDirectory("testValidateSingleFileGifRepository"); + path.toFile().deleteOnExit(); + Path imagePath = Paths.get(path.toString(), "image1.gif"); + Files.write(imagePath, data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(imagePath)); + } + @Test public void testValidateMultiLayerGifRepository() throws IOException { // Generate some random data @@ -188,6 +206,24 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.OK, service.validate(path)); } + @Test + public void testValidateSingleFileQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateSingleFileQChatAttachment"); + path.toFile().deleteOnExit(); + Path filePath = Paths.get(path.toString(), "document.pdf"); + Files.write(filePath, data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + @Test public void testValidateInvalidQChatAttachmentFileExtension() throws IOException { // Generate some random data From c3f19ea0c1c52507dbaa0872de506c3d408cabd9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 20 Jan 2023 10:21:05 +0000 Subject: [PATCH 148/496] Don't allow the custom validation methods to evade superclass validation. --- .../org/qortal/arbitrary/misc/Service.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 96934de2..0aeb99ed 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -20,7 +20,12 @@ public enum Service { ARBITRARY_DATA(100, false, null, null), QCHAT_ATTACHMENT(120, true, 1024*1024L, null) { @Override - public ValidationResult validate(Path path) { + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + // Custom validation function to require a single file, with a whitelisted extension int fileCount = 0; File[] files = path.toFile().listFiles(); @@ -49,7 +54,12 @@ public enum Service { }, WEBSITE(200, true, null, null) { @Override - public ValidationResult validate(Path path) { + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + // Custom validation function to require an index HTML file in the root directory List fileNames = ArbitraryDataRenderer.indexFiles(); String[] files = path.toFile().list(); @@ -80,7 +90,12 @@ public enum Service { METADATA(1100, false, null, null), GIF_REPOSITORY(1200, true, 25*1024*1024L, null) { @Override - public ValidationResult validate(Path path) { + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + // Custom validation function to require .gif files only, and at least 1 int gifCount = 0; File[] files = path.toFile().listFiles(); From 1f7fec6251d095519fa26d6ae65b6e72995d4e43 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 20 Jan 2023 10:40:20 +0000 Subject: [PATCH 149/496] Exclude .qortal directory in validation functions, as it was incorrectly failing with "DIRECTORIES_NOT_ALLOWED". --- .../org/qortal/arbitrary/misc/Service.java | 6 ++ .../test/arbitrary/ArbitraryServiceTests.java | 98 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 0aeb99ed..5ddccbe5 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -35,6 +35,9 @@ public enum Service { } if (files != null) { for (File file : files) { + if (file.getName().equals(".qortal")) { + continue; + } if (file.isDirectory()) { return ValidationResult.DIRECTORIES_NOT_ALLOWED; } @@ -105,6 +108,9 @@ public enum Service { } if (files != null) { for (File file : files) { + if (file.getName().equals(".qortal")) { + continue; + } if (file.isDirectory()) { return ValidationResult.DIRECTORIES_NOT_ALLOWED; } diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index bbd17ab7..96843876 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -1,11 +1,26 @@ package org.qortal.test.arbitrary; +import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.Before; import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +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.arbitrary.misc.Service.ValidationResult; +import org.qortal.controller.arbitrary.ArbitraryDataManager; +import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.ArbitraryUtils; import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.RegisterNameTransaction; +import org.qortal.utils.Base58; import java.io.IOException; import java.nio.file.Files; @@ -189,6 +204,48 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); } + @Test + public void testValidatePublishedGifRepository() throws IOException, DataException, MissingDataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image3.gif"), data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(path)); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = "test_identifier"; + + // 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); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction + ArbitraryUtils.createAndMintTxn(repository, publicKey58, path, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice); + + // Build the latest data state for this name, and no exceptions should be thrown because validation passes + ArbitraryDataReader arbitraryDataReader1a = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + arbitraryDataReader1a.loadSynchronously(true); + } + } + @Test public void testValidateQChatAttachment() throws IOException { // Generate some random data @@ -291,4 +348,45 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); } + @Test + public void testValidatePublishedQChatAttachment() throws IOException, DataException, MissingDataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateSingleFileQChatAttachment"); + path.toFile().deleteOnExit(); + Path filePath = Paths.get(path.toString(), "document.pdf"); + Files.write(filePath, data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = "test_identifier"; + + // 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); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction + ArbitraryUtils.createAndMintTxn(repository, publicKey58, filePath, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice); + + // Build the latest data state for this name, and no exceptions should be thrown because validation passes + ArbitraryDataReader arbitraryDataReader1a = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + arbitraryDataReader1a.loadSynchronously(true); + } + } + } \ No newline at end of file From 9f30571b12a3463465547286b47083ee1417b271 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Jan 2023 15:58:53 +0000 Subject: [PATCH 150/496] Use a filename without an extension when publishing data from a string (instead of .tmp) --- src/main/java/org/qortal/api/resource/ArbitraryResource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 978183c0..25b968f1 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -1128,7 +1128,7 @@ public class ArbitraryResource { if (path == null) { // See if we have a string instead if (string != null) { - File tempFile = File.createTempFile("qortal-", ".tmp"); + File tempFile = File.createTempFile("qortal-", ""); tempFile.deleteOnExit(); BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile.toPath().toString())); writer.write(string); @@ -1138,7 +1138,7 @@ public class ArbitraryResource { } // ... or base64 encoded raw data else if (base64 != null) { - File tempFile = File.createTempFile("qortal-", ".tmp"); + File tempFile = File.createTempFile("qortal-", ""); tempFile.deleteOnExit(); Files.write(tempFile.toPath(), Base64.decode(base64)); path = tempFile.toPath().toString(); From 6196841609ce1b11bc10ae653928e0d40e07f11f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Jan 2023 15:59:16 +0000 Subject: [PATCH 151/496] Allow files without extensions in QCHAT_ATTACHMENT validation. --- src/main/java/org/qortal/arbitrary/misc/Service.java | 3 ++- 1 file changed, 2 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 5ddccbe5..01419d2f 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -42,7 +42,8 @@ public enum Service { return ValidationResult.DIRECTORIES_NOT_ALLOWED; } final String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(); - final List allowedExtensions = Arrays.asList("zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx"); + // 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; } From 1d568fa46245cac370a8f7a79cfb887033a52f93 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Jan 2023 16:29:23 +0000 Subject: [PATCH 152/496] Return file lists via /arbitrary/metadata/* endpoints, but exclude it from /arbitrary/resources/* endpoints. --- .../api/resource/ArbitraryResource.java | 4 ++-- .../arbitrary/ArbitraryResourceMetadata.java | 20 ++++++++++++++---- .../ArbitraryTransactionMetadataTests.java | 21 +++++++++++++++++++ 3 files changed, 39 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 25b968f1..0df81d9b 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -719,7 +719,7 @@ public class ArbitraryResource { try { ArbitraryDataTransactionMetadata transactionMetadata = ArbitraryMetadataManager.getInstance().fetchMetadata(resource, false); if (transactionMetadata != null) { - ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata); + ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata, true); if (resourceMetadata != null) { return resourceMetadata; } @@ -1288,7 +1288,7 @@ public class ArbitraryResource { ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ResourceIdType.NAME, resourceInfo.service, resourceInfo.identifier); ArbitraryDataTransactionMetadata transactionMetadata = resource.getLatestTransactionMetadata(); - ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata); + ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata, false); if (resourceMetadata != null) { resourceInfo.metadata = resourceMetadata; } diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java index e2bcaf56..497e214f 100644 --- a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceMetadata.java @@ -15,22 +15,24 @@ public class ArbitraryResourceMetadata { private List tags; private Category category; private String categoryName; + private List files; public ArbitraryResourceMetadata() { } - public ArbitraryResourceMetadata(String title, String description, List tags, Category category) { + public ArbitraryResourceMetadata(String title, String description, List tags, Category category, List files) { this.title = title; this.description = description; this.tags = tags; this.category = category; + this.files = files; if (category != null) { this.categoryName = category.getName(); } } - public static ArbitraryResourceMetadata fromTransactionMetadata(ArbitraryDataTransactionMetadata transactionMetadata) { + public static ArbitraryResourceMetadata fromTransactionMetadata(ArbitraryDataTransactionMetadata transactionMetadata, boolean includeFileList) { if (transactionMetadata == null) { return null; } @@ -39,10 +41,20 @@ public class ArbitraryResourceMetadata { List tags = transactionMetadata.getTags(); Category category = transactionMetadata.getCategory(); - if (title == null && description == null && tags == null && category == null) { + // We don't always want to include the file list as it can be too verbose + List files = null; + if (includeFileList) { + files = transactionMetadata.getFiles(); + } + + if (title == null && description == null && tags == null && category == null && files == null) { return null; } - return new ArbitraryResourceMetadata(title, description, tags, category); + return new ArbitraryResourceMetadata(title, description, tags, category, files); + } + + public List getFiles() { + return this.files; } } diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java index d8071777..5d28568d 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java @@ -12,6 +12,7 @@ import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataManager; +import org.qortal.data.arbitrary.ArbitraryResourceMetadata; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.repository.DataException; @@ -311,6 +312,15 @@ public class ArbitraryTransactionMetadataTests extends Common { // Check the file list metadata is correct assertEquals(1, arbitraryDataFile.getMetadata().getFiles().size()); assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("file.txt")); + + // Ensure the file list can be read back out again, when specified to be included + ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(arbitraryDataFile.getMetadata(), true); + assertTrue(resourceMetadata.getFiles().contains("file.txt")); + + // Ensure it's not returned when specified to be excluded + // The entire object will be null because there is no metadata + ArbitraryResourceMetadata resourceMetadataSimple = ArbitraryResourceMetadata.fromTransactionMetadata(arbitraryDataFile.getMetadata(), false); + assertNull(resourceMetadataSimple); } } @@ -348,6 +358,17 @@ public class ArbitraryTransactionMetadataTests extends Common { assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("file.txt")); assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("image1.jpg")); assertTrue(arbitraryDataFile.getMetadata().getFiles().contains("subdirectory/config.json")); + + // Ensure the file list can be read back out again, when specified to be included + ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(arbitraryDataFile.getMetadata(), true); + assertTrue(resourceMetadata.getFiles().contains("file.txt")); + assertTrue(resourceMetadata.getFiles().contains("image1.jpg")); + assertTrue(resourceMetadata.getFiles().contains("subdirectory/config.json")); + + // Ensure it's not returned when specified to be excluded + // The entire object will be null because there is no metadata + ArbitraryResourceMetadata resourceMetadataSimple = ArbitraryResourceMetadata.fromTransactionMetadata(arbitraryDataFile.getMetadata(), false); + assertNull(resourceMetadataSimple); } } From 8dffe1e3ac884161c08234cd5859475fcf02fde4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Jan 2023 18:59:46 +0000 Subject: [PATCH 153/496] 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 154/496] 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 6b36d94c6f20637155c855060c579f0c7873b8fc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Jan 2023 12:48:42 +0000 Subject: [PATCH 155/496] Removed searchResultsTransactions cache, to simplify code. The hostedTransactions cache is still in place, which limits disk reads when searching, so this additional cache isn't really needed. --- .../arbitrary/ArbitraryDataStorageManager.java | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java index 4568d3fd..ededbfa6 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java @@ -48,7 +48,6 @@ public class ArbitraryDataStorageManager extends Thread { private List hostedTransactions; private String searchQuery; - private List searchResultsTransactions; private static final long DIRECTORY_SIZE_CHECK_INTERVAL = 10 * 60 * 1000L; // 10 minutes @@ -344,11 +343,6 @@ public class ArbitraryDataStorageManager extends Thread { */ public List searchHostedTransactions(Repository repository, String query, Integer limit, Integer offset) { - // Load from results cache if we can (results that exists for the same query), to avoid disk reads - if (this.searchResultsTransactions != null && this.searchQuery.equals(query.toLowerCase())) { - return ArbitraryTransactionUtils.limitOffsetTransactions(this.searchResultsTransactions, limit, offset); - } - // Using cache if we can, to avoid disk reads if (this.hostedTransactions == null) { this.hostedTransactions = this.loadAllHostedTransactions(repository); @@ -376,10 +370,7 @@ public class ArbitraryDataStorageManager extends Thread { // Sort by newest first searchResultsList.sort(Comparator.comparingLong(ArbitraryTransactionData::getTimestamp).reversed()); - // Update cache - this.searchResultsTransactions = searchResultsList; - - return ArbitraryTransactionUtils.limitOffsetTransactions(this.searchResultsTransactions, limit, offset); + return ArbitraryTransactionUtils.limitOffsetTransactions(searchResultsList, limit, offset); } /** From 8c708558cb4371ca1cff063ba944ab73a1425bbc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Jan 2023 14:33:34 +0000 Subject: [PATCH 156/496] Implemented ElectrumX version negotiation. Fixes issues with DOGE wallet. --- .../java/org/qortal/crosschain/ElectrumX.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index e1eb1963..a331b111 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -5,6 +5,7 @@ import java.math.BigDecimal; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; +import java.text.DecimalFormat; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -30,7 +31,11 @@ public class ElectrumX extends BitcoinyBlockchainProvider { private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class); private static final Random RANDOM = new Random(); + // See: https://electrumx.readthedocs.io/en/latest/protocol-changes.html private static final double MIN_PROTOCOL_VERSION = 1.2; + private static final double MAX_PROTOCOL_VERSION = 2.0; // Higher than current latest, for hopeful future-proofing + private static final String CLIENT_NAME = "Qortal"; + private static final int BLOCK_HEADER_LENGTH = 80; // "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})" @@ -679,6 +684,9 @@ public class ElectrumX extends BitcoinyBlockchainProvider { this.scanner = new Scanner(this.socket.getInputStream()); this.scanner.useDelimiter("\n"); + // All connections need to start with a version negotiation + this.connectedRpc("server.version"); + // Check connection is suitable by asking for server features, including genesis block hash JSONObject featuresJson = (JSONObject) this.connectedRpc("server.features"); @@ -725,6 +733,17 @@ public class ElectrumX extends BitcoinyBlockchainProvider { JSONArray requestParams = new JSONArray(); requestParams.addAll(Arrays.asList(params)); + + // server.version needs additional params to negotiate a version + if (method.equals("server.version")) { + requestParams.add(CLIENT_NAME); + List versions = new ArrayList<>(); + DecimalFormat df = new DecimalFormat("#.#"); + versions.add(df.format(MIN_PROTOCOL_VERSION)); + versions.add(df.format(MAX_PROTOCOL_VERSION)); + requestParams.add(versions); + } + requestJson.put("params", requestParams); String request = requestJson.toJSONString() + "\n"; From d7b1615d4f9985cc8a644f26b548e058c43324c2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Jan 2023 16:26:36 +0000 Subject: [PATCH 157/496] 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 bf06d47842337c62636eabe15dbeb8fbd8b4a933 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Jan 2023 16:55:43 +0000 Subject: [PATCH 158/496] Create an ArbitraryDataResource object when building. Eventually this could be passed in to the reader instead of the individual components (service, name, identifier, etc) This is now used to improve logging when extracting. --- .../org/qortal/arbitrary/ArbitraryDataReader.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index d1a8b4f5..91e9eeb7 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -60,6 +60,9 @@ public class ArbitraryDataReader { private int layerCount; private byte[] latestSignature; + // The resource being read + ArbitraryDataResource arbitraryDataResource = null; + public ArbitraryDataReader(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) { // Ensure names are always lowercase if (resourceIdType == ResourceIdType.NAME) { @@ -116,6 +119,11 @@ public class ArbitraryDataReader { return new ArbitraryDataBuildQueueItem(this.resourceId, this.resourceIdType, this.service, this.identifier); } + private ArbitraryDataResource createArbitraryDataResource() { + return new ArbitraryDataResource(this.resourceId, this.resourceIdType, this.service, this.identifier); + } + + /** * loadAsynchronously * @@ -163,6 +171,8 @@ public class ArbitraryDataReader { return; } + this.arbitraryDataResource = this.createArbitraryDataResource(); + this.preExecute(); this.deleteExistingFiles(); this.fetch(); @@ -436,7 +446,7 @@ public class ArbitraryDataReader { byte[] secret = this.secret58 != null ? Base58.decode(this.secret58) : null; if (secret != null && secret.length == Transformer.AES256_LENGTH) { try { - LOGGER.info("Decrypting using algorithm {}...", algorithm); + LOGGER.info("Decrypting {} using algorithm {}...", this.arbitraryDataResource, algorithm); 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()); From 5962ebd08a9e7042a092a9455a66c3708025f0e8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Jan 2023 16:56:53 +0000 Subject: [PATCH 159/496] More logging improvements in ArbitraryDataReader.decrypt() --- src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index 91e9eeb7..78723958 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -446,7 +446,7 @@ public class ArbitraryDataReader { byte[] secret = this.secret58 != null ? Base58.decode(this.secret58) : null; if (secret != null && secret.length == Transformer.AES256_LENGTH) { try { - LOGGER.info("Decrypting {} using algorithm {}...", this.arbitraryDataResource, algorithm); + LOGGER.debug("Decrypting {} using algorithm {}...", this.arbitraryDataResource, algorithm); 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()); @@ -457,7 +457,7 @@ public class ArbitraryDataReader { } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException | BadPaddingException | IllegalBlockSizeException | IOException | InvalidKeyException e) { - LOGGER.info(String.format("Exception when decrypting using algorithm %s", algorithm), e); + LOGGER.info(String.format("Exception when decrypting %s using algorithm %s", this.arbitraryDataResource, algorithm), e); throw new DataException(String.format("Unable to decrypt file at path %s using algorithm %s: %s", this.filePath, algorithm, e.getMessage())); } } else { From 6ad0989ea27f8c60436aada0223549c385cb9100 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Jan 2023 18:35:44 +0000 Subject: [PATCH 160/496] Reduce log spam --- src/main/java/org/qortal/network/Network.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index 8aac68f0..f8f73c2a 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -339,7 +339,7 @@ public class Network { try { if (!isConnected) { // Add this signature to the list of pending requests for this peer - LOGGER.info("Making connection to peer {} to request files for signature {}...", peerAddressString, Base58.encode(signature)); + LOGGER.debug("Making connection to peer {} to request files for signature {}...", peerAddressString, Base58.encode(signature)); Peer peer = new Peer(peerData); peer.setIsDataPeer(true); peer.addPendingSignatureRequest(signature); From ae44065d7e08cf1d6d77ae1ba4caf827b8bc6f29 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Jan 2023 19:34:23 +0000 Subject: [PATCH 161/496] Fixed issue with CancelSellName transactions. --- .../CancelSellNameTransactionData.java | 22 ++++++++- src/main/java/org/qortal/naming/Name.java | 7 ++- .../hsqldb/HSQLDBDatabaseUpdates.java | 5 ++ ...DBCancelSellNameTransactionRepository.java | 7 +-- .../org/qortal/test/naming/BuySellTests.java | 46 +++++++++++++++++++ 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java b/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java index ff3d0a08..14677daf 100644 --- a/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/CancelSellNameTransactionData.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.qortal.transaction.Transaction.TransactionType; @@ -19,6 +20,11 @@ public class CancelSellNameTransactionData extends TransactionData { @Schema(description = "which name to cancel selling", example = "my-name") private String name; + // For internal use when orphaning + @XmlTransient + @Schema(hidden = true) + private Long salePrice; + // Constructors // For JAXB @@ -30,11 +36,17 @@ public class CancelSellNameTransactionData extends TransactionData { this.creatorPublicKey = this.ownerPublicKey; } - public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name) { + public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name, Long salePrice) { super(TransactionType.CANCEL_SELL_NAME, baseTransactionData); this.ownerPublicKey = baseTransactionData.creatorPublicKey; this.name = name; + this.salePrice = salePrice; + } + + /** From network/API */ + public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name) { + this(baseTransactionData, name, null); } // Getters / setters @@ -47,4 +59,12 @@ public class CancelSellNameTransactionData extends TransactionData { return this.name; } + public Long getSalePrice() { + return this.salePrice; + } + + public void setSalePrice(Long salePrice) { + this.salePrice = salePrice; + } + } diff --git a/src/main/java/org/qortal/naming/Name.java b/src/main/java/org/qortal/naming/Name.java index 97fe8bbb..ecf826a5 100644 --- a/src/main/java/org/qortal/naming/Name.java +++ b/src/main/java/org/qortal/naming/Name.java @@ -180,8 +180,12 @@ public class Name { } public void cancelSell(CancelSellNameTransactionData cancelSellNameTransactionData) throws DataException { - // Mark not for-sale but leave price in case we want to orphan + // Update previous sale price in transaction data + cancelSellNameTransactionData.setSalePrice(this.nameData.getSalePrice()); + + // Mark not for-sale this.nameData.setIsForSale(false); + this.nameData.setSalePrice(null); // Save sale info into repository this.repository.getNameRepository().save(this.nameData); @@ -190,6 +194,7 @@ public class Name { public void uncancelSell(CancelSellNameTransactionData cancelSellNameTransactionData) throws DataException { // Mark as for-sale using existing price this.nameData.setIsForSale(true); + this.nameData.setSalePrice(cancelSellNameTransactionData.getSalePrice()); // Save no-sale info into repository this.repository.getNameRepository().save(this.nameData); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index e72e5fab..aecac034 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -988,6 +988,11 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE INDEX ChatTransactionsChatReferenceIndex ON ChatTransactions (chat_reference)"); break; + case 46: + // We need to track the sale price when canceling a name sale, so it can be put back when orphaned + stmt.execute("ALTER TABLE CancelSellNameTransactions ADD sale_price QortalAmount"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java index 5f2ea35a..fc8e0bb3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBCancelSellNameTransactionRepository.java @@ -17,15 +17,16 @@ public class HSQLDBCancelSellNameTransactionRepository extends HSQLDBTransaction } TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { - String sql = "SELECT name FROM CancelSellNameTransactions WHERE signature = ?"; + String sql = "SELECT name, sale_price FROM CancelSellNameTransactions WHERE signature = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { if (resultSet == null) return null; String name = resultSet.getString(1); + Long salePrice = resultSet.getLong(2); - return new CancelSellNameTransactionData(baseTransactionData, name); + return new CancelSellNameTransactionData(baseTransactionData, name, salePrice); } catch (SQLException e) { throw new DataException("Unable to fetch cancel sell name transaction from repository", e); } @@ -38,7 +39,7 @@ public class HSQLDBCancelSellNameTransactionRepository extends HSQLDBTransaction HSQLDBSaver saveHelper = new HSQLDBSaver("CancelSellNameTransactions"); saveHelper.bind("signature", cancelSellNameTransactionData.getSignature()).bind("owner", cancelSellNameTransactionData.getOwnerPublicKey()).bind("name", - cancelSellNameTransactionData.getName()); + cancelSellNameTransactionData.getName()).bind("sale_price", cancelSellNameTransactionData.getSalePrice()); try { saveHelper.execute(this.repository); diff --git a/src/test/java/org/qortal/test/naming/BuySellTests.java b/src/test/java/org/qortal/test/naming/BuySellTests.java index faed3d72..4530820e 100644 --- a/src/test/java/org/qortal/test/naming/BuySellTests.java +++ b/src/test/java/org/qortal/test/naming/BuySellTests.java @@ -165,6 +165,52 @@ public class BuySellTests extends Common { assertEquals("price incorrect", price, nameData.getSalePrice()); } + @Test + public void testCancelSellNameAndRelist() throws DataException { + // Register-name and sell-name + testSellName(); + + // Cancel Sell-name + CancelSellNameTransactionData transactionData = new CancelSellNameTransactionData(TestTransaction.generateBase(alice), name); + TransactionUtils.signAndMint(repository, transactionData, alice); + + NameData nameData; + + // Check name is no longer for sale + nameData = repository.getNameRepository().fromName(name); + assertFalse(nameData.isForSale()); + assertNull(nameData.getSalePrice()); + + // Re-sell-name + Long newPrice = random.nextInt(1000) * Amounts.MULTIPLIER; + SellNameTransactionData sellNameTransactionData = new SellNameTransactionData(TestTransaction.generateBase(alice), name, newPrice); + TransactionUtils.signAndMint(repository, sellNameTransactionData, alice); + + // Check name is for sale + nameData = repository.getNameRepository().fromName(name); + assertTrue(nameData.isForSale()); + assertEquals("price incorrect", newPrice, nameData.getSalePrice()); + + // Orphan sell-name + BlockUtils.orphanLastBlock(repository); + + // Check name no longer for sale + nameData = repository.getNameRepository().fromName(name); + assertFalse(nameData.isForSale()); + assertNull(nameData.getSalePrice()); + + // Orphan cancel-sell-name + BlockUtils.orphanLastBlock(repository); + + // Check name is for sale (at original price) + nameData = repository.getNameRepository().fromName(name); + assertTrue(nameData.isForSale()); + assertEquals("price incorrect", price, nameData.getSalePrice()); + + // Orphan sell-name and register-name + BlockUtils.orphanBlocks(repository, 2); + } + @Test public void testBuyName() throws DataException { // Register-name and sell-name From 06d8a21714990231d778c09f70454285f859409e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Jan 2023 19:38:26 +0000 Subject: [PATCH 162/496] Added CANCEL_SELL_NAME equivalents to NamesDatabaseIntegrityCheck.java --- .../NamesDatabaseIntegrityCheck.java | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java index e69d1a35..004fa692 100644 --- a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java +++ b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java @@ -102,6 +102,21 @@ public class NamesDatabaseIntegrityCheck { } } + // Process CANCEL_SELL_NAME transactions + if (currentTransaction.getType() == TransactionType.CANCEL_SELL_NAME) { + CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) currentTransaction; + Name nameObj = new Name(repository, cancelSellNameTransactionData.getName()); + if (nameObj != null && nameObj.getNameData() != null) { + nameObj.cancelSell(cancelSellNameTransactionData); + modificationCount++; + LOGGER.trace("Processed CANCEL_SELL_NAME transaction for name {}", name); + } + else { + // Something went wrong + throw new DataException(String.format("Name data not found for name %s", cancelSellNameTransactionData.getName())); + } + } + // Process BUY_NAME transactions if (currentTransaction.getType() == TransactionType.BUY_NAME) { BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) currentTransaction; @@ -128,7 +143,7 @@ public class NamesDatabaseIntegrityCheck { public int rebuildAllNames() { int modificationCount = 0; try (final Repository repository = RepositoryManager.getRepository()) { - List names = this.fetchAllNames(repository); + List names = this.fetchAllNames(repository); // TODO: de-duplicate, to speed up this process for (String name : names) { modificationCount += this.rebuildName(name, repository); } @@ -326,6 +341,10 @@ public class NamesDatabaseIntegrityCheck { TransactionType.BUY_NAME, Arrays.asList("name = ?"), Arrays.asList(name)); signatures.addAll(buyNameTransactions); + List cancelSellNameTransactions = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria( + TransactionType.CANCEL_SELL_NAME, Arrays.asList("name = ?"), Arrays.asList(name)); + signatures.addAll(cancelSellNameTransactions); + List transactions = new ArrayList<>(); for (byte[] signature : signatures) { TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); @@ -390,6 +409,12 @@ public class NamesDatabaseIntegrityCheck { names.add(sellNameTransactionData.getName()); } } + if ((transactionData instanceof CancelSellNameTransactionData)) { + CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) transactionData; + if (!names.contains(cancelSellNameTransactionData.getName())) { + names.add(cancelSellNameTransactionData.getName()); + } + } } return names; } From a24ba40d5c34b81618f0f05151fbdc8ee240ee4f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 09:54:15 +0000 Subject: [PATCH 163/496] Added additional Dogecoin ElectrumX server. --- src/main/java/org/qortal/crosschain/Dogecoin.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/crosschain/Dogecoin.java b/src/main/java/org/qortal/crosschain/Dogecoin.java index d6955e18..9af8d990 100644 --- a/src/main/java/org/qortal/crosschain/Dogecoin.java +++ b/src/main/java/org/qortal/crosschain/Dogecoin.java @@ -47,7 +47,8 @@ public class Dogecoin extends Bitcoiny { // Servers chosen on NO BASIS WHATSOEVER from various sources! new Server("electrum1.cipig.net", ConnectionType.SSL, 20060), new Server("electrum2.cipig.net", ConnectionType.SSL, 20060), - new Server("electrum3.cipig.net", ConnectionType.SSL, 20060)); + new Server("electrum3.cipig.net", ConnectionType.SSL, 20060), + new Server("161.97.137.235", ConnectionType.SSL, 50002)); // TODO: add more mainnet servers. It's too centralized. } From 876658256f735853040e930b057ee1db025d54a2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 11:57:15 +0000 Subject: [PATCH 164/496] Prevent a P2SH address being funded for a trade if there is an unconfirmed buy or cancel request in progress for it already. This prevents foreign coins from leaving the local wallet when there is a high probability that the trade will fail, and therefore should reduce the chances of losing transaction fees due to refunds. Whenever this occurs, the UI will show "Trade has an existing buy request or is pending cancellation." after clicking Buy. --- .../api/resource/CrossChainTradeBotResource.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java index 3c8bd28f..aefca016 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; @@ -38,9 +39,12 @@ import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.Transaction; import org.qortal.utils.Base58; import org.qortal.utils.NTP; @@ -223,6 +227,17 @@ public class CrossChainTradeBotResource { if (crossChainTradeData.mode != AcctMode.OFFERING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // Check if there is a buy or a cancel request in progress for this trade + List txTypes = List.of(Transaction.TransactionType.MESSAGE); + List unconfirmed = repository.getTransactionRepository().getUnconfirmedTransactions(txTypes, null, 0, 0, false); + for (TransactionData transactionData : unconfirmed) { + MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData; + if (Objects.equals(messageTransactionData.getRecipient(), atAddress)) { + // There is a pending request for this trade, so block this buy attempt to reduce the risk of refunds + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Trade has an existing buy request or is pending cancellation."); + } + } + AcctTradeBot.ResponseResult result = TradeBot.getInstance().startResponse(repository, atData, acct, crossChainTradeData, tradeBotRespondRequest.foreignKey, tradeBotRespondRequest.receivingAddress); From 4a42dc2d00babddbe6bbe9d5ba9c94e693e2880b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 14:14:44 +0000 Subject: [PATCH 165/496] 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 166/496] 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 167/496] 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 168/496] 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 169/496] 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 170/496] 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 171/496] 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 e86b9b1caf99a01e4565ba4aa171e471b765b9ca Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 15:34:30 +0000 Subject: [PATCH 172/496] Added additional Litecoin ElectrumX server. --- src/main/java/org/qortal/crosschain/Litecoin.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 3ab30b2b..6fc6ba50 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -54,7 +54,8 @@ public class Litecoin extends Bitcoiny { new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 20063), new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 20063), new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022), - new Server("ltc.rentonrisk.com", Server.ConnectionType.SSL, 50002)); + new Server("ltc.rentonrisk.com", Server.ConnectionType.SSL, 50002), + new Server("62.171.169.176", Server.ConnectionType.SSL, 50002)); } @Override From c5c826453be2ec64f949d76aca128650ceec71d5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 15:41:48 +0000 Subject: [PATCH 173/496] Removed unnecessary join when finding MESSAGE transactions, which caused secret to be unavailable when querying pruned blocks. --- .../org/qortal/repository/hsqldb/HSQLDBMessageRepository.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java index f00c79fc..f31c5cd8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java @@ -28,7 +28,6 @@ public class HSQLDBMessageRepository implements MessageRepository { StringBuilder sql = new StringBuilder(1024); sql.append("SELECT signature from MessageTransactions " + "JOIN Transactions USING (signature) " - + "JOIN BlockTransactions ON transaction_signature = signature " + "WHERE "); List whereClauses = new ArrayList<>(); From e1e52b31657458bae4cfc156c7808d096d12b2ff Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Jan 2023 15:52:46 +0000 Subject: [PATCH 174/496] 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 175/496] 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 176/496] 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 177/496] 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 178/496] 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 179/496] 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 180/496] 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 181/496] 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 182/496] 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 183/496] 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 184/496] 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 185/496] 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 186/496] 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 187/496] 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 188/496] 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 189/496] 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 190/496] 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 191/496] 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 192/496] 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 21f5d9a3d06089717be29fc517e07ce1c35c9ba1 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Tue, 31 Jan 2023 17:23:25 -0500 Subject: [PATCH 193/496] Add foreign chain height to API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET ​/crosschain​/{COIN}/height --- .../resource/CrossChainBitcoinResource.java | 32 +++++++++++++++++++ .../resource/CrossChainDigibyteResource.java | 32 +++++++++++++++++++ .../resource/CrossChainDogecoinResource.java | 32 +++++++++++++++++++ .../resource/CrossChainLitecoinResource.java | 32 +++++++++++++++++++ .../CrossChainPirateChainResource.java | 32 +++++++++++++++++++ .../resource/CrossChainRavencoinResource.java | 32 +++++++++++++++++++ .../java/org/qortal/crosschain/Bitcoiny.java | 10 ++++++ 7 files changed, 202 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java index dd967451..80f9ff04 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java @@ -14,6 +14,7 @@ import java.util.List; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; +import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -35,6 +36,37 @@ public class CrossChainBitcoinResource { @Context HttpServletRequest request; + @GET + @Path("/height") + @Operation( + summary = "Returns current Bitcoin block height", + description = "Returns the height of the most recent block in the Bitcoin chain.", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getBitcoinHeight() { + Bitcoin bitcoin = Bitcoin.getInstance(); + + try { + Integer height = bitcoin.getBlockchainHeight(); + if (height == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return height.toString(); + + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/walletbalance") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java index 31d51c73..e315947a 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java @@ -14,6 +14,7 @@ import java.util.List; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; +import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -35,6 +36,37 @@ public class CrossChainDigibyteResource { @Context HttpServletRequest request; + @GET + @Path("/height") + @Operation( + summary = "Returns current Digibyte block height", + description = "Returns the height of the most recent block in the Digibyte chain.", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getDigibyteHeight() { + Digibyte digibyte = Digibyte.getInstance(); + + try { + Integer height = digibyte.getBlockchainHeight(); + if (height == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return height.toString(); + + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/walletbalance") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java index 28bebfb8..602d131b 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java @@ -21,6 +21,7 @@ import org.qortal.crosschain.SimpleTransaction; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; +import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -33,6 +34,37 @@ public class CrossChainDogecoinResource { @Context HttpServletRequest request; + @GET + @Path("/height") + @Operation( + summary = "Returns current Dogecoin block height", + description = "Returns the height of the most recent block in the Dogecoin chain.", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getDogecoinHeight() { + Dogecoin dogecoin = Dogecoin.getInstance(); + + try { + Integer height = dogecoin.getBlockchainHeight(); + if (height == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return height.toString(); + + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/walletbalance") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java index d12dd94c..653cc2ec 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -14,6 +14,7 @@ import java.util.List; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; +import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -35,6 +36,37 @@ public class CrossChainLitecoinResource { @Context HttpServletRequest request; + @GET + @Path("/height") + @Operation( + summary = "Returns current Litecoin block height", + description = "Returns the height of the most recent block in the Litecoin chain.", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getLitecoinHeight() { + Litecoin litecoin = Litecoin.getInstance(); + + try { + Integer height = litecoin.getBlockchainHeight(); + if (height == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return height.toString(); + + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/walletbalance") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java index bd7bf57d..6989e7c7 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java @@ -20,6 +20,7 @@ import org.qortal.crosschain.SimpleTransaction; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; +import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -32,6 +33,37 @@ public class CrossChainPirateChainResource { @Context HttpServletRequest request; + @GET + @Path("/height") + @Operation( + summary = "Returns current PirateChain block height", + description = "Returns the height of the most recent block in the PirateChain chain.", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getPirateChainHeight() { + PirateChain pirateChain = PirateChain.getInstance(); + + try { + Integer height = pirateChain.getBlockchainHeight(); + if (height == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return height.toString(); + + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/walletbalance") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java index 97550392..9e76b8a2 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java @@ -14,6 +14,7 @@ import java.util.List; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; +import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -35,6 +36,37 @@ public class CrossChainRavencoinResource { @Context HttpServletRequest request; + @GET + @Path("/height") + @Operation( + summary = "Returns current Ravencoin block height", + description = "Returns the height of the most recent block in the Ravencoin chain.", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getRavencoinHeight() { + Ravencoin ravencoin = Ravencoin.getInstance(); + + try { + Integer height = ravencoin.getBlockchainHeight(); + if (height == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return height.toString(); + + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/walletbalance") @Operation( diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index c08bd91e..d1523b50 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -167,6 +167,16 @@ public abstract class Bitcoiny implements ForeignBlockchain { return blockTimestamps.get(5); } + /** + * Returns height from latest block. + *

+ * @throws ForeignBlockchainException if error occurs + */ + public int getBlockchainHeight() throws ForeignBlockchainException { + int height = this.blockchainProvider.getCurrentHeight(); + return height; + } + /** Returns fee per transaction KB. To be overridden for testnet/regtest. */ public Coin getFeePerKb() { return this.bitcoinjContext.getFeePerKb(); From 64d835362993ef8a7615257bac178c05c139c555 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 2 Feb 2023 15:54:03 +0100 Subject: [PATCH 194/496] Added V2 support in the block archive, and added feature to rebuild a V1 block archive using V2 block serialization. Should drastically reduce the archive size once rebuilt. --- .../qortal/api/resource/BlocksResource.java | 12 +- src/main/java/org/qortal/block/Block.java | 4 + .../org/qortal/controller/Controller.java | 21 +++- .../repository/BlockArchiveRebuilder.java | 119 ++++++++++++++++++ .../network/message/CachedBlockV2Message.java | 43 +++++++ .../qortal/repository/BlockArchiveReader.java | 66 +++++++--- .../qortal/repository/BlockArchiveWriter.java | 109 ++++++++++++++-- .../transform/block/BlockTransformer.java | 24 ++-- 8 files changed, 359 insertions(+), 39 deletions(-) create mode 100644 src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java create mode 100644 src/main/java/org/qortal/network/message/CachedBlockV2Message.java diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index 15541802..20207c70 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -48,6 +48,7 @@ import org.qortal.repository.RepositoryManager; import org.qortal.transform.TransformationException; import org.qortal.transform.block.BlockTransformer; import org.qortal.utils.Base58; +import org.qortal.utils.Triple; @Path("/blocks") @Tag(name = "Blocks") @@ -165,10 +166,13 @@ public class BlocksResource { } // Not found, so try the block archive - byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, false, repository); - if (bytes != null) { - if (version != 1) { - throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Archived blocks require version 1"); + Triple serializedBlock = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, false, repository); + if (serializedBlock != null) { + byte[] bytes = serializedBlock.getA(); + Integer serializationVersion = serializedBlock.getB(); + if (version != serializationVersion) { + // TODO: we could quite easily reserialize the block with the requested version + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Block is not stored using requested serialization version."); } return Base58.encode(bytes); } diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 3f306b93..540f8cf7 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -657,6 +657,10 @@ public class Block { return this.atStates; } + public byte[] getAtStatesHash() { + return this.atStatesHash; + } + /** * Return expanded info on block's online accounts. *

diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index e9e1fcc2..ed1d2d07 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1379,9 +1379,24 @@ public class Controller extends Thread { // If we have no block data, we should check the archive in case it's there if (blockData == null) { if (Settings.getInstance().isArchiveEnabled()) { - byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, true, repository); - if (bytes != null) { - CachedBlockMessage blockMessage = new CachedBlockMessage(bytes); + Triple serializedBlock = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, true, repository); + if (serializedBlock != null) { + byte[] bytes = serializedBlock.getA(); + Integer serializationVersion = serializedBlock.getB(); + + Message blockMessage; + switch (serializationVersion) { + case 1: + blockMessage = new CachedBlockMessage(bytes); + break; + + case 2: + blockMessage = new CachedBlockV2Message(bytes); + break; + + default: + return; + } blockMessage.setId(message.getId()); // This call also causes the other needed data to be pulled in from repository diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java b/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java new file mode 100644 index 00000000..74201251 --- /dev/null +++ b/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java @@ -0,0 +1,119 @@ +package org.qortal.controller.repository; + +import org.apache.commons.io.FileUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.controller.Synchronizer; +import org.qortal.repository.*; +import org.qortal.settings.Settings; +import org.qortal.transform.TransformationException; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; + + +public class BlockArchiveRebuilder { + + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveRebuilder.class); + + private final int serializationVersion; + + public BlockArchiveRebuilder(int serializationVersion) { + this.serializationVersion = serializationVersion; + } + + public void start() throws DataException, IOException { + if (!Settings.getInstance().isArchiveEnabled() || Settings.getInstance().isLite()) { + return; + } + + // New archive path is in a different location from original archive path, to avoid conflicts. + // It will be moved later, once the process is complete. + final Path newArchivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive-rebuild"); + final Path originalArchivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive"); + + // Delete archive-rebuild if it exists from a previous attempt + FileUtils.deleteDirectory(newArchivePath.toFile()); + + try (final Repository repository = RepositoryManager.getRepository()) { + int startHeight = 1; // We need to rebuild the entire archive + + LOGGER.info("Rebuilding block archive from height {}...", startHeight); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(1000L); + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Synchronizer.getInstance().isSynchronizing()) { + continue; + } + + // Rebuild archive + try { + final int maximumArchiveHeight = BlockArchiveReader.getInstance().getHeightOfLastArchivedBlock(); + if (startHeight >= maximumArchiveHeight) { + // We've finished. + // Delete existing archive and move the newly built one into its place + FileUtils.deleteDirectory(originalArchivePath.toFile()); + FileUtils.moveDirectory(newArchivePath.toFile(), originalArchivePath.toFile()); + LOGGER.info("Block archive successfully rebuilt"); + return; + } + + BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, serializationVersion, newArchivePath, repository); + + // Set data source to BLOCK_ARCHIVE as we are rebuilding + writer.setDataSource(BlockArchiveWriter.BlockArchiveDataSource.BLOCK_ARCHIVE); + + // We can't enforce the 100MB file size target, as the final file needs to contain all blocks + // that exist in the current archive. Otherwise, the final blocks in the archive will be lost. + writer.setShouldEnforceFileSizeTarget(false); + + // We want to log the rebuild progress + writer.setShouldLogProgress(true); + + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + switch (result) { + case OK: + // Increment block archive height + startHeight += writer.getWrittenCount(); + repository.saveChanges(); + break; + + case STOPPING: + return; + + // We've reached the limit of the blocks we can archive + // Sleep for a while to allow more to become available + case NOT_ENOUGH_BLOCKS: + // This shouldn't happen, as we're not enforcing minimum file sizes + repository.discardChanges(); + throw new DataException("Unable to rebuild archive due to unexpected NOT_ENOUGH_BLOCKS response."); + + case BLOCK_NOT_FOUND: + // We tried to archive a block that didn't exist. This is a major failure and likely means + // that a bootstrap or re-sync is needed. Try again every minute until then. + LOGGER.info("Error: block not found when rebuilding archive. If this error persists, " + + "a bootstrap or re-sync may be needed."); + repository.discardChanges(); + throw new DataException("Unable to rebuild archive because a block is missing."); + } + + } catch (IOException | TransformationException e) { + LOGGER.info("Caught exception when rebuilding block archive", e); + } + + } + } catch (InterruptedException e) { + // Do nothing + } finally { + // Delete archive-rebuild if it still exists, as that means something went wrong + FileUtils.deleteDirectory(newArchivePath.toFile()); + } + } + +} diff --git a/src/main/java/org/qortal/network/message/CachedBlockV2Message.java b/src/main/java/org/qortal/network/message/CachedBlockV2Message.java new file mode 100644 index 00000000..c981293d --- /dev/null +++ b/src/main/java/org/qortal/network/message/CachedBlockV2Message.java @@ -0,0 +1,43 @@ +package org.qortal.network.message; + +import com.google.common.primitives.Ints; +import org.qortal.block.Block; +import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +// This is an OUTGOING-only Message which more readily lends itself to being cached +public class CachedBlockV2Message extends Message implements Cloneable { + + public CachedBlockV2Message(Block block) throws TransformationException { + super(MessageType.BLOCK_V2); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try { + bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); + + bytes.write(BlockTransformer.toBytes(block)); + } catch (IOException e) { + throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); + } + + this.dataBytes = bytes.toByteArray(); + this.checksumBytes = Message.generateChecksum(this.dataBytes); + } + + public CachedBlockV2Message(byte[] cachedBytes) { + super(MessageType.BLOCK_V2); + + this.dataBytes = cachedBytes; + this.checksumBytes = Message.generateChecksum(this.dataBytes); + } + + public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) { + throw new UnsupportedOperationException("CachedBlockMessageV2 is for outgoing messages only"); + } + +} diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index 311d21c7..c5878563 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -3,10 +3,7 @@ package org.qortal.repository; import com.google.common.primitives.Ints; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockArchiveData; -import org.qortal.data.block.BlockData; -import org.qortal.data.transaction.TransactionData; import org.qortal.settings.Settings; import org.qortal.transform.TransformationException; import org.qortal.transform.block.BlockTransformation; @@ -72,15 +69,30 @@ public class BlockArchiveReader { this.fetchFileList(); } - byte[] serializedBytes = this.fetchSerializedBlockBytesForHeight(height); - if (serializedBytes == null) { + Triple serializedBlock = this.fetchSerializedBlockBytesForHeight(height); + byte[] serializedBytes = serializedBlock.getA(); + Integer serializationVersion = serializedBlock.getB(); + if (serializedBytes == null || serializationVersion == null) { return null; } ByteBuffer byteBuffer = ByteBuffer.wrap(serializedBytes); BlockTransformation blockInfo = null; try { - blockInfo = BlockTransformer.fromByteBuffer(byteBuffer); + switch (serializationVersion) { + case 1: + blockInfo = BlockTransformer.fromByteBuffer(byteBuffer); + break; + + case 2: + blockInfo = BlockTransformer.fromByteBufferV2(byteBuffer); + break; + + default: + // Invalid serialization version + return null; + } + if (blockInfo != null && blockInfo.getBlockData() != null) { // Block height is stored outside of the main serialized bytes, so it // won't be set automatically. @@ -168,15 +180,17 @@ public class BlockArchiveReader { return null; } - public byte[] fetchSerializedBlockBytesForSignature(byte[] signature, boolean includeHeightPrefix, Repository repository) { + public Triple fetchSerializedBlockBytesForSignature(byte[] signature, boolean includeHeightPrefix, Repository repository) { if (this.fileListCache == null) { this.fetchFileList(); } Integer height = this.fetchHeightForSignature(signature, repository); if (height != null) { - byte[] blockBytes = this.fetchSerializedBlockBytesForHeight(height); - if (blockBytes == null) { + Triple serializedBlock = this.fetchSerializedBlockBytesForHeight(height); + byte[] blockBytes = serializedBlock.getA(); + Integer version = serializedBlock.getB(); + if (blockBytes == null || version == null) { return null; } @@ -187,18 +201,18 @@ public class BlockArchiveReader { try { bytes.write(Ints.toByteArray(height)); bytes.write(blockBytes); - return bytes.toByteArray(); + return new Triple<>(bytes.toByteArray(), version, height); } catch (IOException e) { return null; } } - return blockBytes; + return new Triple<>(blockBytes, version, height); } return null; } - public byte[] fetchSerializedBlockBytesForHeight(int height) { + public Triple fetchSerializedBlockBytesForHeight(int height) { String filename = this.getFilenameForHeight(height); if (filename == null) { // We don't have this block in the archive @@ -221,7 +235,7 @@ public class BlockArchiveReader { // End of fixed length header // Make sure the version is one we recognize - if (version != 1) { + if (version != 1 && version != 2) { LOGGER.info("Error: unknown version in file {}: {}", filename, version); return null; } @@ -258,7 +272,7 @@ public class BlockArchiveReader { byte[] blockBytes = new byte[blockLength]; file.read(blockBytes); - return blockBytes; + return new Triple<>(blockBytes, version, height); } catch (FileNotFoundException e) { LOGGER.info("File {} not found: {}", filename, e.getMessage()); @@ -279,6 +293,30 @@ public class BlockArchiveReader { } } + public int getHeightOfLastArchivedBlock() { + if (this.fileListCache == null) { + this.fetchFileList(); + } + + int maxEndHeight = 0; + + Iterator it = this.fileListCache.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry pair = (Map.Entry) it.next(); + if (pair == null && pair.getKey() == null && pair.getValue() == null) { + continue; + } + Triple heightInfo = (Triple) pair.getValue(); + Integer endHeight = heightInfo.getB(); + + if (endHeight != null && endHeight > maxEndHeight) { + maxEndHeight = endHeight; + } + } + + return maxEndHeight; + } + public void invalidateFileListCache() { this.fileListCache = null; } diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 5127bf9b..c2eb17c9 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -6,10 +6,13 @@ import org.apache.logging.log4j.Logger; import org.qortal.block.Block; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; +import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockArchiveData; import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.TransactionData; import org.qortal.settings.Settings; import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformation; import org.qortal.transform.block.BlockTransformer; import java.io.ByteArrayOutputStream; @@ -18,6 +21,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; public class BlockArchiveWriter { @@ -28,27 +32,57 @@ public class BlockArchiveWriter { BLOCK_NOT_FOUND } + public enum BlockArchiveDataSource { + BLOCK_REPOSITORY, // To build an archive from the Blocks table + BLOCK_ARCHIVE // To build a new archive from an existing archive + } + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveWriter.class); public static final long DEFAULT_FILE_SIZE_TARGET = 100 * 1024 * 1024; // 100MiB private int startHeight; private final int endHeight; + private final int serializationVersion; + private final Path archivePath; private final Repository repository; private long fileSizeTarget = DEFAULT_FILE_SIZE_TARGET; private boolean shouldEnforceFileSizeTarget = true; + // Default data source to BLOCK_REPOSITORY; can optionally be overridden + private BlockArchiveDataSource dataSource = BlockArchiveDataSource.BLOCK_REPOSITORY; + + private boolean shouldLogProgress = false; + private int writtenCount; private int lastWrittenHeight; private Path outputPath; - public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { + /** + * Instantiate a BlockArchiveWriter using a custom archive path + * @param startHeight + * @param endHeight + * @param repository + */ + public BlockArchiveWriter(int startHeight, int endHeight, int serializationVersion, Path archivePath, Repository repository) { this.startHeight = startHeight; this.endHeight = endHeight; + this.serializationVersion = serializationVersion; + this.archivePath = archivePath.toAbsolutePath(); this.repository = repository; } + /** + * Instantiate a BlockArchiveWriter using the default archive path and version + * @param startHeight + * @param endHeight + * @param repository + */ + public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { + this(startHeight, endHeight, 1, Paths.get(Settings.getInstance().getRepositoryPath(), "archive"), repository); + } + public static int getMaxArchiveHeight(Repository repository) throws DataException { // We must only archive trimmed blocks, or the archive will grow far too large final int accountSignaturesTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); @@ -72,8 +106,7 @@ public class BlockArchiveWriter { public BlockArchiveWriteResult write() throws DataException, IOException, TransformationException, InterruptedException { // Create the archive folder if it doesn't exist - // This is a subfolder of the db directory, to make bootstrapping easier - Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + // This is generally a subfolder of the db directory, to make bootstrapping easier try { Files.createDirectories(archivePath); } catch (IOException e) { @@ -95,8 +128,7 @@ public class BlockArchiveWriter { LOGGER.info(String.format("Fetching blocks from height %d...", startHeight)); int i = 0; - while (headerBytes.size() + bytes.size() < this.fileSizeTarget - || this.shouldEnforceFileSizeTarget == false) { + while (headerBytes.size() + bytes.size() < this.fileSizeTarget) { if (Controller.isStopping()) { return BlockArchiveWriteResult.STOPPING; @@ -112,7 +144,28 @@ public class BlockArchiveWriter { //LOGGER.info("Fetching block {}...", currentHeight); - BlockData blockData = repository.getBlockRepository().fromHeight(currentHeight); + BlockData blockData = null; + List transactions = null; + List atStates = null; + byte[] atStatesHash = null; + + switch (this.dataSource) { + case BLOCK_ARCHIVE: + BlockTransformation archivedBlock = BlockArchiveReader.getInstance().fetchBlockAtHeight(currentHeight); + if (archivedBlock != null) { + blockData = archivedBlock.getBlockData(); + transactions = archivedBlock.getTransactions(); + atStates = archivedBlock.getAtStates(); + atStatesHash = archivedBlock.getAtStatesHash(); + } + break; + + case BLOCK_REPOSITORY: + default: + blockData = repository.getBlockRepository().fromHeight(currentHeight); + break; + } + if (blockData == null) { return BlockArchiveWriteResult.BLOCK_NOT_FOUND; } @@ -122,18 +175,47 @@ public class BlockArchiveWriter { repository.getBlockArchiveRepository().save(blockArchiveData); repository.saveChanges(); + // Build the block + Block block; + if (atStatesHash != null) { + block = new Block(repository, blockData, transactions, atStatesHash); + } + else { + block = new Block(repository, blockData, transactions, atStates); + } + // Write the block data to some byte buffers - Block block = new Block(repository, blockData); int blockIndex = bytes.size(); // Write block index to header headerBytes.write(Ints.toByteArray(blockIndex)); // Write block height bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); - byte[] blockBytes = BlockTransformer.toBytes(block); + + // Get serialized block bytes + byte[] blockBytes; + switch (serializationVersion) { + case 1: + blockBytes = BlockTransformer.toBytes(block); + break; + + case 2: + blockBytes = BlockTransformer.toBytesV2(block); + break; + + default: + throw new DataException("Invalid serialization version"); + } + // Write block length bytes.write(Ints.toByteArray(blockBytes.length)); // Write block bytes bytes.write(blockBytes); + + // Log every 1000 blocks + if (this.shouldLogProgress && i % 1000 == 0) { + LOGGER.info("Archived up to block height {}. Size of current file: {} bytes", currentHeight, (headerBytes.size() + bytes.size())); + } + i++; } @@ -147,11 +229,10 @@ public class BlockArchiveWriter { // We have enough blocks to create a new file int endHeight = startHeight + i - 1; - int version = 1; String filePath = String.format("%s/%d-%d.dat", archivePath.toString(), startHeight, endHeight); FileOutputStream fileOutputStream = new FileOutputStream(filePath); // Write version number - fileOutputStream.write(Ints.toByteArray(version)); + fileOutputStream.write(Ints.toByteArray(serializationVersion)); // Write start height fileOutputStream.write(Ints.toByteArray(startHeight)); // Write end height @@ -199,4 +280,12 @@ public class BlockArchiveWriter { this.shouldEnforceFileSizeTarget = shouldEnforceFileSizeTarget; } + public void setDataSource(BlockArchiveDataSource dataSource) { + this.dataSource = dataSource; + } + + public void setShouldLogProgress(boolean shouldLogProgress) { + this.shouldLogProgress = shouldLogProgress; + } + } diff --git a/src/main/java/org/qortal/transform/block/BlockTransformer.java b/src/main/java/org/qortal/transform/block/BlockTransformer.java index c97aa090..15445327 100644 --- a/src/main/java/org/qortal/transform/block/BlockTransformer.java +++ b/src/main/java/org/qortal/transform/block/BlockTransformer.java @@ -312,16 +312,24 @@ public class BlockTransformer extends Transformer { ByteArrayOutputStream atHashBytes = new ByteArrayOutputStream(atBytesLength); long atFees = 0; - for (ATStateData atStateData : block.getATStates()) { - // Skip initial states generated by DEPLOY_AT transactions in the same block - if (atStateData.isInitial()) - continue; + if (block.getAtStatesHash() != null) { + // We already have the AT states hash + atFees = blockData.getATFees(); + atHashBytes.write(block.getAtStatesHash()); + } + else { + // We need to build the AT states hash + for (ATStateData atStateData : block.getATStates()) { + // Skip initial states generated by DEPLOY_AT transactions in the same block + if (atStateData.isInitial()) + continue; - atHashBytes.write(atStateData.getATAddress().getBytes(StandardCharsets.UTF_8)); - atHashBytes.write(atStateData.getStateHash()); - atHashBytes.write(Longs.toByteArray(atStateData.getFees())); + atHashBytes.write(atStateData.getATAddress().getBytes(StandardCharsets.UTF_8)); + atHashBytes.write(atStateData.getStateHash()); + atHashBytes.write(Longs.toByteArray(atStateData.getFees())); - atFees += atStateData.getFees(); + atFees += atStateData.getFees(); + } } bytes.write(Ints.toByteArray(blockData.getATCount())); From d27316eb64103f79c7729be0c53118b72bce464e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 2 Feb 2023 18:11:56 +0100 Subject: [PATCH 195/496] Clear cache after rebuilding. --- .../org/qortal/controller/repository/BlockArchiveRebuilder.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java b/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java index 74201251..78616a99 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java @@ -60,6 +60,7 @@ public class BlockArchiveRebuilder { // Delete existing archive and move the newly built one into its place FileUtils.deleteDirectory(originalArchivePath.toFile()); FileUtils.moveDirectory(newArchivePath.toFile(), originalArchivePath.toFile()); + BlockArchiveReader.getInstance().invalidateFileListCache(); LOGGER.info("Block archive successfully rebuilt"); return; } From 257ca2da05bbbc6a7e4e538c18304cee55179f4b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Feb 2023 12:36:57 +0100 Subject: [PATCH 196/496] Bumped default block archive serialization version to V2. --- src/main/java/org/qortal/repository/BlockArchiveWriter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index c2eb17c9..2eb4c6a6 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -80,7 +80,7 @@ public class BlockArchiveWriter { * @param repository */ public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { - this(startHeight, endHeight, 1, Paths.get(Settings.getInstance().getRepositoryPath(), "archive"), repository); + this(startHeight, endHeight, 2, Paths.get(Settings.getInstance().getRepositoryPath(), "archive"), repository); } public static int getMaxArchiveHeight(Repository repository) throws DataException { From ae5b713e5803aca370e297b10805725bbdbeae7a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 14:32:33 +0000 Subject: [PATCH 197/496] Rework of AT state trimming and pruning, in order to more reliably track the "latest" AT states. This should fix an edge case where AT states data was pruned/trimmed but it was then later required in consensus. The older state was deleted because it was replaced by a new "latest" state in a brand new block. But once the new "latest" state was orphaned from the block, the old "latest" state was then required again. This works around the problem by excluding very recent blocks in the latest AT states data, so that it is unaffected by real-time sync activity. The trade off is that we could end up retaining more AT states than needed, so a secondary cleanup process may need to run at some time in the future to remove these. But it should only be a minimal amount of data, and can be cleaned up with a single query. This would have been happening to a certain degree already. # Conflicts: # src/main/java/org/qortal/controller/repository/AtStatesPruner.java # src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java --- .../controller/repository/AtStatesPruner.java | 6 +- .../repository/AtStatesTrimmer.java | 6 +- .../controller/repository/PruneManager.java | 14 ++ .../org/qortal/repository/ATRepository.java | 2 +- .../repository/hsqldb/HSQLDBATRepository.java | 5 +- .../hsqldb/HSQLDBDatabasePruning.java | 2 +- .../org/qortal/test/BlockArchiveTests.java | 26 ++-- .../java/org/qortal/test/BootstrapTests.java | 3 +- src/test/java/org/qortal/test/PruneTests.java | 143 +++++++++++++++++- .../org/qortal/test/at/AtRepositoryTests.java | 19 +-- .../org/qortal/test/common/BlockUtils.java | 9 ++ 11 files changed, 197 insertions(+), 38 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index 064fe0ea..f06efdb8 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -39,9 +39,10 @@ public class AtStatesPruner implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); + int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); repository.discardChanges(); - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); while (!Controller.isStopping()) { @@ -92,7 +93,8 @@ public class AtStatesPruner implements Runnable { if (upperPrunableHeight > upperBatchHeight) { pruneStartHeight = upperBatchHeight; repository.getATRepository().setAtPruneHeight(pruneStartHeight); - repository.getATRepository().rebuildLatestAtStates(); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); final int finalPruneStartHeight = pruneStartHeight; diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index 6c026385..125628f1 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -26,9 +26,10 @@ public class AtStatesTrimmer implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int trimStartHeight = repository.getATRepository().getAtTrimHeight(); + int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); repository.discardChanges(); - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); while (!Controller.isStopping()) { @@ -70,7 +71,8 @@ public class AtStatesTrimmer implements Runnable { if (upperTrimmableHeight > upperBatchHeight) { trimStartHeight = upperBatchHeight; repository.getATRepository().setAtTrimHeight(trimStartHeight); - repository.getATRepository().rebuildLatestAtStates(); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); final int finalTrimStartHeight = trimStartHeight; diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index ec27456f..dfb6290b 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -157,4 +157,18 @@ public class PruneManager { return (height < latestUnprunedHeight); } + /** + * When rebuilding the latest AT states, we need to specify a maxHeight, so that we aren't tracking + * very recent AT states that could potentially be orphaned. This method ensures that AT states + * are given a sufficient number of blocks to confirm before being tracked as a latest AT state. + */ + public static int getMaxHeightForLatestAtStates(Repository repository) throws DataException { + // Get current chain height, and subtract a certain number of "confirmation" blocks + // This is to ensure we are basing our latest AT states data on confirmed blocks - + // ones that won't be orphaned in any normal circumstances + final int confirmationBlocks = 250; + final int chainHeight = repository.getBlockRepository().getBlockchainHeight(); + return chainHeight - confirmationBlocks; + } + } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 0f537ae9..93da924c 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -119,7 +119,7 @@ public interface ATRepository { *

* NOTE: performs implicit repository.saveChanges(). */ - public void rebuildLatestAtStates() throws DataException; + public void rebuildLatestAtStates(int maxHeight) throws DataException; /** Returns height of first trimmable AT state. */ diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 04823925..dd0404a8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -603,7 +603,7 @@ public class HSQLDBATRepository implements ATRepository { @Override - public void rebuildLatestAtStates() throws DataException { + public void rebuildLatestAtStates(int maxHeight) throws DataException { // latestATStatesLock is to prevent concurrent updates on LatestATStates // that could result in one process using a partial or empty dataset // because it was in the process of being rebuilt by another thread @@ -624,11 +624,12 @@ public class HSQLDBATRepository implements ATRepository { + "CROSS JOIN LATERAL(" + "SELECT height FROM ATStates " + "WHERE ATStates.AT_address = ATs.AT_address " + + "AND height <= ?" + "ORDER BY AT_address DESC, height DESC LIMIT 1" + ") " + ")"; try { - this.repository.executeCheckedUpdate(insertSql); + this.repository.executeCheckedUpdate(insertSql, maxHeight); } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to populate temporary latest AT states cache in repository", e); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 978ba25e..e2bfc9ef 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -99,7 +99,7 @@ public class HSQLDBDatabasePruning { // It's essential that we rebuild the latest AT states here, as we are using this data in the next query. // Failing to do this will result in important AT states being deleted, rendering the database unusable. - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(endHeight); // Loop through all the LatestATStates and copy them to the new table diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java index 3bfa4e84..8b3de67b 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -23,7 +23,6 @@ import org.qortal.transform.TransformationException; import org.qortal.transform.block.BlockTransformation; import org.qortal.utils.BlockArchiveUtils; import org.qortal.utils.NTP; -import org.qortal.utils.Triple; import java.io.File; import java.io.IOException; @@ -314,9 +313,10 @@ public class BlockArchiveTests extends Common { repository.getBlockRepository().setBlockPruneHeight(901); // Prune the AT states for the archived blocks - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(900); + repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 900); - assertEquals(900-1, numATStatesPruned); + assertEquals(900-2, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state repository.getATRepository().setAtPruneHeight(901); // Now ensure the SQL repository is missing blocks 2 and 900... @@ -563,16 +563,23 @@ public class BlockArchiveTests extends Common { // Trim the first 500 blocks repository.getBlockRepository().trimOldOnlineAccountsSignatures(0, 500); repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(501); + repository.getATRepository().rebuildLatestAtStates(500); repository.getATRepository().trimAtStates(0, 500, 1000); repository.getATRepository().setAtTrimHeight(501); - // Now block 500 should only have the AT state data hash - block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); - atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + // Now block 499 should only have the AT state data hash + List block499AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(499); + atStatesData = repository.getATRepository().getATStateAtHeight(block499AtStatesData.get(0).getATAddress(), 499); assertNotNull(atStatesData.getStateHash()); assertNull(atStatesData.getStateData()); - // ... but block 501 should have the full data + // ... but block 500 should have the full data (due to being retained as the "latest" AT state in the trimmed range + block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // ... and block 501 should also have the full data List block501AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(501); atStatesData = repository.getATRepository().getATStateAtHeight(block501AtStatesData.get(0).getATAddress(), 501); assertNotNull(atStatesData.getStateHash()); @@ -612,9 +619,10 @@ public class BlockArchiveTests extends Common { repository.getBlockRepository().setBlockPruneHeight(501); // Prune the AT states for the archived blocks - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(500); + repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(2, 500); - assertEquals(499, numATStatesPruned); + assertEquals(498, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state repository.getATRepository().setAtPruneHeight(501); // Now ensure the SQL repository is missing blocks 2 and 500... diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java index aa641e71..b60b412c 100644 --- a/src/test/java/org/qortal/test/BootstrapTests.java +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -176,7 +176,8 @@ public class BootstrapTests extends Common { repository.getBlockRepository().setBlockPruneHeight(901); // Prune the AT states for the archived blocks - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(900); + repository.saveChanges(); repository.getATRepository().pruneAtStates(0, 900); repository.getATRepository().setAtPruneHeight(901); diff --git a/src/test/java/org/qortal/test/PruneTests.java b/src/test/java/org/qortal/test/PruneTests.java index 0914d794..5a31146e 100644 --- a/src/test/java/org/qortal/test/PruneTests.java +++ b/src/test/java/org/qortal/test/PruneTests.java @@ -1,16 +1,33 @@ package org.qortal.test; +import com.google.common.hash.HashCode; import org.junit.Before; import org.junit.Test; +import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.block.Block; import org.qortal.controller.BlockMinter; +import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.LitecoinACCTv3; +import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +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; import org.qortal.test.common.AtUtils; +import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; import java.util.ArrayList; import java.util.List; @@ -19,6 +36,13 @@ import static org.junit.Assert.*; public class PruneTests extends Common { + // Constants for test AT (an LTC ACCT) + public static final byte[] litecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); + public static final int tradeTimeout = 20; // blocks + public static final long redeemAmount = 80_40200000L; + public static final long fundingAmount = 123_45600000L; + public static final long litecoinAmount = 864200L; // 0.00864200 LTC + @Before public void beforeTest() throws DataException { Common.useDefaultSettings(); @@ -62,23 +86,32 @@ public class PruneTests extends Common { repository.getBlockRepository().setBlockPruneHeight(6); // Prune AT states for blocks 2-5 + repository.getATRepository().rebuildLatestAtStates(5); + repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 5); - assertEquals(4, numATStatesPruned); + assertEquals(3, numATStatesPruned); repository.getATRepository().setAtPruneHeight(6); - // Make sure that blocks 2-5 are now missing block data and AT states data - for (Integer i=2; i <= 5; i++) { + // Make sure that blocks 2-4 are now missing block data and AT states data + for (Integer i=2; i <= 4; i++) { BlockData blockData = repository.getBlockRepository().fromHeight(i); assertNull(blockData); List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); assertTrue(atStatesDataList.isEmpty()); } - // ... but blocks 6-10 have block data and full AT states data + // Block 5 should have full AT states data even though it was pruned. + // This is because we identified that as the "latest" AT state in that block range + BlockData blockData = repository.getBlockRepository().fromHeight(5); + assertNull(blockData); + List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(5); + assertEquals(1, atStatesDataList.size()); + + // Blocks 6-10 have block data and full AT states data for (Integer i=6; i <= 10; i++) { - BlockData blockData = repository.getBlockRepository().fromHeight(i); + blockData = repository.getBlockRepository().fromHeight(i); assertNotNull(blockData.getSignature()); - List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); + atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); assertNotNull(atStatesDataList); assertFalse(atStatesDataList.isEmpty()); ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(atStatesDataList.get(0).getATAddress(), i); @@ -88,4 +121,102 @@ public class PruneTests extends Common { } } + @Test + public void testPruneSleepingAt() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = Common.getTestAccount(repository, "alice"); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + // Mint enough blocks to take the original DEPLOY_AT past the prune threshold (in this case 20) + Block block = BlockUtils.mintBlocks(repository, 25); + + // Send creator's address to AT, instead of typical partner's address + byte[] messageData = LitecoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); + long txTimestamp = block.getBlockData().getTimestamp(); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress, txTimestamp); + + // AT should process 'cancel' message in next block + BlockUtils.mintBlock(repository); + + // Prune AT states up to block 20 + repository.getATRepository().rebuildLatestAtStates(20); + repository.saveChanges(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 20); + assertEquals(1, numATStatesPruned); // deleted state at heights 2, but state at height 3 remains + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + + // Test orphaning - should be possible because the previous AT state at height 3 is still available + BlockUtils.orphanLastBlock(repository); + } + } + + + // Helper methods for AT testing + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = LitecoinACCTv3.buildQortalAT(tradeAddress, litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-LTC cross-chain trade"; + String description = String.format("Qortal-Litecoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-LTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient, long txTimestamp) throws DataException { + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } } diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index 8ef4c774..8441731f 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -2,29 +2,20 @@ package org.qortal.test.at; import static org.junit.Assert.*; -import java.nio.ByteBuffer; import java.util.List; -import org.ciyam.at.CompilationException; import org.ciyam.at.MachineState; -import org.ciyam.at.OpCode; import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -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; import org.qortal.test.common.AtUtils; import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; import org.qortal.transaction.DeployAtTransaction; public class AtRepositoryTests extends Common { @@ -76,7 +67,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxHeight); repository.getATRepository().trimAtStates(2, maxHeight, 1000); ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); @@ -130,7 +121,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = blockchainHeight; // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxHeight); // COMMIT to check latest AT states persist / TEMPORARY table interaction repository.saveChanges(); @@ -163,8 +154,8 @@ public class AtRepositoryTests extends Common { int maxTrimHeight = blockchainHeight - 4; Integer testHeight = maxTrimHeight + 1; - // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + // Trim AT state data (using a max height of maxTrimHeight + 1, so it is beyond the trimmed range) + repository.getATRepository().rebuildLatestAtStates(maxTrimHeight + 1); repository.saveChanges(); repository.getATRepository().trimAtStates(2, maxTrimHeight, 1000); @@ -333,7 +324,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxHeight); repository.getATRepository().trimAtStates(2, maxHeight, 1000); List atStates = repository.getATRepository().getBlockATStatesAtHeight(testHeight); diff --git a/src/test/java/org/qortal/test/common/BlockUtils.java b/src/test/java/org/qortal/test/common/BlockUtils.java index 3077b65b..ab57dadf 100644 --- a/src/test/java/org/qortal/test/common/BlockUtils.java +++ b/src/test/java/org/qortal/test/common/BlockUtils.java @@ -20,6 +20,15 @@ public class BlockUtils { return BlockMinter.mintTestingBlock(repository, mintingAccount); } + /** Mints multiple blocks using "alice-reward-share" test account, and returns the final block. */ + public static Block mintBlocks(Repository repository, int count) throws DataException { + Block block = null; + for (int i=0; i Date: Sat, 4 Feb 2023 18:30:31 -0500 Subject: [PATCH 198/496] Updated ElectrumX servers --- .../java/org/qortal/crosschain/Bitcoin.java | 54 +++++++++++++++++-- .../java/org/qortal/crosschain/Digibyte.java | 1 + .../java/org/qortal/crosschain/Dogecoin.java | 7 +-- .../java/org/qortal/crosschain/Litecoin.java | 9 ++-- .../java/org/qortal/crosschain/Ravencoin.java | 12 +++-- 5 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/Bitcoin.java b/src/main/java/org/qortal/crosschain/Bitcoin.java index 7fec5a17..b65bac8e 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoin.java +++ b/src/main/java/org/qortal/crosschain/Bitcoin.java @@ -49,6 +49,7 @@ public class Bitcoin extends Bitcoiny { //CLOSED new Server("bitcoin.grey.pw", Server.ConnectionType.SSL, 50002), //CLOSED new Server("btc.litepay.ch", Server.ConnectionType.SSL, 50002), //CLOSED new Server("electrum.pabu.io", Server.ConnectionType.SSL, 50002), + //CLOSED new Server("electrumx.dev", Server.ConnectionType.SSL, 50002), //CLOSED new Server("electrumx.hodlwallet.com", Server.ConnectionType.SSL, 50002), //CLOSED new Server("gd42.org", Server.ConnectionType.SSL, 50002), //CLOSED new Server("korea.electrum-server.com", Server.ConnectionType.SSL, 50002), @@ -56,28 +57,75 @@ public class Bitcoin extends Bitcoiny { //1.15.0 new Server("alviss.coinjoined.com", Server.ConnectionType.SSL, 50002), //1.15.0 new Server("electrum.acinq.co", Server.ConnectionType.SSL, 50002), //1.14.0 new Server("electrum.coinext.com.br", Server.ConnectionType.SSL, 50002), + //F1.7.0 new Server("btc.lastingcoin.net", Server.ConnectionType.SSL, 50002), new Server("104.248.139.211", Server.ConnectionType.SSL, 50002), + new Server("128.0.190.26", Server.ConnectionType.SSL, 50002), new Server("142.93.6.38", Server.ConnectionType.SSL, 50002), new Server("157.245.172.236", Server.ConnectionType.SSL, 50002), new Server("167.172.226.175", Server.ConnectionType.SSL, 50002), new Server("167.172.42.31", Server.ConnectionType.SSL, 50002), new Server("178.62.80.20", Server.ConnectionType.SSL, 50002), new Server("185.64.116.15", Server.ConnectionType.SSL, 50002), + new Server("188.165.206.215", Server.ConnectionType.SSL, 50002), + new Server("188.165.211.112", Server.ConnectionType.SSL, 50002), + new Server("2azzarita.hopto.org", Server.ConnectionType.SSL, 50002), + new Server("2electrumx.hopto.me", Server.ConnectionType.SSL, 56022), + new Server("2ex.digitaleveryware.com", Server.ConnectionType.SSL, 50002), + new Server("65.39.140.37", Server.ConnectionType.SSL, 50002), new Server("68.183.188.105", Server.ConnectionType.SSL, 50002), + new Server("71.73.14.254", Server.ConnectionType.SSL, 50002), + new Server("94.23.247.135", Server.ConnectionType.SSL, 50002), + new Server("assuredly.not.fyi", Server.ConnectionType.SSL, 50002), + new Server("ax101.blockeng.ch", Server.ConnectionType.SSL, 50002), + new Server("ax102.blockeng.ch", Server.ConnectionType.SSL, 50002), + new Server("b.1209k.com", Server.ConnectionType.SSL, 50002), + new Server("b6.1209k.com", Server.ConnectionType.SSL, 50002), + new Server("bitcoin.dermichi.com", Server.ConnectionType.SSL, 50002), + new Server("bitcoin.lu.ke", Server.ConnectionType.SSL, 50002), new Server("bitcoin.lukechilds.co", Server.ConnectionType.SSL, 50002), new Server("blkhub.net", Server.ConnectionType.SSL, 50002), - new Server("btc.lastingcoin.net", Server.ConnectionType.SSL, 50002), + new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002), + new Server("btc.ocf.sh", Server.ConnectionType.SSL, 50002), new Server("btce.iiiiiii.biz", Server.ConnectionType.SSL, 50002), new Server("caleb.vegas", Server.ConnectionType.SSL, 50002), new Server("eai.coincited.net", Server.ConnectionType.SSL, 50002), + new Server("electrum.bhoovd.com", Server.ConnectionType.SSL, 50002), new Server("electrum.bitaroo.net", Server.ConnectionType.SSL, 50002), - new Server("electrumx.dev", Server.ConnectionType.SSL, 50002), + new Server("electrum.bitcoinlizard.net", Server.ConnectionType.SSL, 50002), + new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 50002), + new Server("electrum.emzy.de", Server.ConnectionType.SSL, 50002), + new Server("electrum.exan.tech", Server.ConnectionType.SSL, 50002), + new Server("electrum.kendigisland.xyz", Server.ConnectionType.SSL, 50002), + new Server("electrum.mmitech.info", Server.ConnectionType.SSL, 50002), + new Server("electrum.petrkr.net", Server.ConnectionType.SSL, 50002), + new Server("electrum.stippy.com", Server.ConnectionType.SSL, 50002), + new Server("electrum.thomasfischbach.de", Server.ConnectionType.SSL, 50002), + new Server("electrum0.snel.it", Server.ConnectionType.SSL, 50002), + new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 50002), + new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 50002), + new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 50002), + new Server("electrumx.alexridevski.net", Server.ConnectionType.SSL, 50002), + new Server("electrumx-core.1209k.com", Server.ConnectionType.SSL, 50002), new Server("elx.bitske.com", Server.ConnectionType.SSL, 50002), + new Server("ex03.axalgo.com", Server.ConnectionType.SSL, 50002), + new Server("ex05.axalgo.com", Server.ConnectionType.SSL, 50002), + new Server("ex07.axalgo.com", Server.ConnectionType.SSL, 50002), new Server("fortress.qtornado.com", Server.ConnectionType.SSL, 50002), + new Server("fulcrum.grey.pw", Server.ConnectionType.SSL, 50002), + new Server("fulcrum.sethforprivacy.com", Server.ConnectionType.SSL, 51002), new Server("guichet.centure.cc", Server.ConnectionType.SSL, 50002), - new Server("kareoke.qoppa.org", Server.ConnectionType.SSL, 50002), new Server("hodlers.beer", Server.ConnectionType.SSL, 50002), + new Server("kareoke.qoppa.org", Server.ConnectionType.SSL, 50002), + new Server("kirsche.emzy.de", Server.ConnectionType.SSL, 50002), new Server("node1.btccuracao.com", Server.ConnectionType.SSL, 50002), + new Server("osr1ex1.compumundohipermegared.one", Server.ConnectionType.SSL, 50002), + new Server("smmalis37.ddns.net", Server.ConnectionType.SSL, 50002), + new Server("ulrichard.ch", Server.ConnectionType.SSL, 50002), + new Server("vmd104012.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("vmd104014.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("vmd63185.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("vmd71287.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("vmd84592.contaboserver.net", Server.ConnectionType.SSL, 50002), new Server("xtrum.com", Server.ConnectionType.SSL, 50002)); } diff --git a/src/main/java/org/qortal/crosschain/Digibyte.java b/src/main/java/org/qortal/crosschain/Digibyte.java index 4358b3b3..42ab2a5a 100644 --- a/src/main/java/org/qortal/crosschain/Digibyte.java +++ b/src/main/java/org/qortal/crosschain/Digibyte.java @@ -45,6 +45,7 @@ public class Digibyte extends Bitcoiny { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=dgb + new Server("electrum-dgb.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20059), new Server("electrum2.cipig.net", ConnectionType.SSL, 20059), new Server("electrum3.cipig.net", ConnectionType.SSL, 20059)); diff --git a/src/main/java/org/qortal/crosschain/Dogecoin.java b/src/main/java/org/qortal/crosschain/Dogecoin.java index 9af8d990..5bdbeed8 100644 --- a/src/main/java/org/qortal/crosschain/Dogecoin.java +++ b/src/main/java/org/qortal/crosschain/Dogecoin.java @@ -45,11 +45,12 @@ public class Dogecoin extends Bitcoiny { public Collection getServers() { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! + // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=doge + new Server("161.97.137.235", ConnectionType.SSL, 50002), + new Server("electrum-doge.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20060), new Server("electrum2.cipig.net", ConnectionType.SSL, 20060), - new Server("electrum3.cipig.net", ConnectionType.SSL, 20060), - new Server("161.97.137.235", ConnectionType.SSL, 50002)); - // TODO: add more mainnet servers. It's too centralized. + new Server("electrum3.cipig.net", ConnectionType.SSL, 20060)); } @Override diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 6fc6ba50..33650d81 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -45,17 +45,18 @@ public class Litecoin extends Bitcoiny { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=ltc - //CLOSED new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002), //CLOSED new Server("electrum-ltc.someguy123.net", Server.ConnectionType.SSL, 50002), + //CLOSED new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022), + //BEHIND new Server("62.171.169.176", Server.ConnectionType.SSL, 50002), //PHISHY new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 50002), new Server("backup.electrum-ltc.org", Server.ConnectionType.SSL, 443), new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 50002), + new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002), + new Server("electrum-ltc.qortal.online", Server.ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 20063), new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 20063), new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 20063), - new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022), - new Server("ltc.rentonrisk.com", Server.ConnectionType.SSL, 50002), - new Server("62.171.169.176", Server.ConnectionType.SSL, 50002)); + new Server("ltc.rentonrisk.com", Server.ConnectionType.SSL, 50002)); } @Override diff --git a/src/main/java/org/qortal/crosschain/Ravencoin.java b/src/main/java/org/qortal/crosschain/Ravencoin.java index 7bf5b20f..9735f51c 100644 --- a/src/main/java/org/qortal/crosschain/Ravencoin.java +++ b/src/main/java/org/qortal/crosschain/Ravencoin.java @@ -45,13 +45,15 @@ public class Ravencoin extends Bitcoiny { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=rvn - new Server("aethyn.com", ConnectionType.SSL, 50002), - new Server("electrum2.rvn.rocks", ConnectionType.SSL, 50002), - new Server("rvn-dashboard.com", ConnectionType.SSL, 50002), - new Server("rvn4lyfe.com", ConnectionType.SSL, 50002), + //CLOSED new Server("aethyn.com", ConnectionType.SSL, 50002), + //CLOSED new Server("electrum2.rvn.rocks", ConnectionType.SSL, 50002), + //BEHIND new Server("electrum3.rvn.rocks", ConnectionType.SSL, 50002), + new Server("electrum-rvn.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20051), new Server("electrum2.cipig.net", ConnectionType.SSL, 20051), - new Server("electrum3.cipig.net", ConnectionType.SSL, 20051)); + new Server("electrum3.cipig.net", ConnectionType.SSL, 20051), + new Server("rvn-dashboard.com", ConnectionType.SSL, 50002), + new Server("rvn4lyfe.com", ConnectionType.SSL, 50002)); } @Override From 30c9f63cb1c03b8382cae7be7f4406d53c0d0f68 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Sat, 4 Feb 2023 21:03:55 -0500 Subject: [PATCH 199/496] Add unused foreign address to API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST ​/crosschain​/{COIN}/unusedaddress --- .../resource/CrossChainBitcoinResource.java | 39 +++++++++++++++++++ .../resource/CrossChainDigibyteResource.java | 39 +++++++++++++++++++ .../resource/CrossChainDogecoinResource.java | 39 +++++++++++++++++++ .../resource/CrossChainLitecoinResource.java | 39 +++++++++++++++++++ .../resource/CrossChainRavencoinResource.java | 39 +++++++++++++++++++ 5 files changed, 195 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java index 80f9ff04..1e276e59 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java @@ -150,6 +150,45 @@ public class CrossChainBitcoinResource { } } + @POST + @Path("/unusedaddress") + @Operation( + summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String getUnusedBitcoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Bitcoin bitcoin = Bitcoin.getInstance(); + + if (!bitcoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return bitcoin.getUnusedReceiveAddress(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/send") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java index e315947a..781d78f6 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java @@ -150,6 +150,45 @@ public class CrossChainDigibyteResource { } } + @POST + @Path("/unusedaddress") + @Operation( + summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String getUnusedDigibyteReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Digibyte digibyte = Digibyte.getInstance(); + + if (!digibyte.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return digibyte.getUnusedReceiveAddress(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/send") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java index 602d131b..ff1d6d14 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java @@ -148,6 +148,45 @@ public class CrossChainDogecoinResource { } } + @POST + @Path("/unusedaddress") + @Operation( + summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String getUnusedDogecoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Dogecoin dogecoin = Dogecoin.getInstance(); + + if (!dogecoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return dogecoin.getUnusedReceiveAddress(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/send") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java index 653cc2ec..3e2ff799 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -150,6 +150,45 @@ public class CrossChainLitecoinResource { } } + @POST + @Path("/unusedaddress") + @Operation( + summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String getUnusedLitecoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Litecoin litecoin = Litecoin.getInstance(); + + if (!litecoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return litecoin.getUnusedReceiveAddress(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/send") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java index 9e76b8a2..b1d6aed4 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java @@ -150,6 +150,45 @@ public class CrossChainRavencoinResource { } } + @POST + @Path("/unusedaddress") + @Operation( + summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String getUnusedRavencoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Ravencoin ravencoin = Ravencoin.getInstance(); + + if (!ravencoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return ravencoin.getUnusedReceiveAddress(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/send") @Operation( From 8f589391a6023aa59b45c623873795cdc668ee7f Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Sat, 4 Feb 2023 21:57:31 -0500 Subject: [PATCH 200/496] Updated depreciated actions Node.js 12 actions are deprecated. Please update the following actions to use Node.js 16: actions/checkout@v2, actions/cache@v2, actions/setup-java@v2. For more information see: https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/ --- .github/workflows/pr-testing.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-testing.yml b/.github/workflows/pr-testing.yml index f712a321..3d0925df 100644 --- a/.github/workflows/pr-testing.yml +++ b/.github/workflows/pr-testing.yml @@ -8,16 +8,16 @@ jobs: mavenTesting: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Cache local Maven repository - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-maven- - name: Set up the Java JDK - uses: actions/setup-java@v2 + uses: actions/setup-java@v3 with: java-version: '11' distribution: 'adopt' From 6f867031e2d31fb66b41437412fb706c24a83a86 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Sun, 5 Feb 2023 12:53:49 +0100 Subject: [PATCH 201/496] Add electrum servers and fix java reflect error --- pom.xml | 1 + src/main/java/org/qortal/crosschain/Digibyte.java | 1 + src/main/java/org/qortal/crosschain/Dogecoin.java | 2 +- src/main/java/org/qortal/crosschain/Litecoin.java | 1 + src/main/java/org/qortal/crosschain/Ravencoin.java | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 12f8472c..c6dffd04 100644 --- a/pom.xml +++ b/pom.xml @@ -304,6 +304,7 @@ implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> org.qortal.controller.Controller + true . .. diff --git a/src/main/java/org/qortal/crosschain/Digibyte.java b/src/main/java/org/qortal/crosschain/Digibyte.java index 42ab2a5a..2b31468d 100644 --- a/src/main/java/org/qortal/crosschain/Digibyte.java +++ b/src/main/java/org/qortal/crosschain/Digibyte.java @@ -46,6 +46,7 @@ public class Digibyte extends Bitcoiny { // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=dgb new Server("electrum-dgb.qortal.online", ConnectionType.SSL, 50002), + new Server("electrum1-dgb.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20059), new Server("electrum2.cipig.net", ConnectionType.SSL, 20059), new Server("electrum3.cipig.net", ConnectionType.SSL, 20059)); diff --git a/src/main/java/org/qortal/crosschain/Dogecoin.java b/src/main/java/org/qortal/crosschain/Dogecoin.java index 5bdbeed8..6e763377 100644 --- a/src/main/java/org/qortal/crosschain/Dogecoin.java +++ b/src/main/java/org/qortal/crosschain/Dogecoin.java @@ -46,8 +46,8 @@ public class Dogecoin extends Bitcoiny { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=doge - new Server("161.97.137.235", ConnectionType.SSL, 50002), new Server("electrum-doge.qortal.online", ConnectionType.SSL, 50002), + new Server("electrum1-doge.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20060), new Server("electrum2.cipig.net", ConnectionType.SSL, 20060), new Server("electrum3.cipig.net", ConnectionType.SSL, 20060)); diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 33650d81..4e672d3f 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -53,6 +53,7 @@ public class Litecoin extends Bitcoiny { new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 50002), new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002), new Server("electrum-ltc.qortal.online", Server.ConnectionType.SSL, 50002), + new Server("electrum1-ltc.qortal.online", Server.ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 20063), new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 20063), new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 20063), diff --git a/src/main/java/org/qortal/crosschain/Ravencoin.java b/src/main/java/org/qortal/crosschain/Ravencoin.java index 9735f51c..f571a141 100644 --- a/src/main/java/org/qortal/crosschain/Ravencoin.java +++ b/src/main/java/org/qortal/crosschain/Ravencoin.java @@ -49,6 +49,7 @@ public class Ravencoin extends Bitcoiny { //CLOSED new Server("electrum2.rvn.rocks", ConnectionType.SSL, 50002), //BEHIND new Server("electrum3.rvn.rocks", ConnectionType.SSL, 50002), new Server("electrum-rvn.qortal.online", ConnectionType.SSL, 50002), + new Server("electrum1-rvn.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20051), new Server("electrum2.cipig.net", ConnectionType.SSL, 20051), new Server("electrum3.cipig.net", ConnectionType.SSL, 20051), From bef170df7e858e24af52493d039435e424b5587c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 6 Feb 2023 18:42:37 +0000 Subject: [PATCH 202/496] Updated PirateChain lightwallet servers. --- src/main/java/org/qortal/crosschain/PirateChain.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/PirateChain.java b/src/main/java/org/qortal/crosschain/PirateChain.java index 09b37481..a1d31a4e 100644 --- a/src/main/java/org/qortal/crosschain/PirateChain.java +++ b/src/main/java/org/qortal/crosschain/PirateChain.java @@ -57,9 +57,9 @@ public class PirateChain extends Bitcoiny { public Collection getServers() { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! - new Server("arrrlightd.qortal.online", ConnectionType.SSL, 443), - new Server("arrrlightd1.qortal.online", ConnectionType.SSL, 443), - new Server("arrrlightd2.qortal.online", ConnectionType.SSL, 443), + new Server("wallet-arrr1.qortal.online", ConnectionType.SSL, 443), + new Server("wallet-arrr2.qortal.online", ConnectionType.SSL, 443), + new Server("wallet-arrr3.qortal.online", ConnectionType.SSL, 443), new Server("lightd.pirate.black", ConnectionType.SSL, 443)); } From 6fca30ce75e2f6e3b9b46bf4fc8ddcfec80ad6ef Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 7 Feb 2023 19:56:54 +0000 Subject: [PATCH 203/496] Added GET /admin/summary/alltime endpoint, to view a summary of chain activity since genesis. --- .../qortal/api/resource/AdminResource.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 9cff1bbb..46e204db 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -222,6 +222,42 @@ public class AdminResource { } } + @GET + @Path("/summary/alltime") + @Operation( + summary = "Summary of activity since genesis", + responses = { + @ApiResponse( + content = @Content(schema = @Schema(implementation = ActivitySummary.class)) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public ActivitySummary allTimeSummary(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { + Security.checkApiCallAllowed(request); + + ActivitySummary summary = new ActivitySummary(); + + try (final Repository repository = RepositoryManager.getRepository()) { + int startHeight = 1; + long start = repository.getBlockRepository().fromHeight(startHeight).getTimestamp(); + int endHeight = repository.getBlockRepository().getBlockchainHeight(); + + summary.setBlockCount(endHeight - startHeight); + + summary.setTransactionCountByType(repository.getTransactionRepository().getTransactionSummary(startHeight + 1, endHeight)); + + summary.setAssetsIssued(repository.getAssetRepository().getRecentAssetIds(start).size()); + + summary.setNamesRegistered (repository.getNameRepository().getRecentNames(start).size()); + + return summary; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @GET @Path("/enginestats") @Operation( From e7a3e511bda479b832caf6cb7820bbc6ff9c35a9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 8 Feb 2023 19:37:01 +0000 Subject: [PATCH 204/496] Bump version to 3.8.5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c6dffd04..1eb8adb1 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.4 + 3.8.5 jar true From ea356d102648eac431bd1d4a0860a21fff1bdf82 Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Fri, 10 Feb 2023 10:27:28 +0100 Subject: [PATCH 205/496] add signatures to websockt --- .../java/org/qortal/data/chat/ActiveChats.java | 10 ++++++++-- .../repository/hsqldb/HSQLDBChatRepository.java | 15 +++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/data/chat/ActiveChats.java b/src/main/java/org/qortal/data/chat/ActiveChats.java index c546d637..f9e48a1d 100644 --- a/src/main/java/org/qortal/data/chat/ActiveChats.java +++ b/src/main/java/org/qortal/data/chat/ActiveChats.java @@ -17,17 +17,19 @@ public class ActiveChats { private Long timestamp; private String sender; private String senderName; + private byte[] signature; protected GroupChat() { /* JAXB */ } - public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName) { + public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName, byte[] signature) { this.groupId = groupId; this.groupName = groupName; this.timestamp = timestamp; this.sender = sender; this.senderName = senderName; + this.signature = signature; } public int getGroupId() { @@ -49,6 +51,10 @@ public class ActiveChats { public String getSenderName() { return this.senderName; } + + public byte[] getSignature() { + return this.signature; + } } @XmlAccessorType(XmlAccessType.FIELD) @@ -118,4 +124,4 @@ public class ActiveChats { return this.direct; } -} +} \ No newline at end of file diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java index 08226d53..b5b64594 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -177,11 +177,11 @@ public class HSQLDBChatRepository implements ChatRepository { private List getActiveGroupChats(String address) 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 " + String groupsSql = "SELECT group_id, group_name, latest_timestamp, sender, sender_name, signature " + "FROM GroupMembers " + "JOIN Groups USING (group_id) " + "LEFT OUTER JOIN LATERAL(" - + "SELECT created_when AS latest_timestamp, sender, name AS sender_name " + + "SELECT created_when AS latest_timestamp, sender, name AS sender_name, signature " + "FROM ChatTransactions " + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -205,8 +205,9 @@ public class HSQLDBChatRepository implements ChatRepository { String sender = resultSet.getString(4); String senderName = resultSet.getString(5); + byte[] signature = resultSet.getBytes(6); - GroupChat groupChat = new GroupChat(groupId, groupName, timestamp, sender, senderName); + GroupChat groupChat = new GroupChat(groupId, groupName, timestamp, sender, senderName, signature); groupChats.add(groupChat); } while (resultSet.next()); } @@ -215,7 +216,7 @@ public class HSQLDBChatRepository implements ChatRepository { } // We need different SQL to handle group-less chat - String grouplessSql = "SELECT created_when, sender, SenderNames.name " + String grouplessSql = "SELECT created_when, sender, SenderNames.name, signature " + "FROM ChatTransactions " + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -228,15 +229,17 @@ public class HSQLDBChatRepository implements ChatRepository { Long timestamp = null; String sender = null; String senderName = null; + byte[] signature = null; if (resultSet != null) { // We found a recipient-less, group-less CHAT message, so report its details timestamp = resultSet.getLong(1); sender = resultSet.getString(2); senderName = resultSet.getString(3); + signature = resultSet.getBytes(4); } - GroupChat groupChat = new GroupChat(0, null, timestamp, sender, senderName); + GroupChat groupChat = new GroupChat(0, null, timestamp, sender, senderName, signature); groupChats.add(groupChat); } catch (SQLException e) { throw new DataException("Unable to fetch active group chats from repository", e); @@ -291,4 +294,4 @@ public class HSQLDBChatRepository implements ChatRepository { return directChats; } -} +} \ No newline at end of file From 11654ba9c6963445ffaa3f323a7a15f45ccdf54b Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Fri, 10 Feb 2023 11:05:54 +0100 Subject: [PATCH 206/496] Add Chat Data --- src/main/java/org/qortal/data/chat/ActiveChats.java | 8 +++++++- .../repository/hsqldb/HSQLDBChatRepository.java | 13 ++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/data/chat/ActiveChats.java b/src/main/java/org/qortal/data/chat/ActiveChats.java index f9e48a1d..d5ebcf3f 100644 --- a/src/main/java/org/qortal/data/chat/ActiveChats.java +++ b/src/main/java/org/qortal/data/chat/ActiveChats.java @@ -18,18 +18,20 @@ public class ActiveChats { private String sender; private String senderName; private byte[] signature; + private byte[] data; protected GroupChat() { /* JAXB */ } - public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName, byte[] signature) { + public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName, byte[] signature, byte[] data) { this.groupId = groupId; this.groupName = groupName; this.timestamp = timestamp; this.sender = sender; this.senderName = senderName; this.signature = signature; + this.data = data; } public int getGroupId() { @@ -55,6 +57,10 @@ public class ActiveChats { public byte[] getSignature() { return this.signature; } + + public byte[] getData() { + return this.data; + } } @XmlAccessorType(XmlAccessType.FIELD) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java index b5b64594..a995a0b3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -177,11 +177,11 @@ public class HSQLDBChatRepository implements ChatRepository { private List getActiveGroupChats(String address) 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 " + String groupsSql = "SELECT group_id, group_name, latest_timestamp, sender, sender_name, signature, data " + "FROM GroupMembers " + "JOIN Groups USING (group_id) " + "LEFT OUTER JOIN LATERAL(" - + "SELECT created_when AS latest_timestamp, sender, name AS sender_name, signature " + + "SELECT created_when AS latest_timestamp, sender, name AS sender_name, signature, data " + "FROM ChatTransactions " + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -206,8 +206,9 @@ public class HSQLDBChatRepository implements ChatRepository { String sender = resultSet.getString(4); String senderName = resultSet.getString(5); byte[] signature = resultSet.getBytes(6); + byte[] data = resultSet.getBytes(7); - GroupChat groupChat = new GroupChat(groupId, groupName, timestamp, sender, senderName, signature); + GroupChat groupChat = new GroupChat(groupId, groupName, timestamp, sender, senderName, signature, data); groupChats.add(groupChat); } while (resultSet.next()); } @@ -216,7 +217,7 @@ public class HSQLDBChatRepository implements ChatRepository { } // We need different SQL to handle group-less chat - String grouplessSql = "SELECT created_when, sender, SenderNames.name, signature " + String grouplessSql = "SELECT created_when, sender, SenderNames.name, signature, data " + "FROM ChatTransactions " + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -230,6 +231,7 @@ public class HSQLDBChatRepository implements ChatRepository { String sender = null; String senderName = null; byte[] signature = null; + byte[] data = null; if (resultSet != null) { // We found a recipient-less, group-less CHAT message, so report its details @@ -237,9 +239,10 @@ public class HSQLDBChatRepository implements ChatRepository { sender = resultSet.getString(2); senderName = resultSet.getString(3); signature = resultSet.getBytes(4); + data = resultSet.getBytes(5); } - GroupChat groupChat = new GroupChat(0, null, timestamp, sender, senderName, signature); + GroupChat groupChat = new GroupChat(0, null, timestamp, sender, senderName, signature, data); groupChats.add(groupChat); } catch (SQLException e) { throw new DataException("Unable to fetch active group chats from repository", e); From ec09312cc5cf403add447649ede004f0cff0b1d3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Feb 2023 17:42:12 +0000 Subject: [PATCH 207/496] Updated AdvancedInstaller project for 3.8.5 --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 7af02485..51ba5f69 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - + From eb6d84c04d37de22c16eb6cc3f8270061a09126c Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Sun, 12 Feb 2023 00:10:13 -0500 Subject: [PATCH 208/496] Add new ElectrumX servers --- src/main/java/org/qortal/crosschain/Digibyte.java | 1 + src/main/java/org/qortal/crosschain/Dogecoin.java | 1 + src/main/java/org/qortal/crosschain/Litecoin.java | 1 + src/main/java/org/qortal/crosschain/Ravencoin.java | 1 + 4 files changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/crosschain/Digibyte.java b/src/main/java/org/qortal/crosschain/Digibyte.java index 2b31468d..c5d96383 100644 --- a/src/main/java/org/qortal/crosschain/Digibyte.java +++ b/src/main/java/org/qortal/crosschain/Digibyte.java @@ -45,6 +45,7 @@ public class Digibyte extends Bitcoiny { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=dgb + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 55002), new Server("electrum-dgb.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1-dgb.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20059), diff --git a/src/main/java/org/qortal/crosschain/Dogecoin.java b/src/main/java/org/qortal/crosschain/Dogecoin.java index 6e763377..99f557a5 100644 --- a/src/main/java/org/qortal/crosschain/Dogecoin.java +++ b/src/main/java/org/qortal/crosschain/Dogecoin.java @@ -46,6 +46,7 @@ public class Dogecoin extends Bitcoiny { return Arrays.asList( // Servers chosen on NO BASIS WHATSOEVER from various sources! // Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=doge + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 54002), new Server("electrum-doge.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1-doge.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20060), diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 4e672d3f..1dd9037a 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -50,6 +50,7 @@ public class Litecoin extends Bitcoiny { //BEHIND new Server("62.171.169.176", Server.ConnectionType.SSL, 50002), //PHISHY new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 50002), new Server("backup.electrum-ltc.org", Server.ConnectionType.SSL, 443), + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 50002), new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 50002), new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002), new Server("electrum-ltc.qortal.online", Server.ConnectionType.SSL, 50002), diff --git a/src/main/java/org/qortal/crosschain/Ravencoin.java b/src/main/java/org/qortal/crosschain/Ravencoin.java index f571a141..6030fa50 100644 --- a/src/main/java/org/qortal/crosschain/Ravencoin.java +++ b/src/main/java/org/qortal/crosschain/Ravencoin.java @@ -48,6 +48,7 @@ public class Ravencoin extends Bitcoiny { //CLOSED new Server("aethyn.com", ConnectionType.SSL, 50002), //CLOSED new Server("electrum2.rvn.rocks", ConnectionType.SSL, 50002), //BEHIND new Server("electrum3.rvn.rocks", ConnectionType.SSL, 50002), + new Server("electrum.qortal.link", Server.ConnectionType.SSL, 56002), new Server("electrum-rvn.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1-rvn.qortal.online", ConnectionType.SSL, 50002), new Server("electrum1.cipig.net", ConnectionType.SSL, 20051), From 896d8143854a43bb22d52f9b72c19e88b90154ec Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Feb 2023 13:20:23 +0000 Subject: [PATCH 209/496] Add block_sequence to Transactions table, and populate all past transactions. This data was being lost when pruning the BlockTransactions table. Note: on first run this will reshape the db, which can take several minutes. --- src/main/java/org/qortal/block/Block.java | 11 ++- .../org/qortal/controller/Controller.java | 1 + .../data/transaction/TransactionData.java | 14 ++++ .../qortal/repository/RepositoryManager.java | 68 +++++++++++++++++++ .../repository/TransactionRepository.java | 2 + .../hsqldb/HSQLDBDatabaseUpdates.java | 11 +++ .../HSQLDBTransactionRepository.java | 20 +++++- 7 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 3f306b93..59de8870 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1682,12 +1682,14 @@ public class Block { transactionData.getSignature()); this.repository.getBlockRepository().save(blockTransactionData); - // Update transaction's height in repository + // Update transaction's height in repository and local transactionData transactionRepository.updateBlockHeight(transactionData.getSignature(), this.blockData.getHeight()); - - // Update local transactionData's height too transaction.getTransactionData().setBlockHeight(this.blockData.getHeight()); + // Update transaction's sequence in repository and local transactionData + transactionRepository.updateBlockSequence(transactionData.getSignature(), sequence); + transaction.getTransactionData().setBlockSequence(sequence); + // No longer unconfirmed transactionRepository.confirmTransaction(transactionData.getSignature()); @@ -1774,6 +1776,9 @@ public class Block { // Unset height transactionRepository.updateBlockHeight(transactionData.getSignature(), null); + + // Unset sequence + transactionRepository.updateBlockSequence(transactionData.getSignature(), null); } transactionRepository.deleteParticipants(transactionData); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index e9e1fcc2..054e5530 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -404,6 +404,7 @@ public class Controller extends Thread { try (final Repository repository = RepositoryManager.getRepository()) { RepositoryManager.archive(repository); RepositoryManager.prune(repository); + RepositoryManager.rebuildTransactionSequences(repository); } } catch (DataException e) { // If exception has no cause then repository is in use by some other process. diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index ec1139f4..713f98b5 100644 --- a/src/main/java/org/qortal/data/transaction/TransactionData.java +++ b/src/main/java/org/qortal/data/transaction/TransactionData.java @@ -76,6 +76,10 @@ public abstract class TransactionData { @Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "height of block containing transaction") protected Integer blockHeight; + // Not always present + @Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "sequence in block containing transaction") + protected Integer blockSequence; + // Not always present @Schema(accessMode = AccessMode.READ_ONLY, description = "group-approval status") protected ApprovalStatus approvalStatus; @@ -106,6 +110,7 @@ public abstract class TransactionData { this.fee = baseTransactionData.fee; this.signature = baseTransactionData.signature; this.blockHeight = baseTransactionData.blockHeight; + this.blockSequence = baseTransactionData.blockSequence; this.approvalStatus = baseTransactionData.approvalStatus; this.approvalHeight = baseTransactionData.approvalHeight; } @@ -174,6 +179,15 @@ public abstract class TransactionData { this.blockHeight = blockHeight; } + public Integer getBlockSequence() { + return this.blockSequence; + } + + @XmlTransient + public void setBlockSequence(Integer blockSequence) { + this.blockSequence = blockSequence; + } + public ApprovalStatus getApprovalStatus() { return approvalStatus; } diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 0d9325b9..983404c1 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -2,13 +2,18 @@ package org.qortal.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.data.transaction.TransactionData; import org.qortal.gui.SplashFrame; import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.settings.Settings; +import org.qortal.transaction.Transaction; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.concurrent.TimeoutException; public abstract class RepositoryManager { @@ -117,6 +122,69 @@ public abstract class RepositoryManager { return false; } + public static boolean rebuildTransactionSequences(Repository repository) throws DataException { + if (Settings.getInstance().isLite()) { + // Lite nodes have no blockchain + return false; + } + + try { + // Check if we have any unpopulated block_sequence values for the first 1000 blocks + List testSignatures = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria( + null, Arrays.asList("block_height < 1000 AND block_sequence IS NULL"), new ArrayList<>()); + if (testSignatures.isEmpty()) { + // block_sequence already populated for the first 1000 blocks, so assume complete. + // We avoid checkpointing and prevent the node from starting up in the case of a rebuild failure, so + // we shouldn't ever be left in a partially rebuilt state. + return false; + } + + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + int totalTransactionCount = 0; + + for (int height = 1; height < blockchainHeight; height++) { + List transactions = new ArrayList<>(); + + // Fetch transactions for height + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height); + for (byte[] signature : signatures) { + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + if (transactionData != null) { + transactions.add(transactionData); + } + } + totalTransactionCount += transactions.size(); + + // Sort the transactions for this height + transactions.sort(Transaction.getDataComparator()); + + // Loop through and update sequences + for (int sequence = 0; sequence < transactions.size(); ++sequence) { + TransactionData transactionData = transactions.get(sequence); + + // Update transaction's sequence in repository + repository.getTransactionRepository().updateBlockSequence(transactionData.getSignature(), sequence); + } + + if (height % 10000 == 0) { + LOGGER.info("Rebuilt sequences for {} blocks (total transactions: {})", height, totalTransactionCount); + } + + repository.saveChanges(); + } + + LOGGER.info("Completed rebuild of transaction sequences."); + return true; + } + catch (DataException e) { + LOGGER.info("Unable to rebuild transaction sequences: {}. The database may have been left in an inconsistent state.", e.getMessage()); + + // Throw an exception so that the node startup is halted, allowing for a retry next time. + repository.discardChanges(); + throw new DataException("Rebuild of transaction sequences failed."); + } + } + public static void setRequestedCheckpoint(Boolean quick) { quickCheckpointRequested = quick; } diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 105a317d..e528166b 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -309,6 +309,8 @@ public interface TransactionRepository { public void updateBlockHeight(byte[] signature, Integer height) throws DataException; + public void updateBlockSequence(byte[] signature, Integer sequence) throws DataException; + public void updateApprovalHeight(byte[] signature, Integer approvalHeight) throws DataException; /** diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index aecac034..cd2b30fa 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -993,6 +993,17 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE CancelSellNameTransactions ADD sale_price QortalAmount"); break; + case 47: + // Add `block_sequence` to the Transaction table, as the BlockTransactions table is pruned for + // older blocks and therefore the sequence becomes unavailable + LOGGER.info("Reshaping Transactions table - this can take a while..."); + stmt.execute("ALTER TABLE Transactions ADD block_sequence INTEGER"); + + // For finding transactions by height and sequence + LOGGER.info("Adding index to Transactions table - this can take a while..."); + stmt.execute("CREATE INDEX TransactionHeightSequenceIndex on Transactions (block_height, block_sequence)"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index a8df1ab5..5bf149b2 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -657,8 +657,13 @@ public class HSQLDBTransactionRepository implements TransactionRepository { List bindParams) throws DataException { List signatures = new ArrayList<>(); + String txTypeClassName = ""; + if (txType != null) { + txTypeClassName = txType.className; + } + StringBuilder sql = new StringBuilder(1024); - sql.append(String.format("SELECT signature FROM %sTransactions", txType.className)); + sql.append(String.format("SELECT signature FROM %sTransactions", txTypeClassName)); if (!whereClauses.isEmpty()) { sql.append(" WHERE "); @@ -1444,6 +1449,19 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + @Override + public void updateBlockSequence(byte[] signature, Integer blockSequence) throws DataException { + HSQLDBSaver saver = new HSQLDBSaver("Transactions"); + + saver.bind("signature", signature).bind("block_sequence", blockSequence); + + try { + saver.execute(repository); + } catch (SQLException e) { + throw new DataException("Unable to update transaction's block sequence in repository", e); + } + } + @Override public void updateApprovalHeight(byte[] signature, Integer approvalHeight) throws DataException { HSQLDBSaver saver = new HSQLDBSaver("Transactions"); From af6be759e7ab6c25bd54f10ca509735251358871 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Feb 2023 13:20:31 +0000 Subject: [PATCH 210/496] Fixed long term issue where logs would report "Repository in use by another process?" when the database actually failed to start for some other reason. It will now log the correct reason. --- src/main/java/org/qortal/controller/Controller.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 054e5530..69995180 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -407,8 +407,8 @@ public class Controller extends Thread { RepositoryManager.rebuildTransactionSequences(repository); } } catch (DataException e) { - // If exception has no cause then repository is in use by some other process. - if (e.getCause() == null) { + // If exception has no cause or message then repository is in use by some other process. + if (e.getCause() == null && e.getMessage() == null) { LOGGER.info("Repository in use by another process?"); Gui.getInstance().fatalError("Repository issue", "Repository in use by another process?"); } else { From a8c27be18ac814e264b06f06d966f29fa4b6648d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Feb 2023 13:21:41 +0000 Subject: [PATCH 211/496] Modified AT and transaction repository queries to use Transactions.block_sequence instead of BlockTransactions.sequence. The former is available for all blocks, whereas the latter is only available for unpruned blocks. Also removed joins with the Blocks table - as the Blocks table is also pruned - and instead retrieved the height from the Transactions table. --- .../repository/hsqldb/HSQLDBATRepository.java | 16 +++++++--------- .../transaction/HSQLDBTransactionRepository.java | 3 +-- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index dd0404a8..33817309 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -296,10 +296,9 @@ public class HSQLDBATRepository implements ATRepository { @Override public Integer getATCreationBlockHeight(String atAddress) throws DataException { - String sql = "SELECT height " + String sql = "SELECT block_height " + "FROM DeployATTransactions " - + "JOIN BlockTransactions ON transaction_signature = signature " - + "JOIN Blocks ON Blocks.signature = block_signature " + + "JOIN Transactions USING (signature) " + "WHERE AT_address = ? " + "LIMIT 1"; @@ -877,18 +876,17 @@ public class HSQLDBATRepository implements ATRepository { public NextTransactionInfo findNextTransaction(String recipient, int height, int sequence) throws DataException { // We only need to search for a subset of transaction types: MESSAGE, PAYMENT or AT - String sql = "SELECT height, sequence, Transactions.signature " + String sql = "SELECT block_height, block_sequence, Transactions.signature " + "FROM (" + "SELECT signature FROM PaymentTransactions WHERE recipient = ? " + "UNION " + "SELECT signature FROM MessageTransactions WHERE recipient = ? " + "UNION " + "SELECT signature FROM ATTransactions WHERE recipient = ?" - + ") AS Transactions " - + "JOIN BlockTransactions ON BlockTransactions.transaction_signature = Transactions.signature " - + "JOIN Blocks ON Blocks.signature = BlockTransactions.block_signature " - + "WHERE (height > ? OR (height = ? AND sequence > ?)) " - + "ORDER BY height ASC, sequence ASC " + + ") AS SelectedTransactions " + + "JOIN Transactions USING (signature)" + + "WHERE (block_height > ? OR (block_height = ? AND block_sequence > ?)) " + + "ORDER BY block_height ASC, block_sequence ASC " + "LIMIT 1"; Object[] bindParams = new Object[] { recipient, recipient, recipient, height, height, sequence }; diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 5bf149b2..e7bab926 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -194,8 +194,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository { @Override public TransactionData fromHeightAndSequence(int height, int sequence) throws DataException { - String sql = "SELECT transaction_signature FROM BlockTransactions JOIN Blocks ON signature = block_signature " - + "WHERE height = ? AND sequence = ?"; + String sql = "SELECT signature FROM Transactions WHERE block_height = ? AND block_sequence = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, height, sequence)) { if (resultSet == null) From 20d4e88fabf770e3294362f39fd899a98bc30625 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Feb 2023 13:21:54 +0000 Subject: [PATCH 212/496] Fixed API endpoints relying on getTransactionsFromSignature(), which therefore won't have worked properly since core V2. --- .../qortal/api/resource/BlocksResource.java | 25 +++++++++++++------ .../api/resource/TransactionsResource.java | 21 +++++++++++++--- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index 15541802..98c5f00d 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -218,14 +218,25 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { - // Check if the block exists in either the database or archive - if (repository.getBlockRepository().getHeightFromSignature(signature) == 0 && - repository.getBlockArchiveRepository().getHeightFromSignature(signature) == 0) { - // Not found in either the database or archive - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); - } + // Check if the block exists in either the database or archive + int height = repository.getBlockRepository().getHeightFromSignature(signature); + if (height == 0) { + height = repository.getBlockArchiveRepository().getHeightFromSignature(signature); + if (height == 0) { + // Not found in either the database or archive + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } + } - return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse); + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height); + + // Expand signatures to transactions + List transactions = new ArrayList<>(signatures.size()); + for (byte[] s : signatures) { + transactions.add(repository.getTransactionRepository().fromSignature(s)); + } + + return transactions; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } diff --git a/src/main/java/org/qortal/api/resource/TransactionsResource.java b/src/main/java/org/qortal/api/resource/TransactionsResource.java index 2b9b28a1..a8962497 100644 --- a/src/main/java/org/qortal/api/resource/TransactionsResource.java +++ b/src/main/java/org/qortal/api/resource/TransactionsResource.java @@ -220,10 +220,25 @@ public class TransactionsResource { } try (final Repository repository = RepositoryManager.getRepository()) { - if (repository.getBlockRepository().getHeightFromSignature(signature) == 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + // Check if the block exists in either the database or archive + int height = repository.getBlockRepository().getHeightFromSignature(signature); + if (height == 0) { + height = repository.getBlockArchiveRepository().getHeightFromSignature(signature); + if (height == 0) { + // Not found in either the database or archive + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } + } - return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse); + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height); + + // Expand signatures to transactions + List transactions = new ArrayList<>(signatures.size()); + for (byte[] s : signatures) { + transactions.add(repository.getTransactionRepository().fromSignature(s)); + } + + return transactions; } catch (ApiException e) { throw e; } catch (DataException e) { From 5b7e9666dc740f89b511005623dbc42132f702f0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Feb 2023 15:40:06 +0000 Subject: [PATCH 213/496] 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 214/496] 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 074cba22663b490a00bf9a6bbccd2ea9f609abc8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Feb 2023 17:33:17 +0000 Subject: [PATCH 215/496] Added QCHAT_AUDIO and QCHAT_VOICE services (limited to 10MB each) --- src/main/java/org/qortal/arbitrary/misc/Service.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 01419d2f..5ea1b7aa 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -84,6 +84,8 @@ public enum Service { 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), From edacce1bac10147956fd1c6056bfea5292a014f5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Feb 2023 17:43:13 +0000 Subject: [PATCH 216/496] Improved logging when creating bootstraps, and catch/log all exceptions. --- src/main/java/org/qortal/api/resource/BootstrapResource.java | 2 +- src/main/java/org/qortal/repository/Bootstrap.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/resource/BootstrapResource.java index b9382dcb..78630dfb 100644 --- a/src/main/java/org/qortal/api/resource/BootstrapResource.java +++ b/src/main/java/org/qortal/api/resource/BootstrapResource.java @@ -60,7 +60,7 @@ public class BootstrapResource { bootstrap.validateBlockchain(); return bootstrap.create(); - } catch (DataException | InterruptedException | IOException e) { + } catch (Exception e) { LOGGER.info("Unable to create bootstrap", e); throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 626433e8..2d2605cc 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -279,7 +279,9 @@ public class Bootstrap { LOGGER.info("Generating checksum file..."); String checksum = Crypto.digestHexString(compressedOutputPath.toFile(), 1024*1024); + LOGGER.info("checksum: {}", checksum); Path checksumPath = Paths.get(String.format("%s.sha256", compressedOutputPath.toString())); + LOGGER.info("Writing checksum to path: {}", checksumPath); Files.writeString(checksumPath, checksum, StandardOpenOption.CREATE); // Return the path to the compressed bootstrap file From cfa0b1d8ea27b1ca2fd0e6dae47f4b93bbcc2700 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Feb 2023 18:02:22 +0000 Subject: [PATCH 217/496] Bump version to 3.8.6 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1eb8adb1..a7bac334 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.5 + 3.8.6 jar true From b2d31a7e0232fcaafd9e009e83e2cb7f038d8468 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Feb 2023 22:26:22 +0000 Subject: [PATCH 218/496] Rebuild the name's history before processing a CancelSellNameTransaction. --- .../qortal/transaction/CancelSellNameTransaction.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java b/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java index 788492a9..876f0aed 100644 --- a/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java @@ -5,6 +5,7 @@ import java.util.List; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.data.naming.NameData; import org.qortal.data.transaction.CancelSellNameTransactionData; import org.qortal.data.transaction.TransactionData; @@ -81,7 +82,13 @@ public class CancelSellNameTransaction extends Transaction { @Override public void preProcess() throws DataException { - // Nothing to do + CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) 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(cancelSellNameTransactionData.getName(), this.repository); } @Override From 52c806f9e6b7171d4dd3c9bc33c371f4772a119f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Feb 2023 22:44:59 +0000 Subject: [PATCH 219/496] Bump version to 3.8.7 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a7bac334..fe59d980 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.6 + 3.8.7 jar true From d30eb6141ae7f8501d8a77d0045563c964fbb184 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 20 Feb 2023 18:10:21 +0000 Subject: [PATCH 220/496] Default minPeerVersion set to 3.8.7 --- 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 5799bd26..ae5dc173 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -215,7 +215,7 @@ public class Settings { public long recoveryModeTimeout = 10 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.8.2"; + private String minPeerVersion = "3.8.7"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From c39b9c764b1e4a6589be19106d284a0d7d0e1204 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 20 Feb 2023 18:12:40 +0000 Subject: [PATCH 221/496] Bump version to 3.8.8 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index fe59d980..f94e39b3 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.7 + 3.8.8 jar true From 148ca0af05fe15aaf76da9e0a01ecd9d257a460e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 22 Feb 2023 09:16:52 +0000 Subject: [PATCH 222/496] Fixed long term bug with UPDATE_NAME transactions, causing name data to be incorrectly deleted if newName == name. --- src/main/java/org/qortal/naming/Name.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/naming/Name.java b/src/main/java/org/qortal/naming/Name.java index ecf826a5..1751cca8 100644 --- a/src/main/java/org/qortal/naming/Name.java +++ b/src/main/java/org/qortal/naming/Name.java @@ -16,6 +16,8 @@ import org.qortal.repository.Repository; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.utils.Unicode; +import java.util.Objects; + public class Name { // Properties @@ -116,7 +118,7 @@ public class Name { this.repository.getNameRepository().save(this.nameData); - if (!updateNameTransactionData.getNewName().isEmpty()) + if (!updateNameTransactionData.getNewName().isEmpty() && !Objects.equals(updateNameTransactionData.getName(), updateNameTransactionData.getNewName())) // Name has changed, delete old entry this.repository.getNameRepository().delete(updateNameTransactionData.getNewName()); From ba9f3b335c1efa530a9979b8c1304bf02df77a31 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 22 Feb 2023 18:59:43 +0000 Subject: [PATCH 223/496] Added unit test to reproduce the UPDATE_NAME issue and prove that the fix is working correctly. --- .../org/qortal/test/naming/UpdateTests.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/test/java/org/qortal/test/naming/UpdateTests.java b/src/test/java/org/qortal/test/naming/UpdateTests.java index 24af5317..54227e94 100644 --- a/src/test/java/org/qortal/test/naming/UpdateTests.java +++ b/src/test/java/org/qortal/test/naming/UpdateTests.java @@ -219,6 +219,65 @@ public class UpdateTests extends Common { } } + // Test that multiple UPDATE_NAME transactions work as expected, when using a matching name and newName string + @Test + public void testDoubleUpdateNameWithMatchingNewName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "name"; + String reducedName = "name"; + String data = "{\"age\":30}"; + + TransactionData initialTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + initialTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(initialTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, initialTransactionData, alice); + + // Check name exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Update name + TransactionData middleTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), name, name, data); + TransactionUtils.signAndMint(repository, middleTransactionData, alice); + + // Check name still exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Update name again + TransactionData newestTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), name, name, data); + TransactionUtils.signAndMint(repository, newestTransactionData, alice); + + // Check name still exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Check updated timestamp is correct + assertEquals((Long) newestTransactionData.getTimestamp(), repository.getNameRepository().fromName(name).getUpdated()); + + // orphan and recheck + BlockUtils.orphanLastBlock(repository); + + // Check name still exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Check updated timestamp is correct + assertEquals((Long) middleTransactionData.getTimestamp(), repository.getNameRepository().fromName(name).getUpdated()); + + // orphan and recheck + BlockUtils.orphanLastBlock(repository); + + // Check name still exists + assertTrue(repository.getNameRepository().nameExists(name)); + assertNotNull(repository.getNameRepository().fromReducedName(reducedName)); + + // Check updated timestamp is empty + assertNull(repository.getNameRepository().fromName(name).getUpdated()); + } + } + // Test that reverting using previous UPDATE_NAME works as expected @Test public void testIntermediateUpdateName() throws DataException { From 466c727dee8b6a4e5077170bcc5e5de14c88bf43 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 22 Feb 2023 19:01:10 +0000 Subject: [PATCH 224/496] Bump version to 3.8.9 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f94e39b3..35c77bcc 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.8 + 3.8.9 jar true From 999e8b8aca2a817a56dec810b138cc07ae22db5d Mon Sep 17 00:00:00 2001 From: AlphaX-Projects <77661270+AlphaX-Projects@users.noreply.github.com> Date: Fri, 24 Feb 2023 09:12:57 +0100 Subject: [PATCH 225/496] Update pom.xml --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 35c77bcc..efa84798 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 1.9 1.2.2 28.1-jre - 2.5.1 + 2.7.1 1.2.1 70.1 1.1 @@ -34,7 +34,7 @@ 1.1.0 1.13.1 4.10 - 1.45.1 + 1.53.0 3.19.4 From c5a0b00cde97a48ad2e2dcde32e95d34a756dd28 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Feb 2023 12:15:22 +0000 Subject: [PATCH 226/496] 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 227/496] 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 228/496] 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 a3702ac6b08aed78f66a292c9a6da4a4f56363ab Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 26 Feb 2023 12:45:38 +0000 Subject: [PATCH 229/496] Revert "Merge pull request #111 from AlphaX-Projects/master" This reverts commit 69902f7f5b490257998a8a74cf9d26eece4a5680, reversing changes made to 466c727dee8b6a4e5077170bcc5e5de14c88bf43. --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index efa84798..35c77bcc 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 1.9 1.2.2 28.1-jre - 2.7.1 + 2.5.1 1.2.1 70.1 1.1 @@ -34,7 +34,7 @@ 1.1.0 1.13.1 4.10 - 1.53.0 + 1.45.1 3.19.4 From cc98abeffb797102446dfb6506afca64288a5959 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 26 Feb 2023 12:51:52 +0000 Subject: [PATCH 230/496] Reduced log spam --- .../controller/arbitrary/ArbitraryDataCleanupManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java index 39425b7e..34acf0cb 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java @@ -204,7 +204,7 @@ public class ArbitraryDataCleanupManager extends Thread { if (completeFileExists && !allChunksExist) { // We have the complete file but not the chunks, so let's convert it - LOGGER.info(String.format("Transaction %s has complete file but no chunks", + LOGGER.debug(String.format("Transaction %s has complete file but no chunks", Base58.encode(arbitraryTransactionData.getSignature()))); ArbitraryTransactionUtils.convertFileToChunks(arbitraryTransactionData, now, STALE_FILE_TIMEOUT); From d54006caf78e32d2da0c99d19f6d03371738561f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 26 Feb 2023 15:59:18 +0000 Subject: [PATCH 231/496] Added "archiveVersion" setting, which specifies the archive version to be used when building. Defaults to 1 for now, but will bump to version 2 at the time of a wider rollout. --- src/main/java/org/qortal/repository/BlockArchiveWriter.java | 2 +- src/main/java/org/qortal/settings/Settings.java | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 2eb4c6a6..1799f3c4 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -80,7 +80,7 @@ public class BlockArchiveWriter { * @param repository */ public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { - this(startHeight, endHeight, 2, Paths.get(Settings.getInstance().getRepositoryPath(), "archive"), repository); + this(startHeight, endHeight, Settings.getInstance().getArchiveVersion(), Paths.get(Settings.getInstance().getRepositoryPath(), "archive"), repository); } public static int getMaxArchiveHeight(Repository repository) throws DataException { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index ae5dc173..52b3aed5 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -178,6 +178,8 @@ public class Settings { private boolean archiveEnabled = true; /** How often to attempt archiving (ms). */ private long archiveInterval = 7171L; // milliseconds + /** Serialization version to use when building an archive */ + private int archiveVersion = 1; /** Whether to automatically bootstrap instead of syncing from genesis */ @@ -926,6 +928,10 @@ public class Settings { return this.archiveInterval; } + public int getArchiveVersion() { + return this.archiveVersion; + } + public boolean getBootstrap() { return this.bootstrap; From 0af6fbe1eb80b1e8fe47e293acf72f0f8d221a75 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 26 Feb 2023 16:52:48 +0000 Subject: [PATCH 232/496] Added `POST /repository/archive/rebuild` endpoint to allow local archive to be rebuilt. When "archiveVersion" is set to 2 in settings, this should allow the archive size to reduce by over 90%. Some nodes might want to maintain an older/larger version, for the purposes of development/debugging, so this is currently opt-in. --- .../qortal/api/resource/AdminResource.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 46e204db..0531f60d 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -45,6 +45,7 @@ import org.qortal.block.BlockChain; import org.qortal.controller.Controller; import org.qortal.controller.Synchronizer; import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.controller.repository.BlockArchiveRebuilder; import org.qortal.data.account.MintingAccountData; import org.qortal.data.account.RewardShareData; import org.qortal.network.Network; @@ -734,6 +735,52 @@ public class AdminResource { } } + @POST + @Path("/repository/archive/rebuild") + @Operation( + summary = "Rebuild archive.", + description = "Rebuilds archive files, using the serialization version specified via the archiveVersion setting.", + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String rebuildArchive(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { + Security.checkApiCallAllowed(request); + + try { + // We don't actually need to lock the blockchain here, but we'll do it anyway so that + // the node can focus on rebuilding rather than synchronizing / minting. + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + int archiveVersion = Settings.getInstance().getArchiveVersion(); + + BlockArchiveRebuilder blockArchiveRebuilder = new BlockArchiveRebuilder(archiveVersion); + blockArchiveRebuilder.start(); + + return "true"; + + } catch (IOException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e); + + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // We couldn't lock blockchain to perform rebuild + return "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @DELETE @Path("/repository") @Operation( From 1153519d788934e852a3568b7f84905d186acadd Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 26 Feb 2023 16:53:43 +0000 Subject: [PATCH 233/496] Various fixes as a result of moving to archive version 2. --- .../qortal/repository/BlockArchiveReader.java | 6 +++++ .../qortal/repository/BlockArchiveWriter.java | 6 ++++- .../org/qortal/utils/BlockArchiveUtils.java | 25 ++++++++++++++++--- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index c5878563..e45f1fdf 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -70,6 +70,9 @@ public class BlockArchiveReader { } Triple serializedBlock = this.fetchSerializedBlockBytesForHeight(height); + if (serializedBlock == null) { + return null; + } byte[] serializedBytes = serializedBlock.getA(); Integer serializationVersion = serializedBlock.getB(); if (serializedBytes == null || serializationVersion == null) { @@ -188,6 +191,9 @@ public class BlockArchiveReader { Integer height = this.fetchHeightForSignature(signature, repository); if (height != null) { Triple serializedBlock = this.fetchSerializedBlockBytesForHeight(height); + if (serializedBlock == null) { + return null; + } byte[] blockBytes = serializedBlock.getA(); Integer version = serializedBlock.getB(); if (blockBytes == null || version == null) { diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 1799f3c4..87d0a93c 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -134,6 +134,7 @@ public class BlockArchiveWriter { return BlockArchiveWriteResult.STOPPING; } if (Synchronizer.getInstance().isSynchronizing()) { + Thread.sleep(1000L); continue; } @@ -180,9 +181,12 @@ public class BlockArchiveWriter { if (atStatesHash != null) { block = new Block(repository, blockData, transactions, atStatesHash); } - else { + else if (atStates != null) { block = new Block(repository, blockData, transactions, atStates); } + else { + block = new Block(repository, blockData); + } // Write the block data to some byte buffers int blockIndex = bytes.size(); diff --git a/src/main/java/org/qortal/utils/BlockArchiveUtils.java b/src/main/java/org/qortal/utils/BlockArchiveUtils.java index 84de1a31..807faef9 100644 --- a/src/main/java/org/qortal/utils/BlockArchiveUtils.java +++ b/src/main/java/org/qortal/utils/BlockArchiveUtils.java @@ -21,6 +21,16 @@ public class BlockArchiveUtils { * into the HSQLDB, in order to make it SQL-compatible * again. *

+ * This is only fully compatible with archives that use + * serialization version 1. For version 2 (or above), + * we are unable to import individual AT states as we + * only have a single combined hash, so the use cases + * for this are greatly limited. + *

+ * A version 1 archive should ultimately be rebuildable + * via a resync or reindex from genesis, allowing + * access to this feature once again. + *

* Note: calls discardChanges() and saveChanges(), so * make sure that you commit any existing repository * changes before calling this method. @@ -61,9 +71,18 @@ public class BlockArchiveUtils { repository.getBlockRepository().save(blockInfo.getBlockData()); // Save AT state data hashes - for (ATStateData atStateData : blockInfo.getAtStates()) { - atStateData.setHeight(blockInfo.getBlockData().getHeight()); - repository.getATRepository().save(atStateData); + if (blockInfo.getAtStates() != null) { + for (ATStateData atStateData : blockInfo.getAtStates()) { + atStateData.setHeight(blockInfo.getBlockData().getHeight()); + repository.getATRepository().save(atStateData); + } + } + else { + // We don't have AT state hashes, so we are only importing a partial state. + // This can still be useful to allow orphaning to very old blocks, when we + // need to access other chainstate info (such as balances) at an earlier block. + // In order to do this, the orphan process must be temporarily adjusted to avoid + // orphaning AT states, as it will otherwise fail due to having no previous state. } } catch (DataException e) { From abdc265fc62580fc74153851539edfc67f4003db Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 26 Feb 2023 16:54:14 +0000 Subject: [PATCH 234/496] Removed legacy bulk archiving/pruning code that is no longer needed. --- .../org/qortal/controller/Controller.java | 8 +- .../qortal/repository/RepositoryManager.java | 61 ---- .../hsqldb/HSQLDBDatabaseArchiving.java | 88 ----- .../hsqldb/HSQLDBDatabasePruning.java | 332 ------------------ 4 files changed, 2 insertions(+), 487 deletions(-) delete mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java delete mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index ed1d2d07..f0bd1ef5 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -400,12 +400,8 @@ public class Controller extends Thread { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); RepositoryManager.setRequestedCheckpoint(Boolean.TRUE); - - try (final Repository repository = RepositoryManager.getRepository()) { - RepositoryManager.archive(repository); - RepositoryManager.prune(repository); - } - } catch (DataException e) { + } + catch (DataException e) { // If exception has no cause then repository is in use by some other process. if (e.getCause() == null) { LOGGER.info("Repository in use by another process?"); diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 0d9325b9..9d76ccae 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -2,11 +2,6 @@ package org.qortal.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.gui.SplashFrame; -import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; -import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; -import org.qortal.repository.hsqldb.HSQLDBRepository; -import org.qortal.settings.Settings; import java.sql.SQLException; import java.util.concurrent.TimeoutException; @@ -61,62 +56,6 @@ public abstract class RepositoryManager { } } - public static boolean archive(Repository repository) { - if (Settings.getInstance().isLite()) { - // Lite nodes have no blockchain - return false; - } - - // Bulk archive the database the first time we use archive mode - if (Settings.getInstance().isArchiveEnabled()) { - if (RepositoryManager.canArchiveOrPrune()) { - try { - return HSQLDBDatabaseArchiving.buildBlockArchive(repository, BlockArchiveWriter.DEFAULT_FILE_SIZE_TARGET); - - } catch (DataException e) { - LOGGER.info("Unable to build block archive. The database may have been left in an inconsistent state."); - } - } - else { - LOGGER.info("Unable to build block archive due to missing ATStatesHeightIndex. Bootstrapping is recommended."); - LOGGER.info("To bootstrap, stop the core and delete the db folder, then start the core again."); - SplashFrame.getInstance().updateStatus("Missing index. Bootstrapping is recommended."); - } - } - return false; - } - - public static boolean prune(Repository repository) { - if (Settings.getInstance().isLite()) { - // Lite nodes have no blockchain - return false; - } - - // Bulk prune the database the first time we use top-only or block archive mode - if (Settings.getInstance().isTopOnly() || - Settings.getInstance().isArchiveEnabled()) { - if (RepositoryManager.canArchiveOrPrune()) { - try { - boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates((HSQLDBRepository) repository); - boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks((HSQLDBRepository) repository); - - // Perform repository maintenance to shrink the db size down - if (prunedATStates && prunedBlocks) { - HSQLDBDatabasePruning.performMaintenance(repository); - return true; - } - - } catch (SQLException | DataException e) { - LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); - } - } - else { - LOGGER.info("Unable to prune blocks due to missing ATStatesHeightIndex. Bootstrapping is recommended."); - } - } - return false; - } - public static void setRequestedCheckpoint(Boolean quick) { quickCheckpointRequested = quick; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java deleted file mode 100644 index 90022b00..00000000 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.qortal.repository.hsqldb; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.qortal.controller.Controller; -import org.qortal.gui.SplashFrame; -import org.qortal.repository.BlockArchiveWriter; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.transform.TransformationException; - -import java.io.IOException; - -/** - * - * When switching to an archiving node, we need to archive most of the database contents. - * This involves copying its data into flat files. - * If we do this entirely as a background process, it is very slow and can interfere with syncing. - * However, if we take the approach of doing this in bulk, before starting up the rest of the - * processes, this makes it much faster and less invasive. - * - * From that point, the original background archiving process will run, but can be dialled right down - * so not to interfere with syncing. - * - */ - - -public class HSQLDBDatabaseArchiving { - - private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabaseArchiving.class); - - - public static boolean buildBlockArchive(Repository repository, long fileSizeTarget) throws DataException { - - // Only build the archive if we haven't already got one that is up to date - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); - if (upToDate) { - // Already archived - return false; - } - - LOGGER.info("Building block archive - this process could take a while..."); - SplashFrame.getInstance().updateStatus("Building block archive..."); - - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); - int startHeight = 0; - - while (!Controller.isStopping()) { - try { - BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); - writer.setFileSizeTarget(fileSizeTarget); - BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); - switch (result) { - case OK: - // Increment block archive height - startHeight = writer.getLastWrittenHeight() + 1; - repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); - repository.saveChanges(); - break; - - case STOPPING: - return false; - - case NOT_ENOUGH_BLOCKS: - // We've reached the limit of the blocks we can archive - // Return from the whole method - return true; - - case BLOCK_NOT_FOUND: - // We tried to archive a block that didn't exist. This is a major failure and likely means - // that a bootstrap or re-sync is needed. Return rom the method - LOGGER.info("Error: block not found when building archive. If this error persists, " + - "a bootstrap or re-sync may be needed."); - return false; - } - - } catch (IOException | TransformationException | InterruptedException e) { - LOGGER.info("Caught exception when creating block cache", e); - return false; - } - } - - // If we got this far then something went wrong (most likely the app is stopping) - return false; - } - -} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java deleted file mode 100644 index e2bfc9ef..00000000 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ /dev/null @@ -1,332 +0,0 @@ -package org.qortal.repository.hsqldb; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.qortal.controller.Controller; -import org.qortal.data.block.BlockData; -import org.qortal.gui.SplashFrame; -import org.qortal.repository.BlockArchiveWriter; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.settings.Settings; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.concurrent.TimeoutException; - -/** - * - * When switching from a full node to a pruning node, we need to delete most of the database contents. - * If we do this entirely as a background process, it is very slow and can interfere with syncing. - * However, if we take the approach of transferring only the necessary rows to a new table and then - * deleting the original table, this makes the process much faster. It was taking several days to - * delete the AT states in the background, but only a couple of minutes to copy them to a new table. - * - * The trade off is that we have to go through a form of "reshape" when starting the app for the first - * time after enabling pruning mode. But given that this is an opt-in mode, I don't think it will be - * a problem. - * - * Once the pruning is complete, it automatically performs a CHECKPOINT DEFRAG in order to - * shrink the database file size down to a fraction of what it was before. - * - * From this point, the original background process will run, but can be dialled right down so not - * to interfere with syncing. - * - */ - - -public class HSQLDBDatabasePruning { - - private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabasePruning.class); - - - public static boolean pruneATStates(HSQLDBRepository repository) throws SQLException, DataException { - - // Only bulk prune AT states if we have never done so before - int pruneHeight = repository.getATRepository().getAtPruneHeight(); - if (pruneHeight > 0) { - // Already pruned AT states - return false; - } - - if (Settings.getInstance().isArchiveEnabled()) { - // Only proceed if we can see that the archiver has already finished - // This way, if the archiver failed for any reason, we can prune once it has had - // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); - if (!upToDate) { - return false; - } - } - - LOGGER.info("Starting bulk prune of AT states - this process could take a while... " + - "(approx. 2 mins on high spec, or upwards of 30 mins in some cases)"); - SplashFrame.getInstance().updateStatus("Pruning database (takes up to 30 mins)..."); - - // Create new AT-states table to hold smaller dataset - repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); - repository.executeCheckedUpdate("CREATE TABLE ATStatesNew (" - + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " - + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " - + "PRIMARY KEY (AT_address, height), " - + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); - repository.executeCheckedUpdate("SET TABLE ATStatesNew NEW SPACE"); - repository.executeCheckedUpdate("CHECKPOINT"); - - // Add a height index - LOGGER.info("Adding index to AT states table..."); - repository.executeCheckedUpdate("CREATE INDEX IF NOT EXISTS ATStatesNewHeightIndex ON ATStatesNew (height)"); - repository.executeCheckedUpdate("CHECKPOINT"); - - - // Find our latest block - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (latestBlock == null) { - LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); - return false; - } - - // Calculate some constants for later use - final int blockchainHeight = latestBlock.getHeight(); - int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); - if (Settings.getInstance().isArchiveEnabled()) { - // Archive mode - don't prune anything that hasn't been archived yet - maximumBlockToTrim = Math.min(maximumBlockToTrim, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); - } - final int endHeight = blockchainHeight; - final int blockStep = 10000; - - - // It's essential that we rebuild the latest AT states here, as we are using this data in the next query. - // Failing to do this will result in important AT states being deleted, rendering the database unusable. - repository.getATRepository().rebuildLatestAtStates(endHeight); - - - // Loop through all the LatestATStates and copy them to the new table - LOGGER.info("Copying AT states..."); - for (int height = 0; height < endHeight; height += blockStep) { - final int batchEndHeight = height + blockStep - 1; - //LOGGER.info(String.format("Copying AT states between %d and %d...", height, batchEndHeight)); - - String sql = "SELECT height, AT_address FROM LatestATStates WHERE height BETWEEN ? AND ?"; - try (ResultSet latestAtStatesResultSet = repository.checkedExecute(sql, height, batchEndHeight)) { - if (latestAtStatesResultSet != null) { - do { - int latestAtHeight = latestAtStatesResultSet.getInt(1); - String latestAtAddress = latestAtStatesResultSet.getString(2); - - // Copy this latest ATState to the new table - //LOGGER.info(String.format("Copying AT %s at height %d...", latestAtAddress, latestAtHeight)); - try { - String updateSql = "INSERT INTO ATStatesNew (" - + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " - + "FROM ATStates " - + "WHERE height = ? AND AT_address = ?)"; - repository.executeCheckedUpdate(updateSql, latestAtHeight, latestAtAddress); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to copy ATStates", e); - } - - // If this batch includes blocks after the maximum block to trim, we will need to copy - // each of its AT states above maximumBlockToTrim as they are considered "recent". We - // need to do this for _all_ AT states in these blocks, regardless of their latest state. - if (batchEndHeight >= maximumBlockToTrim) { - // Now copy this AT's states for each recent block they are present in - for (int i = maximumBlockToTrim; i < endHeight; i++) { - if (latestAtHeight < i) { - // This AT finished before this block so there is nothing to copy - continue; - } - - //LOGGER.info(String.format("Copying recent AT %s at height %d...", latestAtAddress, i)); - try { - // Copy each LatestATState to the new table - String updateSql = "INSERT IGNORE INTO ATStatesNew (" - + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " - + "FROM ATStates " - + "WHERE height = ? AND AT_address = ?)"; - repository.executeCheckedUpdate(updateSql, i, latestAtAddress); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to copy ATStates", e); - } - } - } - repository.saveChanges(); - - } while (latestAtStatesResultSet.next()); - } - } catch (SQLException e) { - throw new DataException("Unable to copy AT states", e); - } - } - - - // Finally, drop the original table and rename - LOGGER.info("Deleting old AT states..."); - repository.executeCheckedUpdate("DROP TABLE ATStates"); - repository.executeCheckedUpdate("ALTER TABLE ATStatesNew RENAME TO ATStates"); - repository.executeCheckedUpdate("ALTER INDEX ATStatesNewHeightIndex RENAME TO ATStatesHeightIndex"); - repository.executeCheckedUpdate("CHECKPOINT"); - - // Update the prune height - int nextPruneHeight = maximumBlockToTrim + 1; - repository.getATRepository().setAtPruneHeight(nextPruneHeight); - repository.saveChanges(); - - repository.executeCheckedUpdate("CHECKPOINT"); - - // Now prune/trim the ATStatesData, as this currently goes back over a month - return HSQLDBDatabasePruning.pruneATStateData(repository); - } - - /* - * Bulk prune ATStatesData to catch up with the now pruned ATStates table - * This uses the existing AT States trimming code but with a much higher end block - */ - private static boolean pruneATStateData(Repository repository) throws DataException { - - if (Settings.getInstance().isArchiveEnabled()) { - // Don't prune ATStatesData in archive mode - return true; - } - - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (latestBlock == null) { - LOGGER.info("Unable to determine blockchain height, necessary for bulk ATStatesData pruning"); - return false; - } - final int blockchainHeight = latestBlock.getHeight(); - int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); - // ATStateData is already trimmed - so carry on from where we left off in the past - int pruneStartHeight = repository.getATRepository().getAtTrimHeight(); - - LOGGER.info("Starting bulk prune of AT states data - this process could take a while... (approx. 3 mins on high spec)"); - - while (pruneStartHeight < upperPrunableHeight) { - // Prune all AT state data up until our latest minus pruneBlockLimit (or our archive height) - - if (Controller.isStopping()) { - return false; - } - - // Override batch size in the settings because this is a one-off process - final int batchSize = 1000; - final int rowLimitPerBatch = 50000; - int upperBatchHeight = pruneStartHeight + batchSize; - int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); - - LOGGER.trace(String.format("Pruning AT states data between %d and %d...", pruneStartHeight, upperPruneHeight)); - - int numATStatesPruned = repository.getATRepository().trimAtStates(pruneStartHeight, upperPruneHeight, rowLimitPerBatch); - repository.saveChanges(); - - if (numATStatesPruned > 0) { - LOGGER.trace(String.format("Pruned %d AT states data rows between blocks %d and %d", - numATStatesPruned, pruneStartHeight, upperPruneHeight)); - } else { - repository.getATRepository().setAtTrimHeight(upperBatchHeight); - // No need to rebuild the latest AT states as we aren't currently synchronizing - repository.saveChanges(); - LOGGER.debug(String.format("Bumping AT states trim height to %d", upperBatchHeight)); - - // Can we move onto next batch? - if (upperPrunableHeight > upperBatchHeight) { - pruneStartHeight = upperBatchHeight; - } - else { - // We've finished pruning - break; - } - } - } - - return true; - } - - public static boolean pruneBlocks(Repository repository) throws SQLException, DataException { - - // Only bulk prune AT states if we have never done so before - int pruneHeight = repository.getBlockRepository().getBlockPruneHeight(); - if (pruneHeight > 0) { - // Already pruned blocks - return false; - } - - if (Settings.getInstance().isArchiveEnabled()) { - // Only proceed if we can see that the archiver has already finished - // This way, if the archiver failed for any reason, we can prune once it has had - // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); - if (!upToDate) { - return false; - } - } - - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (latestBlock == null) { - LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); - return false; - } - final int blockchainHeight = latestBlock.getHeight(); - int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); - int pruneStartHeight = 0; - - if (Settings.getInstance().isArchiveEnabled()) { - // Archive mode - don't prune anything that hasn't been archived yet - upperPrunableHeight = Math.min(upperPrunableHeight, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); - } - - LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 5 mins on high spec)"); - - while (pruneStartHeight < upperPrunableHeight) { - // Prune all blocks up until our latest minus pruneBlockLimit - - int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); - int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); - - LOGGER.info(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); - - int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); - repository.saveChanges(); - - if (numBlocksPruned > 0) { - LOGGER.info(String.format("Pruned %d block%s between %d and %d", - numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), - pruneStartHeight, upperPruneHeight)); - } else { - final int nextPruneHeight = upperPruneHeight + 1; - repository.getBlockRepository().setBlockPruneHeight(nextPruneHeight); - repository.saveChanges(); - LOGGER.debug(String.format("Bumping block base prune height to %d", nextPruneHeight)); - - // Can we move onto next batch? - if (upperPrunableHeight > nextPruneHeight) { - pruneStartHeight = nextPruneHeight; - } - else { - // We've finished pruning - break; - } - } - } - - return true; - } - - public static void performMaintenance(Repository repository) throws SQLException, DataException { - try { - SplashFrame.getInstance().updateStatus("Performing maintenance..."); - - // Timeout if the database isn't ready for backing up after 5 minutes - // Nothing else should be using the db at this point, so a timeout shouldn't happen - long timeout = 5 * 60 * 1000L; - repository.performPeriodicMaintenance(timeout); - - } catch (TimeoutException e) { - LOGGER.info("Attempt to perform maintenance failed due to timeout: {}", e.getMessage()); - } - } - -} From fa14568cb9c3000dd7351cde8e95714a57230f65 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 10:42:43 +0000 Subject: [PATCH 235/496] 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 236/496] 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 237/496] 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 238/496] 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 239/496] 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 240/496] 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 241/496] 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 242/496] 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 0b05de22a04d452b962df5557e06c4a456773e49 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 16:14:43 +0000 Subject: [PATCH 243/496] 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 7d38fa909d8f02f2f46d7d22b939f81d8057b6f7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 16:14:43 +0000 Subject: [PATCH 244/496] 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 7d7cea3278fe8897c074aa5f1f29ecc52f690715 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 17:10:14 +0000 Subject: [PATCH 245/496] 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 cf0681d7df6620acdc1a47b1f9d100670591177e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Mar 2023 17:10:14 +0000 Subject: [PATCH 246/496] 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 247/496] 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 248/496] 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 249/496] 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 250/496] 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 251/496] 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 252/496] 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 253/496] 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 7f21ea7e0044acf9ac0a8a3ceb81341592558218 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 5 Mar 2023 13:16:58 +0000 Subject: [PATCH 254/496] Added new bootstrap host --- src/main/java/org/qortal/settings/Settings.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index ae5dc173..05012b41 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -273,6 +273,7 @@ public class Settings { private String[] bootstrapHosts = new String[] { "http://bootstrap.qortal.org", "http://bootstrap2.qortal.org", + "http://bootstrap3.qortal.org", "http://bootstrap.qortal.online" }; From 3739920ad38d4750553be2c54a8b25de7588cabd Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 6 Mar 2023 13:17:48 +0000 Subject: [PATCH 255/496] Added support for an optional fee in arbitrary transactions, to give the option for data to be published instantly (i.e. no proof of work / mempow required when fee is sufficient). Takes effect at a future undecided timestamp. --- .../api/resource/ArbitraryResource.java | 35 +- .../ArbitraryDataTransactionBuilder.java | 6 +- .../java/org/qortal/block/BlockChain.java | 7 +- .../transaction/ArbitraryTransaction.java | 18 +- src/main/resources/blockchain.json | 3 +- .../ArbitraryDataStoragePolicyTests.java | 2 +- .../ArbitraryTransactionMetadataTests.java | 8 +- .../arbitrary/ArbitraryTransactionTests.java | 346 +++++++++++++++++- .../qortal/test/common/ArbitraryUtils.java | 11 +- .../test-chain-v2-block-timestamps.json | 3 +- .../test-chain-v2-disable-reference.json | 3 +- .../test-chain-v2-founder-rewards.json | 3 +- .../test-chain-v2-leftover-reward.json | 3 +- src/test/resources/test-chain-v2-minting.json | 5 +- .../test-chain-v2-qora-holder-extremes.json | 3 +- .../test-chain-v2-qora-holder-reduction.json | 3 +- .../resources/test-chain-v2-qora-holder.json | 3 +- .../test-chain-v2-reward-levels.json | 3 +- .../test-chain-v2-reward-scaling.json | 3 +- .../test-chain-v2-reward-shares.json | 3 +- .../test-chain-v2-self-sponsorship-algo.json | 3 +- src/test/resources/test-chain-v2.json | 3 +- 22 files changed, 433 insertions(+), 44 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 0df81d9b..235e3edc 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -773,6 +773,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("fee") Long fee, String path) { Security.checkApiCallAllowed(request); @@ -781,7 +782,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, path, null, null, false, - title, description, tags, category); + fee, title, description, tags, category); } @POST @@ -818,6 +819,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("fee") Long fee, String path) { Security.checkApiCallAllowed(request); @@ -826,7 +828,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, path, null, null, false, - title, description, tags, category); + fee, title, description, tags, category); } @@ -864,6 +866,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("fee") Long fee, String base64) { Security.checkApiCallAllowed(request); @@ -872,7 +875,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, null, null, base64, false, - title, description, tags, category); + fee, title, description, tags, category); } @POST @@ -907,6 +910,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("fee") Long fee, String base64) { Security.checkApiCallAllowed(request); @@ -915,7 +919,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64, false, - title, description, tags, category); + fee, title, description, tags, category); } @@ -952,6 +956,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("fee") Long fee, String base64Zip) { Security.checkApiCallAllowed(request); @@ -960,7 +965,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, null, null, base64Zip, true, - title, description, tags, category); + fee, title, description, tags, category); } @POST @@ -995,6 +1000,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("fee") Long fee, String base64Zip) { Security.checkApiCallAllowed(request); @@ -1003,7 +1009,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64Zip, true, - title, description, tags, category); + fee, title, description, tags, category); } @@ -1043,6 +1049,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("fee") Long fee, String string) { Security.checkApiCallAllowed(request); @@ -1051,7 +1058,7 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, null, null, string, null, false, - title, description, tags, category); + fee, title, description, tags, category); } @POST @@ -1088,6 +1095,7 @@ public class ArbitraryResource { @QueryParam("description") String description, @QueryParam("tags") List tags, @QueryParam("category") Category category, + @QueryParam("fee") Long fee, String string) { Security.checkApiCallAllowed(request); @@ -1096,14 +1104,14 @@ public class ArbitraryResource { } return this.upload(Service.valueOf(serviceString), name, identifier, null, string, null, false, - title, description, tags, category); + fee, title, description, tags, category); } // Shared methods - private String upload(Service service, String name, String identifier, - String path, String string, String base64, boolean zipped, + private String upload(Service service, String name, String identifier, String path, + String string, String base64, boolean zipped, Long fee, String title, String description, List tags, Category category) { // Fetch public key from registered name try (final Repository repository = RepositoryManager.getRepository()) { @@ -1167,9 +1175,14 @@ public class ArbitraryResource { } } + // Default to zero fee if not specified + if (fee == null) { + fee = 0L; + } + try { ArbitraryDataTransactionBuilder transactionBuilder = new ArbitraryDataTransactionBuilder( - repository, publicKey58, Paths.get(path), name, null, service, identifier, + repository, publicKey58, fee, Paths.get(path), name, null, service, identifier, title, description, tags, category ); diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java index 0f3d4357..b27e511c 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java @@ -46,6 +46,7 @@ public class ArbitraryDataTransactionBuilder { private static final double MAX_FILE_DIFF = 0.5f; private final String publicKey58; + private final long fee; private final Path path; private final String name; private Method method; @@ -64,11 +65,12 @@ public class ArbitraryDataTransactionBuilder { private ArbitraryTransactionData arbitraryTransactionData; private ArbitraryDataFile arbitraryDataFile; - public ArbitraryDataTransactionBuilder(Repository repository, String publicKey58, Path path, String name, + public ArbitraryDataTransactionBuilder(Repository repository, String publicKey58, long fee, Path path, String name, Method method, Service service, String identifier, String title, String description, List tags, Category category) { this.repository = repository; this.publicKey58 = publicKey58; + this.fee = fee; this.path = path; this.name = name; this.method = method; @@ -261,7 +263,7 @@ public class ArbitraryDataTransactionBuilder { } final BaseTransactionData baseTransactionData = new BaseTransactionData(now, Group.NO_GROUP, - lastReference, creatorPublicKey, 0L, null); + lastReference, creatorPublicKey, fee, null); final int size = (int) arbitraryDataFile.size(); final int version = 5; final int nonce = 0; diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index b96350e6..88880887 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -78,7 +78,8 @@ public class BlockChain { onlineAccountMinterLevelValidationHeight, selfSponsorshipAlgoV1Height, feeValidationFixTimestamp, - chatReferenceTimestamp; + chatReferenceTimestamp, + arbitraryOptionalFeeTimestamp; } // Custom transaction fees @@ -522,6 +523,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.chatReferenceTimestamp.name()).longValue(); } + public long getArbitraryOptionalFeeTimestamp() { + return this.featureTriggers.get(FeatureTrigger.arbitraryOptionalFeeTimestamp.name()).longValue(); + } + // More complex getters for aspects that change by height or timestamp diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index 7e7d4040..3452f916 100644 --- a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java +++ b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java @@ -88,6 +88,12 @@ public class ArbitraryTransaction extends Transaction { if (this.transactionData.getFee() < 0) return ValidationResult.NEGATIVE_FEE; + // After the feature trigger, we require the fee to be sufficient if it's not 0. + // If the fee is zero, then the nonce is validated in isSignatureValid() as an alternative to a fee + if (this.arbitraryTransactionData.getTimestamp() >= BlockChain.getInstance().getArbitraryOptionalFeeTimestamp() && this.arbitraryTransactionData.getFee() != 0L) { + return super.isFeeValid(); + } + return ValidationResult.OK; } @@ -208,10 +214,14 @@ public class ArbitraryTransaction extends Transaction { // Clear nonce from transactionBytes ArbitraryTransactionTransformer.clearNonce(transactionBytes); - // We only need to check nonce for recent transactions due to PoW verification overhead - if (NTP.getTime() - this.arbitraryTransactionData.getTimestamp() < HISTORIC_THRESHOLD) { - int difficulty = ArbitraryDataManager.getInstance().getPowDifficulty(); - return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); + // As of feature-trigger timestamp, we only require a nonce when the fee is zero + boolean beforeFeatureTrigger = this.arbitraryTransactionData.getTimestamp() < BlockChain.getInstance().getArbitraryOptionalFeeTimestamp(); + if (beforeFeatureTrigger || this.arbitraryTransactionData.getFee() == 0L) { + // We only need to check nonce for recent transactions due to PoW verification overhead + if (NTP.getTime() - this.arbitraryTransactionData.getTimestamp() < HISTORIC_THRESHOLD) { + int difficulty = ArbitraryDataManager.getInstance().getPowDifficulty(); + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); + } } } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 46b4b4f9..7ce93a28 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -85,7 +85,8 @@ "onlineAccountMinterLevelValidationHeight": 1092000, "selfSponsorshipAlgoV1Height": 1092400, "feeValidationFixTimestamp": 1671918000000, - "chatReferenceTimestamp": 1674316800000 + "chatReferenceTimestamp": 1674316800000, + "arbitraryOptionalFeeTimestamp": 9999999999999 }, "checkpoints": [ { "height": 1136300, "signature": "3BbwawEF2uN8Ni5ofpJXkukoU8ctAPxYoFB7whq9pKfBnjfZcpfEJT4R95NvBDoTP8WDyWvsUvbfHbcr9qSZuYpSKZjUQTvdFf6eqznHGEwhZApWfvXu6zjGCxYCp65F4jsVYYJjkzbjmkCg5WAwN5voudngA23kMK6PpTNygapCzXt" } diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java index 9bf76127..49e645cf 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java @@ -246,7 +246,7 @@ public class ArbitraryDataStoragePolicyTests extends Common { Path path = Paths.get("src/test/resources/arbitrary/demo1"); ArbitraryDataTransactionBuilder txnBuilder = new ArbitraryDataTransactionBuilder( - repository, publicKey58, path, name, Method.PUT, Service.ARBITRARY_DATA, null, + repository, publicKey58, 0L, path, name, Method.PUT, Service.ARBITRARY_DATA, null, null, null, null, null); txnBuilder.build(); diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java index 5d28568d..bf4f0a70 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java @@ -107,7 +107,7 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, title, description, tags, category); // Check the chunk count is correct @@ -157,7 +157,7 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, title, description, tags, category); // Check the chunk count is correct @@ -219,7 +219,7 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, title, description, tags, category); // Check the chunk count is correct @@ -273,7 +273,7 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, title, description, tags, category); // Check the metadata is correct diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java index 294e463e..2c2d52b2 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java @@ -5,6 +5,7 @@ import org.junit.Before; 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; @@ -20,9 +21,11 @@ import org.qortal.test.common.TransactionUtils; import org.qortal.test.common.transaction.TestTransaction; import org.qortal.transaction.ArbitraryTransaction; import org.qortal.transaction.RegisterNameTransaction; +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; @@ -36,7 +39,7 @@ public class ArbitraryTransactionTests extends Common { } @Test - public void testDifficultyTooLow() throws IllegalAccessException, DataException, IOException, MissingDataException { + public void testDifficultyTooLow() throws IllegalAccessException, DataException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String publicKey58 = Base58.encode(alice.getPublicKey()); @@ -78,7 +81,346 @@ public class ArbitraryTransactionTests extends Common { assertTrue(transaction.isSignatureValid()); } - } + @Test + public void testNonceAndFee() throws IllegalAccessException, DataException, IOException { + 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 + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 10000000; // sufficient + boolean computeNonce = true; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + + // Check that nonce validation succeeds + byte[] signature = arbitraryDataFile.getSignature(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); + assertTrue(transaction.isSignatureValid()); + + // Increase difficulty to 15 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); + + // Make sure that nonce validation still succeeds, as the fee has allowed us to avoid including a nonce + assertTrue(transaction.isSignatureValid()); + } + } + + @Test + public void testNonceAndLowFee() throws IllegalAccessException, DataException, IOException { + 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 + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee that is too low + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 9999999; // insufficient + boolean computeNonce = true; + boolean insufficientFeeDetected = false; + try { + ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + } + catch (DataException e) { + if (e.getMessage().contains("INSUFFICIENT_FEE")) { + insufficientFeeDetected = true; + } + } + + // Transaction should be invalid due to an insufficient fee + assertTrue(insufficientFeeDetected); + } + } + + @Test + public void testFeeNoNonce() throws IllegalAccessException, DataException, IOException { + 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 + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 10000000; // sufficient + boolean computeNonce = false; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + + // Check that nonce validation succeeds, even though it wasn't computed. This is because we have included a sufficient fee. + byte[] signature = arbitraryDataFile.getSignature(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); + assertTrue(transaction.isSignatureValid()); + + // Increase difficulty to 15 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); + + // Make sure that nonce validation still succeeds, as the fee has allowed us to avoid including a nonce + assertTrue(transaction.isSignatureValid()); + } + } + + @Test + public void testLowFeeNoNonce() throws IllegalAccessException, DataException, IOException { + 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 + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee that is too low. Also, don't compute a nonce. + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 9999999; // insufficient + + ArbitraryDataTransactionBuilder txnBuilder = new ArbitraryDataTransactionBuilder( + repository, publicKey58, fee, path1, name, ArbitraryTransactionData.Method.PUT, service, identifier, null, null, null, null); + + txnBuilder.setChunkSize(chunkSize); + txnBuilder.build(); + ArbitraryTransactionData transactionData = txnBuilder.getArbitraryTransactionData(); + Transaction.ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, alice); + + // Transaction should be invalid due to an insufficient fee + assertEquals(Transaction.ValidationResult.INSUFFICIENT_FEE, result); + } + } + + @Test + public void testZeroFeeNoNonce() throws IllegalAccessException, DataException, IOException { + 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 + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee that is too low. Also, don't compute a nonce. + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 0L; + + ArbitraryDataTransactionBuilder txnBuilder = new ArbitraryDataTransactionBuilder( + repository, publicKey58, fee, path1, name, ArbitraryTransactionData.Method.PUT, service, identifier, null, null, null, null); + + txnBuilder.setChunkSize(chunkSize); + txnBuilder.build(); + ArbitraryTransactionData transactionData = txnBuilder.getArbitraryTransactionData(); + ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData); + + // Transaction should be invalid + assertFalse(arbitraryTransaction.isSignatureValid()); + } + } + + @Test + public void testNonceAndFeeBeforeFeatureTrigger() throws IllegalAccessException, DataException, IOException { + // Use v2-minting settings, as these are pre-feature-trigger + Common.useSettings("test-settings-v2-minting.json"); + + 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 + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 10000000; // sufficient + boolean computeNonce = true; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + + // Check that nonce validation succeeds + byte[] signature = arbitraryDataFile.getSignature(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); + assertTrue(transaction.isSignatureValid()); + + // Increase difficulty to 15 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); + + // Make sure the nonce validation fails, as we aren't allowing a fee to replace a nonce yet. + // Note: there is a very tiny chance this could succeed due to being extremely lucky + // and finding a high difficulty nonce in the first couple of cycles. It will be rare + // enough that we shouldn't need to account for it. + assertFalse(transaction.isSignatureValid()); + + // Reduce difficulty back to 1, to double check + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + assertTrue(transaction.isSignatureValid()); + } + } + + @Test + public void testNonceAndInsufficientFeeBeforeFeatureTrigger() throws IllegalAccessException, DataException, IOException { + // Use v2-minting settings, as these are pre-feature-trigger + Common.useSettings("test-settings-v2-minting.json"); + + 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 + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 9999999; // insufficient + boolean computeNonce = true; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + + // Check that nonce validation succeeds + byte[] signature = arbitraryDataFile.getSignature(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); + assertTrue(transaction.isSignatureValid()); + + // The transaction should be valid because we don't care about the fee (before the feature trigger) + assertEquals(Transaction.ValidationResult.OK, transaction.isValidUnconfirmed()); + + // Increase difficulty to 15 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); + + // Make sure the nonce validation fails, as we aren't allowing a fee to replace a nonce yet (and it was insufficient anyway) + // Note: there is a very tiny chance this could succeed due to being extremely lucky + // and finding a high difficulty nonce in the first couple of cycles. It will be rare + // enough that we shouldn't need to account for it. + assertFalse(transaction.isSignatureValid()); + + // Reduce difficulty back to 1, to double check + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + assertTrue(transaction.isSignatureValid()); + } + } + + @Test + public void testNonceAndZeroFeeBeforeFeatureTrigger() throws IllegalAccessException, DataException, IOException { + // Use v2-minting settings, as these are pre-feature-trigger + Common.useSettings("test-settings-v2-minting.json"); + + 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 + + // Register the name to Alice + RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction, with a fee + Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = 0L; + boolean computeNonce = true; + ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, computeNonce, null, null, null, null); + + // Check that nonce validation succeeds + byte[] signature = arbitraryDataFile.getSignature(); + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); + assertTrue(transaction.isSignatureValid()); + + // The transaction should be valid because we don't care about the fee (before the feature trigger) + assertEquals(Transaction.ValidationResult.OK, transaction.isValidUnconfirmed()); + + // Increase difficulty to 15 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); + + // Make sure the nonce validation fails, as we aren't allowing a fee to replace a nonce yet (and it was insufficient anyway) + // Note: there is a very tiny chance this could succeed due to being extremely lucky + // and finding a high difficulty nonce in the first couple of cycles. It will be rare + // enough that we shouldn't need to account for it. + assertFalse(transaction.isSignatureValid()); + + // Reduce difficulty back to 1, to double check + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + assertTrue(transaction.isSignatureValid()); + } + } } diff --git a/src/test/java/org/qortal/test/common/ArbitraryUtils.java b/src/test/java/org/qortal/test/common/ArbitraryUtils.java index 81abf47f..73dc8097 100644 --- a/src/test/java/org/qortal/test/common/ArbitraryUtils.java +++ b/src/test/java/org/qortal/test/common/ArbitraryUtils.java @@ -29,19 +29,22 @@ public class ArbitraryUtils { int chunkSize) throws DataException { return ArbitraryUtils.createAndMintTxn(repository, publicKey58, path, name, identifier, method, service, - account, chunkSize, null, null, null, null); + account, chunkSize, 0L, true, null, null, null, null); } public static ArbitraryDataFile createAndMintTxn(Repository repository, String publicKey58, Path path, String name, String identifier, ArbitraryTransactionData.Method method, Service service, PrivateKeyAccount account, - int chunkSize, String title, String description, List tags, Category category) throws DataException { + int chunkSize, long fee, boolean computeNonce, + String title, String description, List tags, Category category) throws DataException { ArbitraryDataTransactionBuilder txnBuilder = new ArbitraryDataTransactionBuilder( - repository, publicKey58, path, name, method, service, identifier, title, description, tags, category); + repository, publicKey58, fee, path, name, method, service, identifier, title, description, tags, category); txnBuilder.setChunkSize(chunkSize); txnBuilder.build(); - txnBuilder.computeNonce(); + if (computeNonce) { + txnBuilder.computeNonce(); + } ArbitraryTransactionData transactionData = txnBuilder.getArbitraryTransactionData(); Transaction.ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, account); assertEquals(Transaction.ValidationResult.OK, result); diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 8c2e0503..3b4de702 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -75,7 +75,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index f7f8e7d8..c93fbb78 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -78,7 +78,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 20d10233..1b068932 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -79,7 +79,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index e71ebab6..aef76cc2 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -79,7 +79,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index 2a388e1f..db6d8a0b 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -74,12 +74,13 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999, + "disableReferenceTimestamp": 0, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index cface0e7..2452d4d2 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -79,7 +79,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index f233680b..23193729 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -80,7 +80,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 4ea82290..9d81632b 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -79,7 +79,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index 5de8d9ff..81609595 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -79,7 +79,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index c008ed42..21a5b7a7 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -79,7 +79,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 2fc0151f..6119ac48 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -79,7 +79,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index 68b33cc3..dc5f3961 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -79,7 +79,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 20, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 63abc695..d0c460df 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -79,7 +79,8 @@ "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "feeValidationFixTimestamp": 0, - "chatReferenceTimestamp": 0 + "chatReferenceTimestamp": 0, + "arbitraryOptionalFeeTimestamp": 0 }, "genesisInfo": { "version": 4, From b6803490b9f84309bc518ca9cc3e7af5467420be Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 6 Mar 2023 14:13:58 +0000 Subject: [PATCH 256/496] Archive version is now loaded from the version of block 2 in the existing archive, or "defaultArchiveVersion" in settings if not available (default: 1). --- .../qortal/api/resource/AdminResource.java | 24 ++++++++++++++----- .../qortal/repository/BlockArchiveReader.java | 13 ++++++++++ .../qortal/repository/BlockArchiveWriter.java | 24 +++++++++++++++---- .../java/org/qortal/settings/Settings.java | 6 ++--- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 0531f60d..ef2a3f95 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -738,8 +738,17 @@ public class AdminResource { @POST @Path("/repository/archive/rebuild") @Operation( - summary = "Rebuild archive.", - description = "Rebuilds archive files, using the serialization version specified via the archiveVersion setting.", + summary = "Rebuild archive", + description = "Rebuilds archive files, using the specified serialization version", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "number", example = "2" + ) + ) + ), responses = { @ApiResponse( description = "\"true\"", @@ -749,9 +758,14 @@ public class AdminResource { ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) @SecurityRequirement(name = "apiKey") - public String rebuildArchive(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { + public String rebuildArchive(@HeaderParam(Security.API_KEY_HEADER) String apiKey, Integer serializationVersion) { Security.checkApiCallAllowed(request); + // Default serialization version to value specified in settings + if (serializationVersion == null) { + serializationVersion = Settings.getInstance().getDefaultArchiveVersion(); + } + try { // We don't actually need to lock the blockchain here, but we'll do it anyway so that // the node can focus on rebuilding rather than synchronizing / minting. @@ -760,9 +774,7 @@ public class AdminResource { blockchainLock.lockInterruptibly(); try { - int archiveVersion = Settings.getInstance().getArchiveVersion(); - - BlockArchiveRebuilder blockArchiveRebuilder = new BlockArchiveRebuilder(archiveVersion); + BlockArchiveRebuilder blockArchiveRebuilder = new BlockArchiveRebuilder(serializationVersion); blockArchiveRebuilder.start(); return "true"; diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index e45f1fdf..1f04bced 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -64,6 +64,19 @@ public class BlockArchiveReader { this.fileListCache = Map.copyOf(map); } + public Integer fetchSerializationVersionForHeight(int height) { + if (this.fileListCache == null) { + this.fetchFileList(); + } + + Triple serializedBlock = this.fetchSerializedBlockBytesForHeight(height); + if (serializedBlock == null) { + return null; + } + Integer serializationVersion = serializedBlock.getB(); + return serializationVersion; + } + public BlockTransformation fetchBlockAtHeight(int height) { if (this.fileListCache == null) { this.fetchFileList(); diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 87d0a93c..8f4d4498 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -43,7 +43,7 @@ public class BlockArchiveWriter { private int startHeight; private final int endHeight; - private final int serializationVersion; + private final Integer serializationVersion; private final Path archivePath; private final Repository repository; @@ -65,12 +65,17 @@ public class BlockArchiveWriter { * @param endHeight * @param repository */ - public BlockArchiveWriter(int startHeight, int endHeight, int serializationVersion, Path archivePath, Repository repository) { + public BlockArchiveWriter(int startHeight, int endHeight, Integer serializationVersion, Path archivePath, Repository repository) { this.startHeight = startHeight; this.endHeight = endHeight; - this.serializationVersion = serializationVersion; this.archivePath = archivePath.toAbsolutePath(); this.repository = repository; + + if (serializationVersion == null) { + // When serialization version isn't specified, fetch it from the existing archive + serializationVersion = this.findSerializationVersion(); + } + this.serializationVersion = serializationVersion; } /** @@ -80,7 +85,18 @@ public class BlockArchiveWriter { * @param repository */ public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { - this(startHeight, endHeight, Settings.getInstance().getArchiveVersion(), Paths.get(Settings.getInstance().getRepositoryPath(), "archive"), repository); + this(startHeight, endHeight, null, Paths.get(Settings.getInstance().getRepositoryPath(), "archive"), repository); + } + + private int findSerializationVersion() { + // Attempt to fetch the serialization version from the existing archive + Integer block2SerializationVersion = BlockArchiveReader.getInstance().fetchSerializationVersionForHeight(2); + if (block2SerializationVersion != null) { + return block2SerializationVersion; + } + + // Default to version specified in settings + return Settings.getInstance().getDefaultArchiveVersion(); } public static int getMaxArchiveHeight(Repository repository) throws DataException { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 52b3aed5..d3405d4e 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -179,7 +179,7 @@ public class Settings { /** How often to attempt archiving (ms). */ private long archiveInterval = 7171L; // milliseconds /** Serialization version to use when building an archive */ - private int archiveVersion = 1; + private int defaultArchiveVersion = 1; /** Whether to automatically bootstrap instead of syncing from genesis */ @@ -928,8 +928,8 @@ public class Settings { return this.archiveInterval; } - public int getArchiveVersion() { - return this.archiveVersion; + public int getDefaultArchiveVersion() { + return this.defaultArchiveVersion; } From 96ac8835158e2bfe30fd9d96a035c0f46289b435 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 6 Mar 2023 14:40:17 +0000 Subject: [PATCH 257/496] Throw exception and break out of loop if archive rebuilding fails --- .../org/qortal/controller/repository/BlockArchiveRebuilder.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java b/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java index 78616a99..63579d3c 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiveRebuilder.java @@ -106,6 +106,7 @@ public class BlockArchiveRebuilder { } catch (IOException | TransformationException e) { LOGGER.info("Caught exception when rebuilding block archive", e); + throw new DataException("Unable to rebuild block archive"); } } From b1452bddf3123bdd3c3506e16682b598c93e7876 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 6 Mar 2023 17:17:55 +0000 Subject: [PATCH 258/496] Added BlockArchiveV2 tests, and updated the V1 tests now that we no longer support bulk archiving/pruning --- ...iveTests.java => BlockArchiveV1Tests.java} | 217 +------- .../org/qortal/test/BlockArchiveV2Tests.java | 504 ++++++++++++++++++ .../test-settings-v2-block-archive.json | 3 +- 3 files changed, 512 insertions(+), 212 deletions(-) rename src/test/java/org/qortal/test/{BlockArchiveTests.java => BlockArchiveV1Tests.java} (69%) create mode 100644 src/test/java/org/qortal/test/BlockArchiveV2Tests.java diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveV1Tests.java similarity index 69% rename from src/test/java/org/qortal/test/BlockArchiveTests.java rename to src/test/java/org/qortal/test/BlockArchiveV1Tests.java index 8b3de67b..a28bd28d 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveV1Tests.java @@ -1,6 +1,7 @@ package org.qortal.test; import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -10,8 +11,6 @@ import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.*; -import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; -import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.settings.Settings; import org.qortal.test.common.AtUtils; @@ -26,7 +25,6 @@ import org.qortal.utils.NTP; 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.sql.SQLException; @@ -34,13 +32,16 @@ import java.util.List; import static org.junit.Assert.*; -public class BlockArchiveTests extends Common { +public class BlockArchiveV1Tests extends Common { @Before - public void beforeTest() throws DataException { + public void beforeTest() throws DataException, IllegalAccessException { Common.useSettings("test-settings-v2-block-archive.json"); NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); this.deleteArchiveDirectory(); + + // Set default archive version to 1, so that archive builds in these tests use V2 + FieldUtils.writeField(Settings.getInstance(), "defaultArchiveVersion", 1, true); } @After @@ -333,212 +334,6 @@ public class BlockArchiveTests extends Common { } } - @Test - public void testBulkArchiveAndPrune() throws DataException, SQLException { - try (final Repository repository = RepositoryManager.getRepository()) { - HSQLDBRepository hsqldb = (HSQLDBRepository) repository; - - // Deploy an AT so that we have AT state data - PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); - byte[] creationBytes = AtUtils.buildSimpleAT(); - long fundingAmount = 1_00000000L; - AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); - - // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 1000; i++) { - BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); - } - - // Assume 900 blocks are trimmed (this specifies the first untrimmed height) - repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); - repository.getATRepository().setAtTrimHeight(901); - - // Check the max archive height - this should be one less than the first untrimmed height - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); - assertEquals(900, maximumArchiveHeight); - - // Check the current archive height - assertEquals(0, repository.getBlockArchiveRepository().getBlockArchiveHeight()); - - // Write blocks 2-900 to the archive (using bulk method) - int fileSizeTarget = 428600; // Pre-calculated size of 900 blocks - assertTrue(HSQLDBDatabaseArchiving.buildBlockArchive(repository, fileSizeTarget)); - - // Ensure the block archive height has increased - assertEquals(901, repository.getBlockArchiveRepository().getBlockArchiveHeight()); - - // Ensure the SQL repository contains blocks 2 and 900... - assertNotNull(repository.getBlockRepository().fromHeight(2)); - assertNotNull(repository.getBlockRepository().fromHeight(900)); - - // Check the current prune heights - assertEquals(0, repository.getBlockRepository().getBlockPruneHeight()); - assertEquals(0, repository.getATRepository().getAtPruneHeight()); - - // Prior to archiving or pruning, ensure blocks 2 to 1002 and their AT states are available in the db - for (int i=2; i<=1002; i++) { - assertNotNull(repository.getBlockRepository().fromHeight(i)); - List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); - assertNotNull(atStates); - assertEquals(1, atStates.size()); - } - - // Prune all the archived blocks and AT states (using bulk method) - assertTrue(HSQLDBDatabasePruning.pruneBlocks(hsqldb)); - assertTrue(HSQLDBDatabasePruning.pruneATStates(hsqldb)); - - // Ensure the current prune heights have increased - assertEquals(901, repository.getBlockRepository().getBlockPruneHeight()); - assertEquals(901, repository.getATRepository().getAtPruneHeight()); - - // Now ensure the SQL repository is missing blocks 2 and 900... - assertNull(repository.getBlockRepository().fromHeight(2)); - assertNull(repository.getBlockRepository().fromHeight(900)); - - // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) - assertNotNull(repository.getBlockRepository().fromHeight(1)); - assertNotNull(repository.getBlockRepository().fromHeight(901)); - - // Validate the latest block height in the repository - assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); - - // Ensure blocks 2-900 are all available in the archive - for (int i=2; i<=900; i++) { - assertNotNull(repository.getBlockArchiveRepository().fromHeight(i)); - } - - // Ensure blocks 2-900 are NOT available in the db - for (int i=2; i<=900; i++) { - assertNull(repository.getBlockRepository().fromHeight(i)); - } - - // Ensure blocks 901 to 1002 and their AT states are available in the db - for (int i=901; i<=1002; i++) { - assertNotNull(repository.getBlockRepository().fromHeight(i)); - List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); - assertNotNull(atStates); - assertEquals(1, atStates.size()); - } - - // Ensure blocks 901 to 1002 are not available in the archive - for (int i=901; i<=1002; i++) { - assertNull(repository.getBlockArchiveRepository().fromHeight(i)); - } - } - } - - @Test - public void testBulkArchiveAndPruneMultipleFiles() throws DataException, SQLException { - try (final Repository repository = RepositoryManager.getRepository()) { - HSQLDBRepository hsqldb = (HSQLDBRepository) repository; - - // Deploy an AT so that we have AT state data - PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); - byte[] creationBytes = AtUtils.buildSimpleAT(); - long fundingAmount = 1_00000000L; - AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); - - // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 1000; i++) { - BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); - } - - // Assume 900 blocks are trimmed (this specifies the first untrimmed height) - repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); - repository.getATRepository().setAtTrimHeight(901); - - // Check the max archive height - this should be one less than the first untrimmed height - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); - assertEquals(900, maximumArchiveHeight); - - // Check the current archive height - assertEquals(0, repository.getBlockArchiveRepository().getBlockArchiveHeight()); - - // Write blocks 2-900 to the archive (using bulk method) - int fileSizeTarget = 42360; // Pre-calculated size of approx 90 blocks - assertTrue(HSQLDBDatabaseArchiving.buildBlockArchive(repository, fileSizeTarget)); - - // Ensure 10 archive files have been created - Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive"); - assertEquals(10, new File(archivePath.toString()).list().length); - - // Check the files exist - assertTrue(Files.exists(Paths.get(archivePath.toString(), "2-90.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "91-179.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "180-268.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "269-357.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "358-446.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "447-535.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "536-624.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "625-713.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "714-802.dat"))); - assertTrue(Files.exists(Paths.get(archivePath.toString(), "803-891.dat"))); - - // Ensure the block archive height has increased - // It won't be as high as 901, because blocks 892-901 were too small to reach the file size - // target of the 11th file - assertEquals(892, repository.getBlockArchiveRepository().getBlockArchiveHeight()); - - // Ensure the SQL repository contains blocks 2 and 891... - assertNotNull(repository.getBlockRepository().fromHeight(2)); - assertNotNull(repository.getBlockRepository().fromHeight(891)); - - // Check the current prune heights - assertEquals(0, repository.getBlockRepository().getBlockPruneHeight()); - assertEquals(0, repository.getATRepository().getAtPruneHeight()); - - // Prior to archiving or pruning, ensure blocks 2 to 1002 and their AT states are available in the db - for (int i=2; i<=1002; i++) { - assertNotNull(repository.getBlockRepository().fromHeight(i)); - List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); - assertNotNull(atStates); - assertEquals(1, atStates.size()); - } - - // Prune all the archived blocks and AT states (using bulk method) - assertTrue(HSQLDBDatabasePruning.pruneBlocks(hsqldb)); - assertTrue(HSQLDBDatabasePruning.pruneATStates(hsqldb)); - - // Ensure the current prune heights have increased - assertEquals(892, repository.getBlockRepository().getBlockPruneHeight()); - assertEquals(892, repository.getATRepository().getAtPruneHeight()); - - // Now ensure the SQL repository is missing blocks 2 and 891... - assertNull(repository.getBlockRepository().fromHeight(2)); - assertNull(repository.getBlockRepository().fromHeight(891)); - - // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) - assertNotNull(repository.getBlockRepository().fromHeight(1)); - assertNotNull(repository.getBlockRepository().fromHeight(892)); - - // Validate the latest block height in the repository - assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); - - // Ensure blocks 2-891 are all available in the archive - for (int i=2; i<=891; i++) { - assertNotNull(repository.getBlockArchiveRepository().fromHeight(i)); - } - - // Ensure blocks 2-891 are NOT available in the db - for (int i=2; i<=891; i++) { - assertNull(repository.getBlockRepository().fromHeight(i)); - } - - // Ensure blocks 892 to 1002 and their AT states are available in the db - for (int i=892; i<=1002; i++) { - assertNotNull(repository.getBlockRepository().fromHeight(i)); - List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); - assertNotNull(atStates); - assertEquals(1, atStates.size()); - } - - // Ensure blocks 892 to 1002 are not available in the archive - for (int i=892; i<=1002; i++) { - assertNull(repository.getBlockArchiveRepository().fromHeight(i)); - } - } - } - @Test public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { diff --git a/src/test/java/org/qortal/test/BlockArchiveV2Tests.java b/src/test/java/org/qortal/test/BlockArchiveV2Tests.java new file mode 100644 index 00000000..3b1d12d3 --- /dev/null +++ b/src/test/java/org/qortal/test/BlockArchiveV2Tests.java @@ -0,0 +1,504 @@ +package org.qortal.test; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.controller.BlockMinter; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.*; +import org.qortal.repository.hsqldb.HSQLDBRepository; +import org.qortal.settings.Settings; +import org.qortal.test.common.AtUtils; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformation; +import org.qortal.utils.BlockArchiveUtils; +import org.qortal.utils.NTP; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.SQLException; +import java.util.List; + +import static org.junit.Assert.*; + +public class BlockArchiveV2Tests extends Common { + + @Before + public void beforeTest() throws DataException, IllegalAccessException { + Common.useSettings("test-settings-v2-block-archive.json"); + NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); + this.deleteArchiveDirectory(); + + // Set default archive version to 2, so that archive builds in these tests use V2 + FieldUtils.writeField(Settings.getInstance(), "defaultArchiveVersion", 2, true); + } + + @After + public void afterTest() throws DataException { + this.deleteArchiveDirectory(); + } + + + @Test + public void testWriter() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + } + } + + @Test + public void testWriterAndReader() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Read block 2 from the archive + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + BlockTransformation block2Info = reader.fetchBlockAtHeight(2); + BlockData block2ArchiveData = block2Info.getBlockData(); + + // Read block 2 from the repository + BlockData block2RepositoryData = repository.getBlockRepository().fromHeight(2); + + // Ensure the values match + assertEquals(block2ArchiveData.getHeight(), block2RepositoryData.getHeight()); + assertArrayEquals(block2ArchiveData.getSignature(), block2RepositoryData.getSignature()); + + // Test some values in the archive + assertEquals(1, block2ArchiveData.getOnlineAccountsCount()); + + // Read block 900 from the archive + BlockTransformation block900Info = reader.fetchBlockAtHeight(900); + BlockData block900ArchiveData = block900Info.getBlockData(); + + // Read block 900 from the repository + BlockData block900RepositoryData = repository.getBlockRepository().fromHeight(900); + + // Ensure the values match + assertEquals(block900ArchiveData.getHeight(), block900RepositoryData.getHeight()); + assertArrayEquals(block900ArchiveData.getSignature(), block900RepositoryData.getSignature()); + + // Test some values in the archive + assertEquals(1, block900ArchiveData.getOnlineAccountsCount()); + + } + } + + @Test + public void testArchivedAtStates() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // 9 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(10); + repository.getATRepository().setAtTrimHeight(10); + + // Check the max archive height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(9, maximumArchiveHeight); + + // Write blocks 2-9 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(9 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(9 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Check blocks 3-9 + for (Integer testHeight = 2; testHeight <= 9; testHeight++) { + + // Read a block from the archive + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + BlockTransformation blockInfo = reader.fetchBlockAtHeight(testHeight); + BlockData archivedBlockData = blockInfo.getBlockData(); + byte[] archivedAtStateHash = blockInfo.getAtStatesHash(); + List archivedTransactions = blockInfo.getTransactions(); + + // Read the same block from the repository + BlockData repositoryBlockData = repository.getBlockRepository().fromHeight(testHeight); + ATStateData repositoryAtStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + // Ensure the repository has full AT state data + assertNotNull(repositoryAtStateData.getStateHash()); + assertNotNull(repositoryAtStateData.getStateData()); + + // Check the archived AT state + if (testHeight == 2) { + assertEquals(1, archivedTransactions.size()); + assertEquals(Transaction.TransactionType.DEPLOY_AT, archivedTransactions.get(0).getType()); + } + else { + // Blocks 3+ shouldn't have any transactions + assertTrue(archivedTransactions.isEmpty()); + } + + // Ensure the archive has the AT states hash + assertNotNull(archivedAtStateHash); + + // Also check the online accounts count and height + assertEquals(1, archivedBlockData.getOnlineAccountsCount()); + assertEquals(testHeight, archivedBlockData.getHeight()); + + // Ensure the values match + assertEquals(archivedBlockData.getHeight(), repositoryBlockData.getHeight()); + assertArrayEquals(archivedBlockData.getSignature(), repositoryBlockData.getSignature()); + assertEquals(archivedBlockData.getOnlineAccountsCount(), repositoryBlockData.getOnlineAccountsCount()); + assertArrayEquals(archivedBlockData.getMinterSignature(), repositoryBlockData.getMinterSignature()); + assertEquals(archivedBlockData.getATCount(), repositoryBlockData.getATCount()); + assertEquals(archivedBlockData.getOnlineAccountsCount(), repositoryBlockData.getOnlineAccountsCount()); + assertArrayEquals(archivedBlockData.getReference(), repositoryBlockData.getReference()); + assertEquals(archivedBlockData.getTimestamp(), repositoryBlockData.getTimestamp()); + assertEquals(archivedBlockData.getATFees(), repositoryBlockData.getATFees()); + assertEquals(archivedBlockData.getTotalFees(), repositoryBlockData.getTotalFees()); + assertEquals(archivedBlockData.getTransactionCount(), repositoryBlockData.getTransactionCount()); + assertArrayEquals(archivedBlockData.getTransactionsSignature(), repositoryBlockData.getTransactionsSignature()); + + // TODO: build atStatesHash and compare against value in archive + } + + // Check block 10 (unarchived) + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + BlockTransformation blockInfo = reader.fetchBlockAtHeight(10); + assertNull(blockInfo); + + } + + } + + @Test + public void testArchiveAndPrune() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // Assume 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(901); + repository.saveChanges(); + assertEquals(901, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Ensure the SQL repository contains blocks 2 and 900... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(900)); + + // Prune all the archived blocks + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 900); + assertEquals(900-1, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(901); + + // Prune the AT states for the archived blocks + repository.getATRepository().rebuildLatestAtStates(900); + repository.saveChanges(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 900); + assertEquals(900-2, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state + repository.getATRepository().setAtPruneHeight(901); + + // Now ensure the SQL repository is missing blocks 2 and 900... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(900)); + + // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(901)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + } + } + + @Test + public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // Make sure that block 500 has full AT state data and data hash + List block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // Trim the first 500 blocks + repository.getBlockRepository().trimOldOnlineAccountsSignatures(0, 500); + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(501); + repository.getATRepository().rebuildLatestAtStates(500); + repository.getATRepository().trimAtStates(0, 500, 1000); + repository.getATRepository().setAtTrimHeight(501); + + // Now block 499 should only have the AT state data hash + List block499AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(499); + atStatesData = repository.getATRepository().getATStateAtHeight(block499AtStatesData.get(0).getATAddress(), 499); + assertNotNull(atStatesData.getStateHash()); + assertNull(atStatesData.getStateData()); + + // ... but block 500 should have the full data (due to being retained as the "latest" AT state in the trimmed range + block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // ... and block 501 should also have the full data + List block501AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(501); + atStatesData = repository.getATRepository().getATStateAtHeight(block501AtStatesData.get(0).getATAddress(), 501); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(500, maximumArchiveHeight); + + BlockData block3DataPreArchive = repository.getBlockRepository().fromHeight(3); + + // Write blocks 2-500 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(500 - 1, writer.getWrittenCount()); // -1 for the genesis block + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(500 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Ensure the SQL repository contains blocks 2 and 500... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(500)); + + // Prune all the archived blocks + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 500); + assertEquals(500-1, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(501); + + // Prune the AT states for the archived blocks + repository.getATRepository().rebuildLatestAtStates(500); + repository.saveChanges(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(2, 500); + assertEquals(498, numATStatesPruned); // Minus 1 for genesis block, and another for the latest AT state + repository.getATRepository().setAtPruneHeight(501); + + // Now ensure the SQL repository is missing blocks 2 and 500... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(500)); + + // ... but it's not missing blocks 1 and 501 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(501)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + // Now orphan some unarchived blocks. + BlockUtils.orphanBlocks(repository, 500); + assertEquals(502, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + // We're close to the lower limit of the SQL database now, so + // we need to import some blocks from the archive + BlockArchiveUtils.importFromArchive(401, 500, repository); + + // Ensure the SQL repository now contains block 401 but not 400... + assertNotNull(repository.getBlockRepository().fromHeight(401)); + assertNull(repository.getBlockRepository().fromHeight(400)); + + // Import the remaining 399 blocks + BlockArchiveUtils.importFromArchive(2, 400, repository); + + // Verify that block 3 matches the original + BlockData block3DataPostArchive = repository.getBlockRepository().fromHeight(3); + assertArrayEquals(block3DataPreArchive.getSignature(), block3DataPostArchive.getSignature()); + assertEquals(block3DataPreArchive.getHeight(), block3DataPostArchive.getHeight()); + + // Orphan 2 more block, which should be the last one that is possible to be orphaned + // TODO: figure out why this is 1 block more than in the equivalent block archive V1 test + BlockUtils.orphanBlocks(repository, 2); + + // Orphan another block, which should fail + Exception exception = null; + try { + BlockUtils.orphanBlocks(repository, 1); + } catch (DataException e) { + exception = e; + } + + // Ensure that a DataException is thrown because there is no more AT states data available + assertNotNull(exception); + assertEquals(DataException.class, exception.getClass()); + + // FUTURE: we may be able to retain unique AT states when trimming, to avoid this exception + // and allow orphaning back through blocks with trimmed AT states. + + } + } + + + /** + * Many nodes are missing an ATStatesHeightIndex due to an earlier bug + * In these cases we disable archiving and pruning as this index is a + * very essential component in these processes. + */ + @Test + public void testMissingAtStatesHeightIndex() throws DataException, SQLException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + + // Firstly check that we're able to prune or archive when the index exists + assertTrue(repository.getATRepository().hasAtStatesHeightIndex()); + assertTrue(RepositoryManager.canArchiveOrPrune()); + + // Delete the index + repository.prepareStatement("DROP INDEX ATSTATESHEIGHTINDEX").execute(); + + // Ensure check that we're unable to prune or archive when the index doesn't exist + assertFalse(repository.getATRepository().hasAtStatesHeightIndex()); + assertFalse(RepositoryManager.canArchiveOrPrune()); + } + } + + + private void deleteArchiveDirectory() { + // Delete archive directory if exists + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + try { + FileUtils.deleteDirectory(archivePath.toFile()); + } catch (IOException e) { + + } + } + +} diff --git a/src/test/resources/test-settings-v2-block-archive.json b/src/test/resources/test-settings-v2-block-archive.json index c5ed1aa8..209ce92d 100644 --- a/src/test/resources/test-settings-v2-block-archive.json +++ b/src/test/resources/test-settings-v2-block-archive.json @@ -9,5 +9,6 @@ "testNtpOffset": 0, "minPeers": 0, "pruneBlockLimit": 100, - "repositoryPath": "dbtest" + "repositoryPath": "dbtest", + "defaultArchiveVersion": 1 } From 44aa0a6026d5ca3988dd5a435dabfa2c44512b1d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 10:00:30 +0000 Subject: [PATCH 259/496] 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 b4a736c5d2a8cdb566ce7b7f55bdafd43e71fda0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 13:53:46 +0000 Subject: [PATCH 260/496] Added optional "sender" filter to GET /chat/messages --- src/main/java/org/qortal/api/resource/ChatResource.java | 2 ++ .../org/qortal/api/websocket/ChatMessagesWebSocket.java | 2 ++ src/main/java/org/qortal/repository/ChatRepository.java | 2 +- .../org/qortal/repository/hsqldb/HSQLDBChatRepository.java | 7 ++++++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index 2601e938..150b6f63 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -72,6 +72,7 @@ public class ChatResource { @QueryParam("reference") String reference, @QueryParam("chatreference") String chatReference, @QueryParam("haschatreference") Boolean hasChatReference, + @QueryParam("sender") String sender, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { @@ -107,6 +108,7 @@ public class ChatResource { chatReferenceBytes, hasChatReference, involvingAddresses, + sender, limit, offset, reverse); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index 76ed936c..c6d7aaed 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -49,6 +49,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + null, null, null, null); sendMessages(session, chatMessages); @@ -79,6 +80,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, involvingAddresses, + null, null, null, null); sendMessages(session, chatMessages); diff --git a/src/main/java/org/qortal/repository/ChatRepository.java b/src/main/java/org/qortal/repository/ChatRepository.java index c4541907..34ad77dd 100644 --- a/src/main/java/org/qortal/repository/ChatRepository.java +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -15,7 +15,7 @@ public interface ChatRepository { */ public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] reference, byte[] chatReferenceBytes, Boolean hasChatReference, - List involving, Integer limit, Integer offset, Boolean reverse) throws DataException; + List involving, String senderAddress, Integer limit, Integer offset, Boolean reverse) throws DataException; public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) 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 a995a0b3..55467d87 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -24,7 +24,7 @@ public class HSQLDBChatRepository implements ChatRepository { @Override public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] referenceBytes, - byte[] chatReferenceBytes, Boolean hasChatReference, List involving, + byte[] chatReferenceBytes, Boolean hasChatReference, List involving, String senderAddress, Integer limit, Integer offset, Boolean reverse) throws DataException { // Check args meet expectations if ((txGroupId != null && involving != null && !involving.isEmpty()) @@ -74,6 +74,11 @@ public class HSQLDBChatRepository implements ChatRepository { whereClauses.add("chat_reference IS NULL"); } + if (senderAddress != null) { + whereClauses.add("sender = ?"); + bindParams.add(senderAddress); + } + if (txGroupId != null) { whereClauses.add("tx_group_id = " + txGroupId); // int safe to use literally whereClauses.add("recipient IS NULL"); From 82c66c0555479df84eabcf12c8f7c9f5da95c5a8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 14:28:13 +0000 Subject: [PATCH 261/496] 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 262/496] 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 384f592f5951cec05924079d0b0980794eeb534c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 14:28:13 +0000 Subject: [PATCH 263/496] 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 b4b9feed..2973f2e2 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, @@ -112,7 +117,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 c5c0dcf0f22ffb1314fa1eea49d37167d35a0c9f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 14:29:52 +0000 Subject: [PATCH 264/496] 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 0388626e429051d69bebcd0245032ee2feb12053 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 15:41:07 +0000 Subject: [PATCH 265/496] Use a lower file size target (10MB instead of 100MB) when using archive V2, as the average block size is over 90% smaller. --- .../org/qortal/repository/BlockArchiveWriter.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 8f4d4498..e47aabbd 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -39,7 +39,8 @@ public class BlockArchiveWriter { private static final Logger LOGGER = LogManager.getLogger(BlockArchiveWriter.class); - public static final long DEFAULT_FILE_SIZE_TARGET = 100 * 1024 * 1024; // 100MiB + public static final long DEFAULT_FILE_SIZE_TARGET_V1 = 100 * 1024 * 1024; // 100MiB + public static final long DEFAULT_FILE_SIZE_TARGET_V2 = 10 * 1024 * 1024; // 10MiB private int startHeight; private final int endHeight; @@ -47,7 +48,7 @@ public class BlockArchiveWriter { private final Path archivePath; private final Repository repository; - private long fileSizeTarget = DEFAULT_FILE_SIZE_TARGET; + private long fileSizeTarget = DEFAULT_FILE_SIZE_TARGET_V1; private boolean shouldEnforceFileSizeTarget = true; // Default data source to BLOCK_REPOSITORY; can optionally be overridden @@ -75,6 +76,12 @@ public class BlockArchiveWriter { // When serialization version isn't specified, fetch it from the existing archive serializationVersion = this.findSerializationVersion(); } + + // Reduce default file size target if we're using V2, as the average block size is over 90% smaller + if (serializationVersion == 2) { + this.setFileSizeTarget(DEFAULT_FILE_SIZE_TARGET_V2); + } + this.serializationVersion = serializationVersion; } From 101023ba1d209a1debee117015193cb6d44830fa Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 16:39:14 +0000 Subject: [PATCH 266/496] 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 267/496] 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 bc44b998dc95a6361284c83a3d363bb0cf73b1f5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 10 Mar 2023 21:29:35 +0000 Subject: [PATCH 268/496] The transaction sequences reshape now fetches transactions from the archive. This is required as it's the only place that holds the original order of each block's transactions. We cannot sort them, because the comparator function for transactions has some dependencies on the existing order for AT transactions. As a result, topOnly nodes cannot perform this reshape, and will be unable to run this version. --- .../qortal/repository/RepositoryManager.java | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 6d1f361e..9008f98e 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -2,9 +2,12 @@ package org.qortal.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.block.Block; +import org.qortal.data.block.BlockData; import org.qortal.data.transaction.TransactionData; import org.qortal.settings.Settings; import org.qortal.transaction.Transaction; +import org.qortal.transform.block.BlockTransformation; import java.sql.SQLException; import java.util.ArrayList; @@ -66,6 +69,10 @@ public abstract class RepositoryManager { // Lite nodes have no blockchain return false; } + if (Settings.getInstance().isTopOnly()) { + // topOnly nodes are unable to perform this reindex, and so are temporarily unsupported + throw new DataException("topOnly nodes are now unsupported, as they are missing data required for a db reshape"); + } try { // Check if we have any unpopulated block_sequence values for the first 1000 blocks @@ -78,24 +85,30 @@ public abstract class RepositoryManager { return false; } + LOGGER.info("Rebuilding transaction sequences - this will take a while..."); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); int totalTransactionCount = 0; for (int height = 1; height < blockchainHeight; height++) { List transactions = new ArrayList<>(); - // Fetch transactions for height - List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height); - for (byte[] signature : signatures) { - TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); - if (transactionData != null) { - transactions.add(transactionData); + // Fetch block and transactions + BlockData blockData = repository.getBlockRepository().fromHeight(height); + if (blockData == null) { + // Try the archive + BlockTransformation blockTransformation = BlockArchiveReader.getInstance().fetchBlockAtHeight(height); + transactions = blockTransformation.getTransactions(); + } + else { + // Get transactions from db + Block block = new Block(repository, blockData); + for (Transaction transaction : block.getTransactions()) { + transactions.add(transaction.getTransactionData()); } } - totalTransactionCount += transactions.size(); - // Sort the transactions for this height - transactions.sort(Transaction.getDataComparator()); + totalTransactionCount += transactions.size(); // Loop through and update sequences for (int sequence = 0; sequence < transactions.size(); ++sequence) { From e4f45c1a7027842c8c1470c0bab29ec8c1438177 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Mar 2023 19:08:07 +0000 Subject: [PATCH 269/496] Break out of orphan loop when stopping. --- src/main/java/org/qortal/block/BlockChain.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 88880887..218fb14d 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -871,6 +871,9 @@ public class BlockChain { BlockData orphanBlockData = repository.getBlockRepository().fromHeight(height); while (height > targetHeight) { + if (Controller.isStopping()) { + return false; + } LOGGER.info(String.format("Forcably orphaning block %d", height)); Block block = new Block(repository, orphanBlockData); From a4551245cbd04b1c07a5c2ee6264d4916866e802 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Mar 2023 19:08:57 +0000 Subject: [PATCH 270/496] Improved error logging in BlockArchiveUtils.importFromArchive() --- src/main/java/org/qortal/utils/BlockArchiveUtils.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/utils/BlockArchiveUtils.java b/src/main/java/org/qortal/utils/BlockArchiveUtils.java index 807faef9..f9ca0d0d 100644 --- a/src/main/java/org/qortal/utils/BlockArchiveUtils.java +++ b/src/main/java/org/qortal/utils/BlockArchiveUtils.java @@ -1,5 +1,7 @@ package org.qortal.utils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; import org.qortal.data.transaction.TransactionData; @@ -12,6 +14,8 @@ import java.util.List; public class BlockArchiveUtils { + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveUtils.class); + /** * importFromArchive *

@@ -87,7 +91,8 @@ public class BlockArchiveUtils { } catch (DataException e) { repository.discardChanges(); - throw new IllegalStateException("Unable to import blocks from archive"); + LOGGER.info("Unable to import blocks from archive", e); + throw(e); } } repository.saveChanges(); From 4840804d328d842127c9d6a3a6c978e67990ed67 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Mar 2023 10:22:26 +0000 Subject: [PATCH 271/496] 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 272/496] 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 273/496] 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 274/496] 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 275/496] 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 276/496] 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 277/496] 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 278/496] 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 279/496] 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 280/496] 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 281/496] 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 282/496] 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 283/496] 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 284/496] 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 285/496] 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 286/496] 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 287/496] 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 288/496] `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 289/496] 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 290/496] 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 291/496] 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 292/496] 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 293/496] 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 294/496] 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 322/496] 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 323/496] 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 324/496] 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 325/496] 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 326/496] 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 327/496] 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 328/496] 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 329/496] 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 330/496] 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 331/496] 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 332/496] 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 333/496] 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 334/496] 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 335/496] 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 336/496] 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 337/496] 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 338/496] "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 339/496] "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 340/496] 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 341/496] 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 342/496] 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 343/496] 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 344/496] 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 345/496] 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 346/496] 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 347/496] 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 348/496] 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 349/496] 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 350/496] 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 351/496] 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 352/496] 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 353/496] 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 354/496] 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 355/496] 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 356/496] 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 357/496] 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 358/496] 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 359/496] 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 8f847d368962c2bccb32aae8c4d52c7f64ef2cc6 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Fri, 21 Apr 2023 19:30:29 -0400 Subject: [PATCH 360/496] Upgraded to TLSv1.3 --- src/main/java/org/qortal/api/ApiService.java | 2 +- src/main/java/org/qortal/api/DomainMapService.java | 2 +- src/main/java/org/qortal/api/GatewayService.java | 2 +- src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 059b8971..1ee733c6 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -96,7 +96,7 @@ public class ApiService { throw new RuntimeException("Failed to start SSL API due to broken keystore"); // BouncyCastle-specific SSLContext build - SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE"); + SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE"); KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE"); KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC"); diff --git a/src/main/java/org/qortal/api/DomainMapService.java b/src/main/java/org/qortal/api/DomainMapService.java index 3b81d94c..8b791121 100644 --- a/src/main/java/org/qortal/api/DomainMapService.java +++ b/src/main/java/org/qortal/api/DomainMapService.java @@ -69,7 +69,7 @@ public class DomainMapService { throw new RuntimeException("Failed to start SSL API due to broken keystore"); // BouncyCastle-specific SSLContext build - SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE"); + SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE"); KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE"); KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC"); diff --git a/src/main/java/org/qortal/api/GatewayService.java b/src/main/java/org/qortal/api/GatewayService.java index 51191af3..24a7b7c9 100644 --- a/src/main/java/org/qortal/api/GatewayService.java +++ b/src/main/java/org/qortal/api/GatewayService.java @@ -69,7 +69,7 @@ public class GatewayService { throw new RuntimeException("Failed to start SSL API due to broken keystore"); // BouncyCastle-specific SSLContext build - SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE"); + SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE"); KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE"); KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC"); diff --git a/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java b/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java index aba1955e..f723e651 100644 --- a/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java +++ b/src/main/java/org/qortal/crypto/TrustlessSSLSocketFactory.java @@ -28,7 +28,7 @@ public abstract class TrustlessSSLSocketFactory { private static final SSLContext sc; static { try { - sc = SSLContext.getInstance("SSL"); + sc = SSLContext.getInstance("TLSv1.3"); sc.init(null, TRUSTLESS_MANAGER, new java.security.SecureRandom()); } catch (Exception e) { throw new RuntimeException(e); From 33aeec7e87557c310eda4def6056d890b1124e55 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Apr 2023 11:00:21 +0100 Subject: [PATCH 361/496] 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 362/496] 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 363/496] 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 364/496] 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 365/496] 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 366/496] 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 367/496] 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 368/496] 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 369/496] 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 370/496] 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 371/496] 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 372/496] 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 373/496] 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 374/496] 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 375/496] 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 376/496] 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 377/496] 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 378/496] 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 379/496] 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 380/496] 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 381/496] 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 382/496] 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 383/496] 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 384/496] 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 385/496] 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 386/496] 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 387/496] 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 388/496] 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 389/496] 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 390/496] 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 391/496] 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 392/496] 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 393/496] 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 394/496] 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 395/496] 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 396/496] 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 397/496] 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 398/496] 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 399/496] 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 400/496] 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 401/496] 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 402/496] 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 403/496] 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 404/496] 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 405/496] 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 406/496] 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 407/496] 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 408/496] 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 409/496] 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 410/496] 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 411/496] 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 412/496] 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 413/496] 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 414/496] 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 415/496] 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 416/496] 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 417/496] 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); + } + } + +} From a8d92805f9170364a3dfcbf8bef6b9c2db982de6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 20 May 2023 11:33:43 +0100 Subject: [PATCH 418/496] Added extra check for topOnly mode. --- src/main/java/org/qortal/settings/Settings.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index a87a72f4..901e1956 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -508,6 +508,9 @@ public class Settings { if (this.minBlockchainPeers < 1 && !singleNodeTestnet) throwValidationError("minBlockchainPeers must be at least 1"); + if (this.topOnly) + throwValidationError("topOnly mode is no longer supported"); + if (this.apiKey != null && this.apiKey.trim().length() < 8) throwValidationError("apiKey must be at least 8 characters"); From 8b51590844fa8b12163b66293babd5838092d2f4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 20 May 2023 20:54:22 +0100 Subject: [PATCH 419/496] Include AT transactions when rebuilding transaction sequences, as these aren't directly included in the block archive. --- .../qortal/repository/RepositoryManager.java | 90 +++++++++++++++++-- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 9008f98e..1562b38c 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -3,17 +3,21 @@ package org.qortal.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.block.Block; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.ATTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.settings.Settings; import org.qortal.transaction.Transaction; import org.qortal.transform.block.BlockTransformation; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; +import java.util.*; import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +import static org.qortal.transaction.Transaction.TransactionType.AT; public abstract class RepositoryManager { private static final Logger LOGGER = LogManager.getLogger(RepositoryManager.class); @@ -91,23 +95,95 @@ public abstract class RepositoryManager { int totalTransactionCount = 0; for (int height = 1; height < blockchainHeight; height++) { - List transactions = new ArrayList<>(); + List inputTransactions = new ArrayList<>(); // Fetch block and transactions BlockData blockData = repository.getBlockRepository().fromHeight(height); + boolean loadedFromArchive = false; if (blockData == null) { - // Try the archive + // Get (non-AT) transactions from the archive BlockTransformation blockTransformation = BlockArchiveReader.getInstance().fetchBlockAtHeight(height); - transactions = blockTransformation.getTransactions(); + blockData = blockTransformation.getBlockData(); + inputTransactions = blockTransformation.getTransactions(); // This doesn't include AT transactions + loadedFromArchive = true; } else { // Get transactions from db Block block = new Block(repository, blockData); for (Transaction transaction : block.getTransactions()) { - transactions.add(transaction.getTransactionData()); + inputTransactions.add(transaction.getTransactionData()); } } + if (blockData == null) { + throw new DataException("Missing block data"); + } + + List transactions = new ArrayList<>(); + + if (loadedFromArchive) { + List transactionDataList = new ArrayList<>(blockData.getTransactionCount()); + // Fetch any AT transactions in this block + List atSignatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height); + for (byte[] s : atSignatures) { + TransactionData transactionData = repository.getTransactionRepository().fromSignature(s); + if (transactionData.getType() == AT) { + transactionDataList.add(transactionData); + } + } + + List atTransactions = new ArrayList<>(); + for (TransactionData transactionData : transactionDataList) { + ATTransactionData atTransactionData = (ATTransactionData) transactionData; + atTransactions.add(atTransactionData); + } + + // Create sorted list of ATs by creation time + List ats = new ArrayList<>(); + + for (ATTransactionData atTransactionData : atTransactions) { + ATData atData = repository.getATRepository().fromATAddress(atTransactionData.getATAddress()); + if (!ats.contains(atData)) { + ats.add(atData); + } + } + + // Sort list of ATs by creation date + ats.sort(Comparator.comparingLong(ATData::getCreation)); + + // Loop through unique ATs + for (ATData atData : ats) { + List thisAtTransactions = atTransactions.stream() + .filter(t -> Objects.equals(t.getATAddress(), atData.getATAddress())) + .collect(Collectors.toList()); + + int count = thisAtTransactions.size(); + + if (count == 1) { + ATTransactionData atTransactionData = thisAtTransactions.get(0); + transactions.add(atTransactionData); + } + else if (count == 2) { + String atCreatorAddress = Crypto.toAddress(atData.getCreatorPublicKey()); + + ATTransactionData atTransactionData1 = thisAtTransactions.stream() + .filter(t -> !Objects.equals(t.getRecipient(), atCreatorAddress)) + .findFirst().orElse(null); + transactions.add(atTransactionData1); + + ATTransactionData atTransactionData2 = thisAtTransactions.stream() + .filter(t -> Objects.equals(t.getRecipient(), atCreatorAddress)) + .findFirst().orElse(null); + transactions.add(atTransactionData2); + } + else if (count > 2) { + LOGGER.info("Error: AT has more than 2 output transactions"); + } + } + } + + // Add all the regular transactions now that AT transactions have been handled + transactions.addAll(inputTransactions); totalTransactionCount += transactions.size(); // Loop through and update sequences From e1043ceacb490e1f0b34ef6032bd457a2aa15c00 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 08:41:56 +0100 Subject: [PATCH 420/496] Fixed bug causing duplicate AT entries in local array. --- src/main/java/org/qortal/repository/RepositoryManager.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 1562b38c..fefaeea9 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -143,7 +143,8 @@ public abstract class RepositoryManager { for (ATTransactionData atTransactionData : atTransactions) { ATData atData = repository.getATRepository().fromATAddress(atTransactionData.getATAddress()); - if (!ats.contains(atData)) { + boolean hasExistingEntry = ats.stream().anyMatch(a -> Objects.equals(a.getATAddress(), atTransactionData.getATAddress())); + if (!hasExistingEntry) { ats.add(atData); } } From b9015217de26fcd4dc8224d739c130f34ff1ce09 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 11:05:34 +0100 Subject: [PATCH 421/496] Fixed bug causing final block to be missed in the reshape. --- src/main/java/org/qortal/repository/RepositoryManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index fefaeea9..390c72c2 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -94,7 +94,7 @@ public abstract class RepositoryManager { int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); int totalTransactionCount = 0; - for (int height = 1; height < blockchainHeight; height++) { + for (int height = 1; height < blockchainHeight; ++height) { List inputTransactions = new ArrayList<>(); // Fetch block and transactions From 68b99c8643d264b5315fa0d9bf28bffe6fc0bf66 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 12:28:51 +0100 Subject: [PATCH 422/496] Update status when rebuilding transaction sequences. --- src/main/java/org/qortal/repository/RepositoryManager.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 390c72c2..d874effd 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -91,6 +91,8 @@ public abstract class RepositoryManager { LOGGER.info("Rebuilding transaction sequences - this will take a while..."); + SplashFrame.getInstance().updateStatus("Rebuilding transactions - please wait..."); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); int totalTransactionCount = 0; From a74fa15d60b0a8a2e122d23d5aa5c7fb67318c2c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 12:31:49 +0100 Subject: [PATCH 423/496] Missing import --- src/main/java/org/qortal/repository/RepositoryManager.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index d874effd..92936278 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -8,6 +8,7 @@ import org.qortal.data.at.ATData; import org.qortal.data.block.BlockData; import org.qortal.data.transaction.ATTransactionData; import org.qortal.data.transaction.TransactionData; +import org.qortal.gui.SplashFrame; import org.qortal.settings.Settings; import org.qortal.transaction.Transaction; import org.qortal.transform.block.BlockTransformation; From c6456669e2a0daf65542295362e418e469c0eb49 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 12:33:37 +0100 Subject: [PATCH 424/496] Don't allow core to start if transaction sequences haven't been rebuilt yet. --- .../java/org/qortal/controller/Controller.java | 12 ++++++++++++ .../qortal/repository/RepositoryManager.java | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 7e8fea5e..94ad885f 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -440,6 +440,18 @@ public class Controller extends Thread { } } + try (Repository repository = RepositoryManager.getRepository()) { + if (RepositoryManager.needsTransactionSequenceRebuild(repository)) { + // Don't allow the node to start if transaction sequences haven't been built yet + // This is needed to handle a case when bootstrapping + Gui.getInstance().fatalError("Database upgrade needed", "Please start the core again to complete the process."); + return; + } + } catch (DataException e) { + LOGGER.error("Error checking transaction sequences in repository", e); + return; + } + // Import current trade bot states and minting accounts if they exist Controller.importRepositoryData(); diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 92936278..03147662 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -69,6 +69,19 @@ public abstract class RepositoryManager { // Backup is best-effort so don't complain } } + + public static boolean needsTransactionSequenceRebuild(Repository repository) throws DataException { + // Check if we have any unpopulated block_sequence values for the first 1000 blocks + List testSignatures = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria( + null, Arrays.asList("block_height < 1000 AND block_sequence IS NULL"), new ArrayList<>()); + if (testSignatures.isEmpty()) { + // block_sequence already populated for the first 1000 blocks, so assume complete. + return false; + } + + return true; + } + public static boolean rebuildTransactionSequences(Repository repository) throws DataException { if (Settings.getInstance().isLite()) { // Lite nodes have no blockchain @@ -81,9 +94,7 @@ public abstract class RepositoryManager { try { // Check if we have any unpopulated block_sequence values for the first 1000 blocks - List testSignatures = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria( - null, Arrays.asList("block_height < 1000 AND block_sequence IS NULL"), new ArrayList<>()); - if (testSignatures.isEmpty()) { + if (!needsTransactionSequenceRebuild(repository)) { // block_sequence already populated for the first 1000 blocks, so assume complete. // We avoid checkpointing and prevent the node from starting up in the case of a rebuild failure, so // we shouldn't ever be left in a partially rebuilt state. From 2b2d6f4e521330630048a3a9838a49742db57da6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 14:02:45 +0100 Subject: [PATCH 425/496] Updated message. --- src/main/java/org/qortal/controller/Controller.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 94ad885f..72ee9b99 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -444,7 +444,7 @@ public class Controller extends Thread { if (RepositoryManager.needsTransactionSequenceRebuild(repository)) { // Don't allow the node to start if transaction sequences haven't been built yet // This is needed to handle a case when bootstrapping - Gui.getInstance().fatalError("Database upgrade needed", "Please start the core again to complete the process."); + Gui.getInstance().fatalError("Database upgrade needed", "Please restart the core to complete the upgrade process."); return; } } catch (DataException e) { From 072aa469e3cf02d7984cfa918117f37ffb1e333c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 15:21:04 +0100 Subject: [PATCH 426/496] Reduce default minBlockchainPeers to 3, ahead of the upcoming reshape. --- 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 a87a72f4..564dfaf9 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -201,7 +201,7 @@ public class Settings { /** Whether to attempt to open the listen port via UPnP */ private boolean uPnPEnabled = true; /** Minimum number of peers to allow block minting / synchronization. */ - private int minBlockchainPeers = 5; + private int minBlockchainPeers = 3; /** Target number of outbound connections to peers we should make. */ private int minOutboundPeers = 16; /** Maximum number of peer connections we allow. */ From 648fa66f6a4ce0b8cc11ea22d95d11e76d966b3a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 15:22:00 +0100 Subject: [PATCH 427/496] Increased default maxPeers to 40. --- 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 564dfaf9..eec2aa80 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -205,7 +205,7 @@ public class Settings { /** Target number of outbound connections to peers we should make. */ private int minOutboundPeers = 16; /** Maximum number of peer connections we allow. */ - private int maxPeers = 36; + private int maxPeers = 40; /** Number of slots to reserve for short-lived QDN data transfers */ private int maxDataPeers = 4; /** Maximum number of threads for network engine. */ From 3c4c5a1457bd5e2a65d0c2554299869d12590e85 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 15:22:24 +0100 Subject: [PATCH 428/496] Default minPeerVersion set to 4.0.0 --- 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 eec2aa80..018c1e29 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -219,7 +219,7 @@ public class Settings { public long recoveryModeTimeout = 10 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.8.7"; + private String minPeerVersion = "4.0.0"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From b1a904a3c7e359bdd64b30bd282002c7866f65d2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 15:26:49 +0100 Subject: [PATCH 429/496] MIN_PEER_VERSION set to 4.0.0 --- src/main/java/org/qortal/network/Handshake.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/Handshake.java b/src/main/java/org/qortal/network/Handshake.java index 47752767..4500cd59 100644 --- a/src/main/java/org/qortal/network/Handshake.java +++ b/src/main/java/org/qortal/network/Handshake.java @@ -265,7 +265,7 @@ public enum Handshake { private static final long PEER_VERSION_131 = 0x0100030001L; /** Minimum peer version that we are allowed to communicate with */ - private static final String MIN_PEER_VERSION = "3.8.2"; + private static final String MIN_PEER_VERSION = "4.0.0"; private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes private static final int POW_DIFFICULTY_PRE_131 = 8; // leading zero bits From 3763035d4a0864b8b82c8cedb68bdb0ceaf178f9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 15:34:27 +0100 Subject: [PATCH 430/496] Default recoveryModeTimeout increased to 24 hours for now. It doesn't quite work as intended, so it's best that it doesn't interfere right away. 24 hours should be long enough for any issues to be manually resolved. --- 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 018c1e29..2449e34a 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -216,7 +216,7 @@ public class Settings { private int maxRetries = 2; /** The number of seconds of no activity before recovery mode begins */ - public long recoveryModeTimeout = 10 * 60 * 1000L; + public long recoveryModeTimeout = 24 * 60 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ private String minPeerVersion = "4.0.0"; From 7a6b83aa221c27260863c605eaf98c476f483746 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 16:49:59 +0100 Subject: [PATCH 431/496] Bump version to 4.1.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 0dfa0cf4..d2232d31 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.0.3 + 4.1.0 jar true From c763445e6e70db881c72ec9a4f731ed1c80db12d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 19:51:22 +0100 Subject: [PATCH 432/496] Log to console if an extra core restart is needed to complete the update process (this needed ins some cases after bootstrapping). --- src/main/java/org/qortal/controller/Controller.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 72ee9b99..e1e90486 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -444,6 +444,7 @@ public class Controller extends Thread { if (RepositoryManager.needsTransactionSequenceRebuild(repository)) { // Don't allow the node to start if transaction sequences haven't been built yet // This is needed to handle a case when bootstrapping + LOGGER.error("Database upgrade needed. Please restart the core to complete the upgrade process."); Gui.getInstance().fatalError("Database upgrade needed", "Please restart the core to complete the upgrade process."); return; } From aea1cc62c812f2bc1998906a963d60191dbc14d0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 20:02:58 +0100 Subject: [PATCH 433/496] Fixed off-by-one bug (correctly this time) --- src/main/java/org/qortal/repository/RepositoryManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 03147662..e0447ab0 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -108,7 +108,7 @@ public abstract class RepositoryManager { int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); int totalTransactionCount = 0; - for (int height = 1; height < blockchainHeight; ++height) { + for (int height = 1; height <= blockchainHeight; ++height) { List inputTransactions = new ArrayList<>(); // Fetch block and transactions From 95d72866e959eae45436f0ac67f5580dee052740 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 20:06:09 +0100 Subject: [PATCH 434/496] Use a better method to detect if a transactions table in need of a rebuild. Should handle cases where a previous rebuild didn't fully complete, or missed a block. --- src/main/java/org/qortal/repository/RepositoryManager.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index e0447ab0..66156620 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -71,11 +71,11 @@ public abstract class RepositoryManager { } public static boolean needsTransactionSequenceRebuild(Repository repository) throws DataException { - // Check if we have any unpopulated block_sequence values for the first 1000 blocks + // Check if we have any transactions without a block_sequence List testSignatures = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria( - null, Arrays.asList("block_height < 1000 AND block_sequence IS NULL"), new ArrayList<>()); + null, Arrays.asList("block_height IS NOT NULL AND block_sequence IS NULL"), new ArrayList<>()); if (testSignatures.isEmpty()) { - // block_sequence already populated for the first 1000 blocks, so assume complete. + // block_sequence intact, so assume complete return false; } From 947b523e6185eca19abae47bb3754fbbc0f11907 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 20:33:33 +0100 Subject: [PATCH 435/496] Limit query to 100 so that it doesn't return endless amounts of transaction signatures. Using a separate database method for now to reduce risk of interfering with other parts of the code which use it. It can be combined later when there is more testing time. --- .../qortal/repository/RepositoryManager.java | 2 +- .../repository/TransactionRepository.java | 17 +++++++ .../HSQLDBTransactionRepository.java | 47 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 66156620..66b1d23e 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -73,7 +73,7 @@ public abstract class RepositoryManager { public static boolean needsTransactionSequenceRebuild(Repository repository) throws DataException { // Check if we have any transactions without a block_sequence List testSignatures = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria( - null, Arrays.asList("block_height IS NOT NULL AND block_sequence IS NULL"), new ArrayList<>()); + null, Arrays.asList("block_height IS NOT NULL AND block_sequence IS NULL"), new ArrayList<>(), 100); if (testSignatures.isEmpty()) { // block_sequence intact, so assume complete return false; diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index e528166b..6cc88290 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -125,6 +125,23 @@ public interface TransactionRepository { public List getSignaturesMatchingCustomCriteria(TransactionType txType, List whereClauses, List bindParams) throws DataException; + /** + * Returns signatures for transactions that match search criteria, with optional limit. + *

+ * Alternate version that allows for custom where clauses and bind params. + * Only use for very specific use cases, such as the names integrity check. + * Not advised to be used otherwise, given that it could be possible for + * unsanitized inputs to be passed in if not careful. + * + * @param txType + * @param whereClauses + * @param bindParams + * @return + * @throws DataException + */ + public List getSignaturesMatchingCustomCriteria(TransactionType txType, List whereClauses, + List bindParams, Integer limit) throws DataException; + /** * Returns signature for latest auto-update transaction. *

diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index e7bab926..740b3e65 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -694,6 +694,53 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + public List getSignaturesMatchingCustomCriteria(TransactionType txType, List whereClauses, + List bindParams, Integer limit) throws DataException { + List signatures = new ArrayList<>(); + + String txTypeClassName = ""; + if (txType != null) { + txTypeClassName = txType.className; + } + + StringBuilder sql = new StringBuilder(1024); + sql.append(String.format("SELECT signature FROM %sTransactions", txTypeClassName)); + + if (!whereClauses.isEmpty()) { + sql.append(" WHERE "); + + final int whereClausesSize = whereClauses.size(); + for (int wci = 0; wci < whereClausesSize; ++wci) { + if (wci != 0) + sql.append(" AND "); + + sql.append(whereClauses.get(wci)); + } + } + + if (limit != null) { + sql.append(" LIMIT ?"); + bindParams.add(limit); + } + + LOGGER.trace(() -> String.format("Transaction search SQL: %s", sql)); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return signatures; + + do { + byte[] signature = resultSet.getBytes(1); + + signatures.add(signature); + } while (resultSet.next()); + + return signatures; + } catch (SQLException e) { + throw new DataException("Unable to fetch matching transaction signatures from repository", e); + } + } + @Override public byte[] getLatestAutoUpdateTransaction(TransactionType txType, int txGroupId, Integer service) throws DataException { StringBuilder sql = new StringBuilder(1024); From 90f7cee058630b0850c698a015d8db4f396d1ecd Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 21 May 2023 20:34:04 +0100 Subject: [PATCH 436/496] Bump version to 4.1.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d2232d31..288205ba 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.1.0 + 4.1.1 jar true From 0b50f965ccda066e3a00aa292943d9ce68f3aca9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 24 May 2023 19:47:10 +0100 Subject: [PATCH 437/496] Default maxNetworkThreadPoolSize set to 120 --- 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 a3a4e2a1..926628e0 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -209,7 +209,7 @@ public class Settings { /** Number of slots to reserve for short-lived QDN data transfers */ private int maxDataPeers = 4; /** Maximum number of threads for network engine. */ - private int maxNetworkThreadPoolSize = 32; + private int maxNetworkThreadPoolSize = 120; /** Maximum number of threads for network proof-of-work compute, used during handshaking. */ private int networkPoWComputePoolSize = 2; /** Maximum number of retry attempts if a peer fails to respond with the requested data */ From b967800a3ee8a0eebdc0a72c6db50ae6e27594fb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 24 May 2023 19:47:25 +0100 Subject: [PATCH 438/496] Default repositoryConnectionPoolSize set to 240 --- 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 926628e0..5fb5dd5d 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -267,7 +267,7 @@ public class Settings { /** Repository storage path. */ private String repositoryPath = "db"; /** Repository connection pool size. Needs to be a bit bigger than maxNetworkThreadPoolSize */ - private int repositoryConnectionPoolSize = 100; + private int repositoryConnectionPoolSize = 240; private List fixedNetwork; // Export/import From 6f0479c4fcbe853bb255c651ca4405a46886b4b3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 24 May 2023 19:47:37 +0100 Subject: [PATCH 439/496] Default minPeerVersion set to 4.1.1 --- 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 5fb5dd5d..3e490e09 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -219,7 +219,7 @@ public class Settings { public long recoveryModeTimeout = 24 * 60 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "4.0.0"; + private String minPeerVersion = "4.1.1"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From 1f30bef4f8d15e4f85b3cf84f016f71b2d51dc2f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 24 May 2023 19:54:36 +0100 Subject: [PATCH 440/496] defaultArchiveVersion set to 2 --- 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 3e490e09..362227a5 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -181,7 +181,7 @@ public class Settings { /** How often to attempt archiving (ms). */ private long archiveInterval = 7171L; // milliseconds /** Serialization version to use when building an archive */ - private int defaultArchiveVersion = 1; + private int defaultArchiveVersion = 2; /** Whether to automatically bootstrap instead of syncing from genesis */ From 1565a461ac4a0848f9c5afd923f8062eefdcf83d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 24 May 2023 20:01:18 +0100 Subject: [PATCH 441/496] Bump version to 4.1.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 288205ba..c9986fd4 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.1.1 + 4.1.2 jar true From c8f3b6918f40040f0d0bbdc73f3a167bca7a17d2 Mon Sep 17 00:00:00 2001 From: crowetic <5431064+crowetic@users.noreply.github.com> Date: Wed, 24 May 2023 16:20:05 -0700 Subject: [PATCH 442/496] Update start.sh Added better defaults for JVM_MEMORY_ARGS and description as to how and when to uncomment the line. --- start.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/start.sh b/start.sh index b3db54fe..a7a1419f 100755 --- a/start.sh +++ b/start.sh @@ -33,7 +33,8 @@ fi # Limits Java JVM stack size and maximum heap usage. # Comment out for bigger systems, e.g. non-routers # or when API documentation is enabled -# JVM_MEMORY_ARGS="-Xss256k -Xmx128m" +# Uncomment (remove '#' sign) line below if your system has less than 12GB of RAM for optimal RAM defaults +# JVM_MEMORY_ARGS="-Xss1256k -Xmx3128m" # Although java.net.preferIPv4Stack is supposed to be false # by default in Java 11, on some platforms (e.g. FreeBSD 12), From 655073c524d6b53f8bf591cd6d62fc8fb5218f50 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Thu, 25 May 2023 04:41:03 -0400 Subject: [PATCH 443/496] Added 2m timeout for GET_WALLET_BALANCE action --- 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 dae20e5d..d26b7791 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -467,6 +467,10 @@ function getDefaultTimeout(action) { // Allow extra time for other actions that create transactions, even if there is no PoW return 5 * 60 * 1000; + case "GET_WALLET_BALANCE": + // Getting a wallet balance can take a while, if there are many transactions + return 2 * 60 * 1000; + default: break; } From d260c0a9a9fab04c8dc0d28ee9fc026ca1334729 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Thu, 25 May 2023 16:35:08 -0400 Subject: [PATCH 444/496] Updated info on foreign coin fees --- Q-Apps.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Q-Apps.md b/Q-Apps.md index ca750e7d..83718507 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -576,14 +576,15 @@ let res = await qortalRequest({ ``` ### Send foreign coin to address -_Requires user approval_ +_Requires user approval_
+Note: default fees can be found [here](https://github.com/Qortal/qortal-ui/blob/master/plugins/plugins/core/qdn/browser/browser.src.js#L205-L209). ``` let res = await qortalRequest({ action: "SEND_COIN", coin: "LTC", destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y", amount: 1.00000000, // 1 LTC - fee: 0.00000020 // fee per byte + fee: 20 // Optional sats per byte (default fee used if omitted, recommended) - not used for QORT or ARRR }); ``` From 13da0e8a7a04dc227907dd1f1b61a7c04c59ec3e Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Thu, 25 May 2023 17:26:29 -0400 Subject: [PATCH 445/496] Adjusted fee info to long format --- Q-Apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Q-Apps.md b/Q-Apps.md index 83718507..83f2a356 100644 --- a/Q-Apps.md +++ b/Q-Apps.md @@ -584,7 +584,7 @@ let res = await qortalRequest({ coin: "LTC", destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y", amount: 1.00000000, // 1 LTC - fee: 20 // Optional sats per byte (default fee used if omitted, recommended) - not used for QORT or ARRR + fee: 0.00000020 // Optional fee per byte (default fee used if omitted, recommended) - not used for QORT or ARRR }); ``` From eda6ab57014acd5d3eb3cd83a46ef976ad59e71e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 26 May 2023 18:01:09 +0200 Subject: [PATCH 446/496] Fixed some failing unit tests, and ignored some failing BTC ones that have been superseded by LTC. --- .../java/org/qortal/test/BootstrapTests.java | 2 +- .../qortal/test/crosschain/BitcoinTests.java | 6 ++++++ .../org/qortal/test/crosschain/HtlcTests.java | 21 +++++++++++-------- .../qortal/test/crosschain/LitecoinTests.java | 7 +++---- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java index b60b412c..58e1cfa2 100644 --- a/src/test/java/org/qortal/test/BootstrapTests.java +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -212,7 +212,7 @@ public class BootstrapTests extends Common { @Test public void testBootstrapHosts() throws IOException { String[] bootstrapHosts = Settings.getInstance().getBootstrapHosts(); - String[] bootstrapTypes = { "archive", "toponly" }; + String[] bootstrapTypes = { "archive" }; // , "toponly" for (String host : bootstrapHosts) { for (String type : bootstrapTypes) { diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java index 07a01ce2..1096d7ad 100644 --- a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java @@ -8,6 +8,7 @@ import org.bitcoinj.core.Transaction; import org.bitcoinj.store.BlockStoreException; import org.junit.After; import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; import org.qortal.crosschain.Bitcoin; import org.qortal.crosschain.ForeignBlockchainException; @@ -32,6 +33,7 @@ public class BitcoinTests extends Common { } @Test + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { System.out.println(String.format("Starting BTC instance...")); System.out.println(String.format("BTC instance started")); @@ -53,6 +55,7 @@ public class BitcoinTests extends Common { } @Test + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") public void testFindHtlcSecret() throws ForeignBlockchainException { // This actually exists on TEST3 but can take a while to fetch String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; @@ -65,6 +68,7 @@ public class BitcoinTests extends Common { } @Test + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") public void testBuildSpend() { String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; @@ -81,6 +85,7 @@ public class BitcoinTests extends Common { } @Test + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") public void testGetWalletBalance() throws ForeignBlockchainException { String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; @@ -102,6 +107,7 @@ public class BitcoinTests extends Common { } @Test + @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; diff --git a/src/test/java/org/qortal/test/crosschain/HtlcTests.java b/src/test/java/org/qortal/test/crosschain/HtlcTests.java index 75b290bf..3f3678f7 100644 --- a/src/test/java/org/qortal/test/crosschain/HtlcTests.java +++ b/src/test/java/org/qortal/test/crosschain/HtlcTests.java @@ -8,6 +8,7 @@ import org.junit.Ignore; import org.junit.Test; import org.qortal.crosschain.Bitcoin; import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; import org.qortal.crypto.Crypto; import org.qortal.crosschain.BitcoinyHTLC; import org.qortal.repository.DataException; @@ -18,17 +19,19 @@ import com.google.common.primitives.Longs; public class HtlcTests extends Common { private Bitcoin bitcoin; + private Litecoin litecoin; @Before public void beforeTest() throws DataException { Common.useDefaultSettings(); // TestNet3 bitcoin = Bitcoin.getInstance(); + litecoin = Litecoin.getInstance(); } @After public void afterTest() { Bitcoin.resetForTesting(); - bitcoin = null; + litecoin = null; } @Test @@ -52,12 +55,12 @@ public class HtlcTests extends Common { do { // We need to perform fresh setup for 1st test Bitcoin.resetForTesting(); - bitcoin = Bitcoin.getInstance(); + litecoin = Litecoin.getInstance(); long now = System.currentTimeMillis(); long timestampBoundary = now / 30_000L; - byte[] secret1 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); + byte[] secret1 = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress); long executionPeriod1 = System.currentTimeMillis() - now; assertNotNull(secret1); @@ -65,7 +68,7 @@ public class HtlcTests extends Common { assertTrue("1st execution period should not be instant!", executionPeriod1 > 10); - byte[] secret2 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); + byte[] secret2 = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress); long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1; assertNotNull(secret2); @@ -86,7 +89,7 @@ public class HtlcTests extends Common { // This actually exists on TEST3 but can take a while to fetch String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - BitcoinyHTLC.Status htlcStatus = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L); + BitcoinyHTLC.Status htlcStatus = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddress, 1L); assertNotNull(htlcStatus); System.out.println(String.format("HTLC %s status: %s", p2shAddress, htlcStatus.name())); @@ -97,21 +100,21 @@ public class HtlcTests extends Common { do { // We need to perform fresh setup for 1st test Bitcoin.resetForTesting(); - bitcoin = Bitcoin.getInstance(); + litecoin = Litecoin.getInstance(); long now = System.currentTimeMillis(); long timestampBoundary = now / 30_000L; // Won't ever exist - String p2shAddress = bitcoin.deriveP2shAddress(Crypto.hash160(Longs.toByteArray(now))); + String p2shAddress = litecoin.deriveP2shAddress(Crypto.hash160(Longs.toByteArray(now))); - BitcoinyHTLC.Status htlcStatus1 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L); + BitcoinyHTLC.Status htlcStatus1 = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddress, 1L); long executionPeriod1 = System.currentTimeMillis() - now; assertNotNull(htlcStatus1); assertTrue("1st execution period should not be instant!", executionPeriod1 > 10); - BitcoinyHTLC.Status htlcStatus2 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L); + BitcoinyHTLC.Status htlcStatus2 = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddress, 1L); long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1; assertNotNull(htlcStatus2); diff --git a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java index 6236483a..5ea7bc95 100644 --- a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java @@ -5,7 +5,6 @@ import static org.junit.Assert.*; import java.util.Arrays; import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; import org.junit.After; import org.junit.Before; import org.junit.Ignore; @@ -33,12 +32,12 @@ public class LitecoinTests extends Common { } @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { + public void testGetMedianBlockTime() throws ForeignBlockchainException { long before = System.currentTimeMillis(); - System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime())); + System.out.println(String.format("Litecoin median blocktime: %d", litecoin.getMedianBlockTime())); long afterFirst = System.currentTimeMillis(); - System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime())); + System.out.println(String.format("Litecoin median blocktime: %d", litecoin.getMedianBlockTime())); long afterSecond = System.currentTimeMillis(); long firstPeriod = afterFirst - before; From 27afcf12bfe1d0e5cccad73ef2fb548b12105d3d Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Mon, 29 May 2023 04:07:52 -0400 Subject: [PATCH 447/496] Prepared files for Japanese translations --- .../resources/i18n/ApiError_jp.properties | 83 ++++++++ src/main/resources/i18n/SysTray_jp.properties | 48 +++++ .../i18n/TransactionValidity_jp.properties | 195 ++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 src/main/resources/i18n/ApiError_jp.properties create mode 100644 src/main/resources/i18n/SysTray_jp.properties create mode 100644 src/main/resources/i18n/TransactionValidity_jp.properties diff --git a/src/main/resources/i18n/ApiError_jp.properties b/src/main/resources/i18n/ApiError_jp.properties new file mode 100644 index 00000000..a9ab51c2 --- /dev/null +++ b/src/main/resources/i18n/ApiError_jp.properties @@ -0,0 +1,83 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# Keys are from api.ApiError enum + +# "localeLang": "en", + +### Common ### +JSON = failed to parse JSON message + +INSUFFICIENT_BALANCE = insufficient balance + +UNAUTHORIZED = API call unauthorized + +REPOSITORY_ISSUE = repository error + +NON_PRODUCTION = this API call is not permitted for production systems + +BLOCKCHAIN_NEEDS_SYNC = blockchain needs to synchronize first + +NO_TIME_SYNC = no clock synchronization yet + +### Validation ### +INVALID_SIGNATURE = invalid signature + +INVALID_ADDRESS = invalid address + +INVALID_PUBLIC_KEY = invalid public key + +INVALID_DATA = invalid data + +INVALID_NETWORK_ADDRESS = invalid network address + +ADDRESS_UNKNOWN = account address unknown + +INVALID_CRITERIA = invalid search criteria + +INVALID_REFERENCE = invalid reference + +TRANSFORMATION_ERROR = could not transform JSON into transaction + +INVALID_PRIVATE_KEY = invalid private key + +INVALID_HEIGHT = invalid block height + +CANNOT_MINT = account cannot mint + +### Blocks ### +BLOCK_UNKNOWN = block unknown + +### Transactions ### +TRANSACTION_UNKNOWN = transaction unknown + +PUBLIC_KEY_NOT_FOUND = public key not found + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = transaction invalid: %s (%s) + +### Naming ### +NAME_UNKNOWN = name unknown + +### Asset ### +INVALID_ASSET_ID = invalid asset ID + +INVALID_ORDER_ID = invalid asset order ID + +ORDER_UNKNOWN = unknown asset order ID + +### Groups ### +GROUP_UNKNOWN = group unknown + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = foreign blockchain or ElectrumX network issue + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = insufficient balance on foreign blockchain + +FOREIGN_BLOCKCHAIN_TOO_SOON = too soon to broadcast foreign blockchain transaction (LockTime/median block time) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = order amount too low + +### Data ### +FILE_NOT_FOUND = file not found + +NO_REPLY = peer didn't reply within the allowed time diff --git a/src/main/resources/i18n/SysTray_jp.properties b/src/main/resources/i18n/SysTray_jp.properties new file mode 100644 index 00000000..39940be0 --- /dev/null +++ b/src/main/resources/i18n/SysTray_jp.properties @@ -0,0 +1,48 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# SysTray pop-up menu + +APPLYING_UPDATE_AND_RESTARTING = Applying automatic update and restarting... + +AUTO_UPDATE = Auto Update + +BLOCK_HEIGHT = height + +BLOCKS_REMAINING = blocks remaining + +BUILD_VERSION = Build version + +CHECK_TIME_ACCURACY = Check time accuracy + +CONNECTING = Connecting + +CONNECTION = connection + +CONNECTIONS = connections + +CREATING_BACKUP_OF_DB_FILES = Creating backup of database files... + +DB_BACKUP = Database Backup + +DB_CHECKPOINT = Database Checkpoint + +DB_MAINTENANCE = Database Maintenance + +EXIT = Exit + +LITE_NODE = Lite node + +MINTING_DISABLED = NOT minting + +MINTING_ENABLED = \u2714 Minting + +OPEN_UI = Open UI + +PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... + +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + +SYNCHRONIZE_CLOCK = Synchronize clock + +SYNCHRONIZING_BLOCKCHAIN = Synchronizing + +SYNCHRONIZING_CLOCK = Synchronizing clock diff --git a/src/main/resources/i18n/TransactionValidity_jp.properties b/src/main/resources/i18n/TransactionValidity_jp.properties new file mode 100644 index 00000000..3f33771d --- /dev/null +++ b/src/main/resources/i18n/TransactionValidity_jp.properties @@ -0,0 +1,195 @@ +# + +ACCOUNT_ALREADY_EXISTS = account already exists + +ACCOUNT_CANNOT_REWARD_SHARE = account cannot reward-share + +ADDRESS_ABOVE_RATE_LIMIT = address reached specified rate limit + +ADDRESS_BLOCKED = this address is blocked + +ALREADY_GROUP_ADMIN = already group admin + +ALREADY_GROUP_MEMBER = already group member + +ALREADY_VOTED_FOR_THAT_OPTION = already voted for that option + +ASSET_ALREADY_EXISTS = asset already exists + +ASSET_DOES_NOT_EXIST = asset does not exist + +ASSET_DOES_NOT_MATCH_AT = asset does not match AT's asset + +ASSET_NOT_SPENDABLE = asset is not spendable + +AT_ALREADY_EXISTS = AT already exists + +AT_IS_FINISHED = AT has finished + +AT_UNKNOWN = AT unknown + +BAN_EXISTS = ban already exists + +BAN_UNKNOWN = ban unknown + +BANNED_FROM_GROUP = banned from group + +BUYER_ALREADY_OWNER = buyer is already owner + +CLOCK_NOT_SYNCED = clock not synchronized + +DUPLICATE_MESSAGE = address sent duplicate message + +DUPLICATE_OPTION = duplicate option + +GROUP_ALREADY_EXISTS = group already exists + +GROUP_APPROVAL_DECIDED = group-approval already decided + +GROUP_APPROVAL_NOT_REQUIRED = group-approval not required + +GROUP_DOES_NOT_EXIST = group does not exist + +GROUP_ID_MISMATCH = group ID mismatch + +GROUP_OWNER_CANNOT_LEAVE = group owner cannot leave group + +HAVE_EQUALS_WANT = have-asset is the same as want-asset + +INCORRECT_NONCE = incorrect PoW nonce + +INSUFFICIENT_FEE = insufficient fee + +INVALID_ADDRESS = invalid address + +INVALID_AMOUNT = invalid amount + +INVALID_ASSET_OWNER = invalid asset owner + +INVALID_AT_TRANSACTION = invalid AT transaction + +INVALID_AT_TYPE_LENGTH = invalid AT 'type' length + +INVALID_BUT_OK = invalid but OK + +INVALID_CREATION_BYTES = invalid creation bytes + +INVALID_DATA_LENGTH = invalid data length + +INVALID_DESCRIPTION_LENGTH = invalid description length + +INVALID_GROUP_APPROVAL_THRESHOLD = invalid group-approval threshold + +INVALID_GROUP_BLOCK_DELAY = invalid group-approval block delay + +INVALID_GROUP_ID = invalid group ID + +INVALID_GROUP_OWNER = invalid group owner + +INVALID_LIFETIME = invalid lifetime + +INVALID_NAME_LENGTH = invalid name length + +INVALID_NAME_OWNER = invalid name owner + +INVALID_OPTION_LENGTH = invalid options length + +INVALID_OPTIONS_COUNT = invalid options count + +INVALID_ORDER_CREATOR = invalid order creator + +INVALID_PAYMENTS_COUNT = invalid payments count + +INVALID_PUBLIC_KEY = invalid public key + +INVALID_QUANTITY = invalid quantity + +INVALID_REFERENCE = invalid reference + +INVALID_RETURN = invalid return + +INVALID_REWARD_SHARE_PERCENT = invalid reward-share percent + +INVALID_SELLER = invalid seller + +INVALID_TAGS_LENGTH = invalid 'tags' length + +INVALID_TIMESTAMP_SIGNATURE = invalid timestamp signature + +INVALID_TX_GROUP_ID = invalid transaction group ID + +INVALID_VALUE_LENGTH = invalid 'value' length + +INVITE_UNKNOWN = group invite unknown + +JOIN_REQUEST_EXISTS = group join request already exists + +MAXIMUM_REWARD_SHARES = already at maximum number of reward-shares for this account + +MISSING_CREATOR = missing creator + +MULTIPLE_NAMES_FORBIDDEN = multiple registered names per account is forbidden + +NAME_ALREADY_FOR_SALE = name already for sale + +NAME_ALREADY_REGISTERED = name already registered + +NAME_BLOCKED = this name is blocked + +NAME_DOES_NOT_EXIST = name does not exist + +NAME_NOT_FOR_SALE = name is not for sale + +NAME_NOT_NORMALIZED = name not in Unicode 'normalized' form + +NEGATIVE_AMOUNT = invalid/negative amount + +NEGATIVE_FEE = invalid/negative fee + +NEGATIVE_PRICE = invalid/negative price + +NO_BALANCE = insufficient balance + +NO_BLOCKCHAIN_LOCK = node's blockchain currently busy + +NO_FLAG_PERMISSION = account does not have that permission + +NOT_GROUP_ADMIN = account is not a group admin + +NOT_GROUP_MEMBER = account is not a group member + +NOT_MINTING_ACCOUNT = account cannot mint + +NOT_YET_RELEASED = feature not yet released + +OK = OK + +ORDER_ALREADY_CLOSED = asset trade order is already closed + +ORDER_DOES_NOT_EXIST = asset trade order does not exist + +POLL_ALREADY_EXISTS = poll already exists + +POLL_DOES_NOT_EXIST = poll does not exist + +POLL_OPTION_DOES_NOT_EXIST = poll option does not exist + +PUBLIC_KEY_UNKNOWN = public key unknown + +REWARD_SHARE_UNKNOWN = reward-share unknown + +SELF_SHARE_EXISTS = self-share (reward-share) already exists + +TIMESTAMP_TOO_NEW = timestamp too new + +TIMESTAMP_TOO_OLD = timestamp too old + +TOO_MANY_UNCONFIRMED = account has too many unconfirmed transactions pending + +TRANSACTION_ALREADY_CONFIRMED = transaction has already confirmed + +TRANSACTION_ALREADY_EXISTS = transaction already exists + +TRANSACTION_UNKNOWN = transaction unknown + +TX_GROUP_ID_MISMATCH = transaction's group ID does not match From a4bb445f3e0da29c91e9012133c53bd61b98eff8 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Mon, 29 May 2023 04:23:22 -0400 Subject: [PATCH 448/496] Added Japanese translations for review --- .../resources/i18n/ApiError_jp.properties | 70 +++---- src/main/resources/i18n/SysTray_jp.properties | 46 ++--- .../i18n/TransactionValidity_jp.properties | 192 +++++++++--------- 3 files changed, 154 insertions(+), 154 deletions(-) diff --git a/src/main/resources/i18n/ApiError_jp.properties b/src/main/resources/i18n/ApiError_jp.properties index a9ab51c2..f1308ea8 100644 --- a/src/main/resources/i18n/ApiError_jp.properties +++ b/src/main/resources/i18n/ApiError_jp.properties @@ -1,83 +1,83 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # Keys are from api.ApiError enum -# "localeLang": "en", +# "localeLang": "jp", ### Common ### -JSON = failed to parse JSON message +JSON = JSON メッセージの解析に失敗しました -INSUFFICIENT_BALANCE = insufficient balance +INSUFFICIENT_BALANCE = 残高が不足しています -UNAUTHORIZED = API call unauthorized +UNAUTHORIZED = API 呼び出しが承認されていません -REPOSITORY_ISSUE = repository error +REPOSITORY_ISSUE = リポジトリ エラー -NON_PRODUCTION = this API call is not permitted for production systems +NON_PRODUCTION = この API 呼び出しは運用システムでは許可されていません -BLOCKCHAIN_NEEDS_SYNC = blockchain needs to synchronize first +BLOCKCHAIN_NEEDS_SYNC = ブロックチェーンは最初に同期する必要があります -NO_TIME_SYNC = no clock synchronization yet +NO_TIME_SYNC = クロック同期はまだありません ### Validation ### -INVALID_SIGNATURE = invalid signature +INVALID_SIGNATURE = 無効な署名 -INVALID_ADDRESS = invalid address +INVALID_ADDRESS = 無効なアドレス -INVALID_PUBLIC_KEY = invalid public key +INVALID_PUBLIC_KEY = 無効な公開キー -INVALID_DATA = invalid data +INVALID_DATA = 無効なデータ -INVALID_NETWORK_ADDRESS = invalid network address +INVALID_NETWORK_ADDRESS = 無効なネットワーク アドレス -ADDRESS_UNKNOWN = account address unknown +ADDRESS_UNKNOWN = アカウントアドレスが不明です -INVALID_CRITERIA = invalid search criteria +INVALID_CRITERIA = 無効な検索条件 -INVALID_REFERENCE = invalid reference +INVALID_REFERENCE = 無効な参照 -TRANSFORMATION_ERROR = could not transform JSON into transaction +TRANSFORMATION_ERROR = JSON をトランザクションに変換できませんでした -INVALID_PRIVATE_KEY = invalid private key +INVALID_PRIVATE_KEY = 無効な秘密キー -INVALID_HEIGHT = invalid block height +INVALID_HEIGHT = 無効なブロックの高さ -CANNOT_MINT = account cannot mint +CANNOT_MINT = アカウントはミントできません ### Blocks ### -BLOCK_UNKNOWN = block unknown +BLOCK_UNKNOWN = 不明なブロック ### Transactions ### -TRANSACTION_UNKNOWN = transaction unknown +TRANSACTION_UNKNOWN = トランザクションが不明です -PUBLIC_KEY_NOT_FOUND = public key not found +PUBLIC_KEY_NOT_FOUND = 公開キーが見つかりません # this one is special in that caller expected to pass two additional strings, hence the two %s -TRANSACTION_INVALID = transaction invalid: %s (%s) +TRANSACTION_INVALID = トランザクションが無効です: %s (%s) ### Naming ### -NAME_UNKNOWN = name unknown +NAME_UNKNOWN = 名前が不明です ### Asset ### -INVALID_ASSET_ID = invalid asset ID +INVALID_ASSET_ID = 無効なアセット ID -INVALID_ORDER_ID = invalid asset order ID +INVALID_ORDER_ID = 無効なアセット注文 ID -ORDER_UNKNOWN = unknown asset order ID +ORDER_UNKNOWN = 不明なアセット注文 ID ### Groups ### -GROUP_UNKNOWN = group unknown +GROUP_UNKNOWN = 不明なグループ ### Foreign Blockchain ### -FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = foreign blockchain or ElectrumX network issue +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = 外部ブロックチェーンまたは ElectrumX ネットワークの問題 -FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = insufficient balance on foreign blockchain +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = 外部ブロックチェーンの残高が不足しています -FOREIGN_BLOCKCHAIN_TOO_SOON = too soon to broadcast foreign blockchain transaction (LockTime/median block time) +FOREIGN_BLOCKCHAIN_TOO_SOON = 外部ブロックチェーン トランザクションをブロードキャストするには早すぎます (LockTime/ブロック時間の中央値) ### Trade Portal ### -ORDER_SIZE_TOO_SMALL = order amount too low +ORDER_SIZE_TOO_SMALL = 注文金額が低すぎます ### Data ### -FILE_NOT_FOUND = file not found +FILE_NOT_FOUND = ファイルが見つかりません -NO_REPLY = peer didn't reply within the allowed time +NO_REPLY = ピアは許可された時間内に応答しませんでした diff --git a/src/main/resources/i18n/SysTray_jp.properties b/src/main/resources/i18n/SysTray_jp.properties index 39940be0..6f0f9b06 100644 --- a/src/main/resources/i18n/SysTray_jp.properties +++ b/src/main/resources/i18n/SysTray_jp.properties @@ -1,48 +1,48 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -APPLYING_UPDATE_AND_RESTARTING = Applying automatic update and restarting... +APPLYING_UPDATE_AND_RESTARTING = 自動アップデートを適用して再起動しています... -AUTO_UPDATE = Auto Update +AUTO_UPDATE = 自動更新 -BLOCK_HEIGHT = height +BLOCK_HEIGHT = 高さ -BLOCKS_REMAINING = blocks remaining +BLOCKS_REMAINING = 残りのブロック -BUILD_VERSION = Build version +BUILD_VERSION = ビルドバージョン -CHECK_TIME_ACCURACY = Check time accuracy +CHECK_TIME_ACCURACY = 時間の精度を確認する -CONNECTING = Connecting +CONNECTING = 接続中 -CONNECTION = connection +CONNECTION = つながり -CONNECTIONS = connections +CONNECTIONS = 接続 -CREATING_BACKUP_OF_DB_FILES = Creating backup of database files... +CREATING_BACKUP_OF_DB_FILES = データベース ファイルのバックアップを作成しています... -DB_BACKUP = Database Backup +DB_BACKUP = データベースのバックアップ -DB_CHECKPOINT = Database Checkpoint +DB_CHECKPOINT = データベースチェックポイント -DB_MAINTENANCE = Database Maintenance +DB_MAINTENANCE = データベースのメンテナンス -EXIT = Exit +EXIT = 終了 -LITE_NODE = Lite node +LITE_NODE = ライトノード -MINTING_DISABLED = NOT minting +MINTING_DISABLED = 鋳造しない -MINTING_ENABLED = \u2714 Minting +MINTING_ENABLED = \u2714 ミント -OPEN_UI = Open UI +OPEN_UI = オープン UI -PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... +PERFORMING_DB_CHECKPOINT = コミットされていないデータベースの変更を保存しています... -PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... +PERFORMING_DB_MAINTENANCE = 定期メンテナンスを実行しています... -SYNCHRONIZE_CLOCK = Synchronize clock +SYNCHRONIZE_CLOCK = クロックを同期する -SYNCHRONIZING_BLOCKCHAIN = Synchronizing +SYNCHRONIZING_BLOCKCHAIN = 同期中 -SYNCHRONIZING_CLOCK = Synchronizing clock +SYNCHRONIZING_CLOCK = 同期クロック diff --git a/src/main/resources/i18n/TransactionValidity_jp.properties b/src/main/resources/i18n/TransactionValidity_jp.properties index 3f33771d..c1111619 100644 --- a/src/main/resources/i18n/TransactionValidity_jp.properties +++ b/src/main/resources/i18n/TransactionValidity_jp.properties @@ -1,195 +1,195 @@ # -ACCOUNT_ALREADY_EXISTS = account already exists +ACCOUNT_ALREADY_EXISTS = アカウントはすでに存在します -ACCOUNT_CANNOT_REWARD_SHARE = account cannot reward-share +ACCOUNT_CANNOT_REWARD_SHARE = アカウントは報酬を共有できません -ADDRESS_ABOVE_RATE_LIMIT = address reached specified rate limit +ADDRESS_ABOVE_RATE_LIMIT = アドレスが指定されたレート制限に達しました -ADDRESS_BLOCKED = this address is blocked +ADDRESS_BLOCKED = このアドレスはブロックされています -ALREADY_GROUP_ADMIN = already group admin +ALREADY_GROUP_ADMIN = すでにグループ管理者です -ALREADY_GROUP_MEMBER = already group member +ALREADY_GROUP_MEMBER = すでにグループメンバーです -ALREADY_VOTED_FOR_THAT_OPTION = already voted for that option +ALREADY_VOTED_FOR_THAT_OPTION = すでにそのオプションに投票しています -ASSET_ALREADY_EXISTS = asset already exists +ASSET_ALREADY_EXISTS = アセットはすでに存在します -ASSET_DOES_NOT_EXIST = asset does not exist +ASSET_DOES_NOT_EXIST = アセットが存在しません -ASSET_DOES_NOT_MATCH_AT = asset does not match AT's asset +ASSET_DOES_NOT_MATCH_AT = アセットが AT のアセットと一致しません -ASSET_NOT_SPENDABLE = asset is not spendable +ASSET_NOT_SPENDABLE = 資産は使用できません -AT_ALREADY_EXISTS = AT already exists +AT_ALREADY_EXISTS = AT はすでに存在します -AT_IS_FINISHED = AT has finished +AT_IS_FINISHED = AT が終了しました -AT_UNKNOWN = AT unknown +AT_UNKNOWN = AT が不明 -BAN_EXISTS = ban already exists +BAN_EXISTS = 禁止はすでに存在します -BAN_UNKNOWN = ban unknown +BAN_UNKNOWN = 禁止は不明 -BANNED_FROM_GROUP = banned from group +BANNED_FROM_GROUP = グループからの参加を禁止されています -BUYER_ALREADY_OWNER = buyer is already owner +BUYER_ALREADY_OWNER = 購入者はすでに所有者です -CLOCK_NOT_SYNCED = clock not synchronized +CLOCK_NOT_SYNCED = クロックが同期されていません -DUPLICATE_MESSAGE = address sent duplicate message +DUPLICATE_MESSAGE = 重複メッセージを送信したアドレス -DUPLICATE_OPTION = duplicate option +DUPLICATE_OPTION = 重複したオプション -GROUP_ALREADY_EXISTS = group already exists +GROUP_ALREADY_EXISTS = グループはすでに存在します -GROUP_APPROVAL_DECIDED = group-approval already decided +GROUP_APPROVAL_DECIDED = グループの承認はすでに決定されています -GROUP_APPROVAL_NOT_REQUIRED = group-approval not required +GROUP_APPROVAL_NOT_REQUIRED = グループ承認は必要ありません -GROUP_DOES_NOT_EXIST = group does not exist +GROUP_DOES_NOT_EXIST = グループが存在しません -GROUP_ID_MISMATCH = group ID mismatch +GROUP_ID_MISMATCH = グループ ID の不一致 -GROUP_OWNER_CANNOT_LEAVE = group owner cannot leave group +GROUP_OWNER_CANNOT_LEAVE = グループ所有者はグループを脱退できません -HAVE_EQUALS_WANT = have-asset is the same as want-asset +HAVE_EQUALS_WANT = 持っている資産は欲しい資産と同じです -INCORRECT_NONCE = incorrect PoW nonce +INCORRECT_NONCE = 不正な PoW ナンス -INSUFFICIENT_FEE = insufficient fee +INSUFFICIENT_FEE = 料金が不十分です -INVALID_ADDRESS = invalid address +INVALID_ADDRESS = 無効なアドレス -INVALID_AMOUNT = invalid amount +INVALID_AMOUNT = 無効な金額 -INVALID_ASSET_OWNER = invalid asset owner +INVALID_ASSET_OWNER = 無効なアセット所有者 -INVALID_AT_TRANSACTION = invalid AT transaction +INVALID_AT_TRANSACTION = 無効な AT トランザクション -INVALID_AT_TYPE_LENGTH = invalid AT 'type' length +INVALID_AT_TYPE_LENGTH = AT の「タイプ」の長さが無効です -INVALID_BUT_OK = invalid but OK +INVALID_BUT_OK = 無効だがOK -INVALID_CREATION_BYTES = invalid creation bytes +INVALID_CREATION_BYTES = 無効な作成バイト数 -INVALID_DATA_LENGTH = invalid data length +INVALID_DATA_LENGTH = 無効なデータ長 -INVALID_DESCRIPTION_LENGTH = invalid description length +INVALID_DESCRIPTION_LENGTH = 無効な説明の長さ -INVALID_GROUP_APPROVAL_THRESHOLD = invalid group-approval threshold +INVALID_GROUP_APPROVAL_THRESHOLD = 無効なグループ承認しきい値 -INVALID_GROUP_BLOCK_DELAY = invalid group-approval block delay +INVALID_GROUP_BLOCK_DELAY = 無効なグループ承認ブロックの遅延 -INVALID_GROUP_ID = invalid group ID +INVALID_GROUP_ID = 無効なグループ ID -INVALID_GROUP_OWNER = invalid group owner +INVALID_GROUP_OWNER = 無効なグループ所有者 -INVALID_LIFETIME = invalid lifetime +INVALID_LIFETIME = 無効な有効期間 -INVALID_NAME_LENGTH = invalid name length +INVALID_NAME_LENGTH = 名前の長さが無効です -INVALID_NAME_OWNER = invalid name owner +INVALID_NAME_OWNER = 無効な名前の所有者 -INVALID_OPTION_LENGTH = invalid options length +INVALID_OPTION_LENGTH = 無効なオプションの長さ -INVALID_OPTIONS_COUNT = invalid options count +INVALID_OPTIONS_COUNT = 無効なオプションの数 -INVALID_ORDER_CREATOR = invalid order creator +INVALID_ORDER_CREATOR = 無効な注文作成者 -INVALID_PAYMENTS_COUNT = invalid payments count +INVALID_PAYMENTS_COUNT = 無効な支払い数 -INVALID_PUBLIC_KEY = invalid public key +INVALID_PUBLIC_KEY = 無効な公開キー -INVALID_QUANTITY = invalid quantity +INVALID_QUANTITY = 無効な数量 -INVALID_REFERENCE = invalid reference +INVALID_REFERENCE = 無効な参照 -INVALID_RETURN = invalid return +INVALID_RETURN = 無効な返品 -INVALID_REWARD_SHARE_PERCENT = invalid reward-share percent +INVALID_REWARD_SHARE_PERCENT = 無効な報酬分配率 -INVALID_SELLER = invalid seller +INVALID_SELLER = 無効な販売者 -INVALID_TAGS_LENGTH = invalid 'tags' length +INVALID_TAGS_LENGTH = 無効な「タグ」の長さ -INVALID_TIMESTAMP_SIGNATURE = invalid timestamp signature +INVALID_TIMESTAMP_SIGNATURE = 無効なタイムスタンプ署名 -INVALID_TX_GROUP_ID = invalid transaction group ID +INVALID_TX_GROUP_ID = 無効なトランザクション グループ ID -INVALID_VALUE_LENGTH = invalid 'value' length +INVALID_VALUE_LENGTH = 無効な「値」の長さ -INVITE_UNKNOWN = group invite unknown +INVITE_UNKNOWN = グループの招待が不明です -JOIN_REQUEST_EXISTS = group join request already exists +JOIN_REQUEST_EXISTS = グループ参加リクエストはすでに存在します -MAXIMUM_REWARD_SHARES = already at maximum number of reward-shares for this account +MAXIMUM_REWARD_SHARES = このアカウントの特典シェアはすでに最大数に達しています -MISSING_CREATOR = missing creator +MISSING_CREATOR = 作成者が見つかりません -MULTIPLE_NAMES_FORBIDDEN = multiple registered names per account is forbidden +MULTIPLE_NAMES_FORBIDDEN = アカウントごとに複数の登録名は禁止されています -NAME_ALREADY_FOR_SALE = name already for sale +NAME_ALREADY_FOR_SALE = 名前はすでに販売中です -NAME_ALREADY_REGISTERED = name already registered +NAME_ALREADY_REGISTERED = 名前はすでに登録されています -NAME_BLOCKED = this name is blocked +NAME_BLOCKED = この名前はブロックされています -NAME_DOES_NOT_EXIST = name does not exist +NAME_DOES_NOT_EXIST = 名前は存在しません -NAME_NOT_FOR_SALE = name is not for sale +NAME_NOT_FOR_SALE = 名前は非売品です -NAME_NOT_NORMALIZED = name not in Unicode 'normalized' form +NAME_NOT_NORMALIZED = 名前は Unicode の「正規化」形式ではありません -NEGATIVE_AMOUNT = invalid/negative amount +NEGATIVE_AMOUNT = 無効/マイナスの金額 -NEGATIVE_FEE = invalid/negative fee +NEGATIVE_FEE = 無効/マイナスの料金 -NEGATIVE_PRICE = invalid/negative price +NEGATIVE_PRICE = 無効/マイナスの価格 -NO_BALANCE = insufficient balance +NO_BALANCE = 残高が不足しています -NO_BLOCKCHAIN_LOCK = node's blockchain currently busy +NO_BLOCKCHAIN_LOCK = ノードのブロックチェーンは現在ビジーです -NO_FLAG_PERMISSION = account does not have that permission +NO_FLAG_PERMISSION = アカウントにはその権限がありません -NOT_GROUP_ADMIN = account is not a group admin +NOT_GROUP_ADMIN = アカウントはグループ管理者ではありません -NOT_GROUP_MEMBER = account is not a group member +NOT_GROUP_MEMBER = アカウントはグループメンバーではありません -NOT_MINTING_ACCOUNT = account cannot mint +NOT_MINTING_ACCOUNT = アカウントはミントできません -NOT_YET_RELEASED = feature not yet released +NOT_YET_RELEASED = 機能はまだリリースされていません OK = OK -ORDER_ALREADY_CLOSED = asset trade order is already closed +ORDER_ALREADY_CLOSED = 資産取引注文はすでに終了しています -ORDER_DOES_NOT_EXIST = asset trade order does not exist +ORDER_DOES_NOT_EXIST = 資産取引注文が存在しません -POLL_ALREADY_EXISTS = poll already exists +POLL_ALREADY_EXISTS = ポーリングはすでに存在します -POLL_DOES_NOT_EXIST = poll does not exist +POLL_DOES_NOT_EXIST = 投票は存在しません -POLL_OPTION_DOES_NOT_EXIST = poll option does not exist +POLL_OPTION_DOES_NOT_EXIST = ポーリング オプションが存在しません -PUBLIC_KEY_UNKNOWN = public key unknown +PUBLIC_KEY_UNKNOWN = 公開鍵が不明です -REWARD_SHARE_UNKNOWN = reward-share unknown +REWARD_SHARE_UNKNOWN = 報酬シェアが不明 -SELF_SHARE_EXISTS = self-share (reward-share) already exists +SELF_SHARE_EXISTS = セルフシェア (報酬シェア) はすでに存在します -TIMESTAMP_TOO_NEW = timestamp too new +TIMESTAMP_TOO_NEW = タイムスタンプが新しすぎます -TIMESTAMP_TOO_OLD = timestamp too old +TIMESTAMP_TOO_OLD = タイムスタンプが古すぎます -TOO_MANY_UNCONFIRMED = account has too many unconfirmed transactions pending +TOO_MANY_UNCONFIRMED = アカウントに保留中の未確認トランザクションが多すぎます -TRANSACTION_ALREADY_CONFIRMED = transaction has already confirmed +TRANSACTION_ALREADY_CONFIRMED = トランザクションはすでに確認されています -TRANSACTION_ALREADY_EXISTS = transaction already exists +TRANSACTION_ALREADY_EXISTS = トランザクションはすでに存在します -TRANSACTION_UNKNOWN = transaction unknown +TRANSACTION_UNKNOWN = トランザクションが不明です -TX_GROUP_ID_MISMATCH = transaction's group ID does not match +TX_GROUP_ID_MISMATCH = トランザクションのグループ ID が一致しません From bfc03db6a9d78ba0ad5e962f70aa1db58d2b17f5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 30 May 2023 17:41:39 +0100 Subject: [PATCH 449/496] Default minPeerVersion set to 4.1.2 --- 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 362227a5..cce3f441 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -219,7 +219,7 @@ public class Settings { public long recoveryModeTimeout = 24 * 60 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "4.1.1"; + private String minPeerVersion = "4.1.2"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From 66c91fd365f321410e411b30f2ad0ffb8d978e3c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 30 May 2023 17:42:31 +0100 Subject: [PATCH 450/496] MIN_PEER_VERSION for handshake set to 4.1.1 --- src/main/java/org/qortal/network/Handshake.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/Handshake.java b/src/main/java/org/qortal/network/Handshake.java index 4500cd59..341f4e21 100644 --- a/src/main/java/org/qortal/network/Handshake.java +++ b/src/main/java/org/qortal/network/Handshake.java @@ -265,7 +265,7 @@ public enum Handshake { private static final long PEER_VERSION_131 = 0x0100030001L; /** Minimum peer version that we are allowed to communicate with */ - private static final String MIN_PEER_VERSION = "4.0.0"; + private static final String MIN_PEER_VERSION = "4.1.1"; private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes private static final int POW_DIFFICULTY_PRE_131 = 8; // leading zero bits From 5fb2640a3afea047c511973467a0f324311232e3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 30 May 2023 18:05:05 +0100 Subject: [PATCH 451/496] Bump version to 4.1.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c9986fd4..7c7ac147 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.1.2 + 4.1.3 jar true From 7f7b02f0038957facf8a916d8a2b68c5f11de760 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Fri, 2 Jun 2023 06:17:48 -0400 Subject: [PATCH 452/496] Updated Japanese translations (Credit: R M) --- .../resources/i18n/ApiError_jp.properties | 38 +++---- src/main/resources/i18n/SysTray_jp.properties | 28 ++--- .../i18n/TransactionValidity_jp.properties | 106 +++++++++--------- 3 files changed, 86 insertions(+), 86 deletions(-) diff --git a/src/main/resources/i18n/ApiError_jp.properties b/src/main/resources/i18n/ApiError_jp.properties index f1308ea8..603914cb 100644 --- a/src/main/resources/i18n/ApiError_jp.properties +++ b/src/main/resources/i18n/ApiError_jp.properties @@ -6,56 +6,56 @@ ### Common ### JSON = JSON メッセージの解析に失敗しました -INSUFFICIENT_BALANCE = 残高が不足しています +INSUFFICIENT_BALANCE = 残高不足 -UNAUTHORIZED = API 呼び出しが承認されていません +UNAUTHORIZED = APIコール未承認 -REPOSITORY_ISSUE = リポジトリ エラー +REPOSITORY_ISSUE = リポジトリエラー -NON_PRODUCTION = この API 呼び出しは運用システムでは許可されていません +NON_PRODUCTION = この APIコールはプロダクションシステムでは許可されていません -BLOCKCHAIN_NEEDS_SYNC = ブロックチェーンは最初に同期する必要があります +BLOCKCHAIN_NEEDS_SYNC = ブロックチェーンをまず同期する必要があります -NO_TIME_SYNC = クロック同期はまだありません +NO_TIME_SYNC = 時刻が未同期 ### Validation ### INVALID_SIGNATURE = 無効な署名 INVALID_ADDRESS = 無効なアドレス -INVALID_PUBLIC_KEY = 無効な公開キー +INVALID_PUBLIC_KEY = 無効な公開鍵 INVALID_DATA = 無効なデータ INVALID_NETWORK_ADDRESS = 無効なネットワーク アドレス -ADDRESS_UNKNOWN = アカウントアドレスが不明です +ADDRESS_UNKNOWN = 不明なアカウントアドレス INVALID_CRITERIA = 無効な検索条件 INVALID_REFERENCE = 無効な参照 -TRANSFORMATION_ERROR = JSON をトランザクションに変換できませんでした +TRANSFORMATION_ERROR = JSONをトランザクションに変換出来ませんでした -INVALID_PRIVATE_KEY = 無効な秘密キー +INVALID_PRIVATE_KEY = 無効な秘密鍵 -INVALID_HEIGHT = 無効なブロックの高さ +INVALID_HEIGHT = 無効なブロック高 -CANNOT_MINT = アカウントはミントできません +CANNOT_MINT = アカウントはミント出来ません ### Blocks ### BLOCK_UNKNOWN = 不明なブロック ### Transactions ### -TRANSACTION_UNKNOWN = トランザクションが不明です +TRANSACTION_UNKNOWN = 不明なトランザクション -PUBLIC_KEY_NOT_FOUND = 公開キーが見つかりません +PUBLIC_KEY_NOT_FOUND = 公開鍵が見つかりません # this one is special in that caller expected to pass two additional strings, hence the two %s -TRANSACTION_INVALID = トランザクションが無効です: %s (%s) +TRANSACTION_INVALID = 無効なトランザクション: %s (%s) ### Naming ### -NAME_UNKNOWN = 名前が不明です +NAME_UNKNOWN = 不明な名前 ### Asset ### INVALID_ASSET_ID = 無効なアセット ID @@ -68,11 +68,11 @@ ORDER_UNKNOWN = 不明なアセット注文 ID GROUP_UNKNOWN = 不明なグループ ### Foreign Blockchain ### -FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = 外部ブロックチェーンまたは ElectrumX ネットワークの問題 +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = 外部ブロックチェーンまたはElectrumXネットワークの問題 FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = 外部ブロックチェーンの残高が不足しています -FOREIGN_BLOCKCHAIN_TOO_SOON = 外部ブロックチェーン トランザクションをブロードキャストするには早すぎます (LockTime/ブロック時間の中央値) +FOREIGN_BLOCKCHAIN_TOO_SOON = 外部ブロックチェーン トランザクションのブロードキャストが時期尚早 (ロックタイム/ブロック時間の中央値) ### Trade Portal ### ORDER_SIZE_TOO_SMALL = 注文金額が低すぎます @@ -80,4 +80,4 @@ ORDER_SIZE_TOO_SMALL = 注文金額が低すぎます ### Data ### FILE_NOT_FOUND = ファイルが見つかりません -NO_REPLY = ピアは許可された時間内に応答しませんでした +NO_REPLY = ピアが制限時間内に応答しませんでした diff --git a/src/main/resources/i18n/SysTray_jp.properties b/src/main/resources/i18n/SysTray_jp.properties index 6f0f9b06..c4cccb5b 100644 --- a/src/main/resources/i18n/SysTray_jp.properties +++ b/src/main/resources/i18n/SysTray_jp.properties @@ -1,29 +1,29 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) -# SysTray pop-up menu +# SysTray pop-up menu # Japanese translation by R M 2023 -APPLYING_UPDATE_AND_RESTARTING = 自動アップデートを適用して再起動しています... +APPLYING_UPDATE_AND_RESTARTING = 自動更新を適用して再起動しています... AUTO_UPDATE = 自動更新 -BLOCK_HEIGHT = 高さ +BLOCK_HEIGHT = ブロック高 BLOCKS_REMAINING = 残りのブロック BUILD_VERSION = ビルドバージョン -CHECK_TIME_ACCURACY = 時間の精度を確認する +CHECK_TIME_ACCURACY = 時刻の精度を確認 CONNECTING = 接続中 -CONNECTION = つながり +CONNECTION = 接続 CONNECTIONS = 接続 -CREATING_BACKUP_OF_DB_FILES = データベース ファイルのバックアップを作成しています... +CREATING_BACKUP_OF_DB_FILES = データベース ファイルのバックアップを作成中... DB_BACKUP = データベースのバックアップ -DB_CHECKPOINT = データベースチェックポイント +DB_CHECKPOINT = データベースのチェックポイント DB_MAINTENANCE = データベースのメンテナンス @@ -31,18 +31,18 @@ EXIT = 終了 LITE_NODE = ライトノード -MINTING_DISABLED = 鋳造しない +MINTING_DISABLED = ミント一時中止中 MINTING_ENABLED = \u2714 ミント -OPEN_UI = オープン UI +OPEN_UI = UIを開く -PERFORMING_DB_CHECKPOINT = コミットされていないデータベースの変更を保存しています... +PERFORMING_DB_CHECKPOINT = コミットされていないデータベースの変更を保存中... -PERFORMING_DB_MAINTENANCE = 定期メンテナンスを実行しています... +PERFORMING_DB_MAINTENANCE = 定期メンテナンスを実行中... -SYNCHRONIZE_CLOCK = クロックを同期する +SYNCHRONIZE_CLOCK = 時刻を同期 -SYNCHRONIZING_BLOCKCHAIN = 同期中 +SYNCHRONIZING_BLOCKCHAIN = ブロックチェーンを同期中 -SYNCHRONIZING_CLOCK = 同期クロック +SYNCHRONIZING_CLOCK = 時刻を同期中 diff --git a/src/main/resources/i18n/TransactionValidity_jp.properties b/src/main/resources/i18n/TransactionValidity_jp.properties index c1111619..9540372a 100644 --- a/src/main/resources/i18n/TransactionValidity_jp.properties +++ b/src/main/resources/i18n/TransactionValidity_jp.properties @@ -1,64 +1,64 @@ -# +# -ACCOUNT_ALREADY_EXISTS = アカウントはすでに存在します +ACCOUNT_ALREADY_EXISTS = 既にアカウントは存在します -ACCOUNT_CANNOT_REWARD_SHARE = アカウントは報酬を共有できません +ACCOUNT_CANNOT_REWARD_SHARE = アカウントは報酬シェアが出来ません ADDRESS_ABOVE_RATE_LIMIT = アドレスが指定されたレート制限に達しました ADDRESS_BLOCKED = このアドレスはブロックされています -ALREADY_GROUP_ADMIN = すでにグループ管理者です +ALREADY_GROUP_ADMIN = 既ににグループ管理者です -ALREADY_GROUP_MEMBER = すでにグループメンバーです +ALREADY_GROUP_MEMBER = 既にグループメンバーです -ALREADY_VOTED_FOR_THAT_OPTION = すでにそのオプションに投票しています +ALREADY_VOTED_FOR_THAT_OPTION = 既にそのオプションに投票しています -ASSET_ALREADY_EXISTS = アセットはすでに存在します +ASSET_ALREADY_EXISTS = 既にアセットは存在します ASSET_DOES_NOT_EXIST = アセットが存在しません -ASSET_DOES_NOT_MATCH_AT = アセットが AT のアセットと一致しません +ASSET_DOES_NOT_MATCH_AT = アセットがATのアセットと一致しません -ASSET_NOT_SPENDABLE = 資産は使用できません +ASSET_NOT_SPENDABLE = 資産が使用不可です -AT_ALREADY_EXISTS = AT はすでに存在します +AT_ALREADY_EXISTS = 既にATが存在します -AT_IS_FINISHED = AT が終了しました +AT_IS_FINISHED = ATが終了しました -AT_UNKNOWN = AT が不明 +AT_UNKNOWN = 不明なAT -BAN_EXISTS = 禁止はすでに存在します +BAN_EXISTS = 既にバンされてます -BAN_UNKNOWN = 禁止は不明 +BAN_UNKNOWN = 不明なバン -BANNED_FROM_GROUP = グループからの参加を禁止されています +BANNED_FROM_GROUP = グループからのバンされています -BUYER_ALREADY_OWNER = 購入者はすでに所有者です +BUYER_ALREADY_OWNER = 既に購入者が所有者です -CLOCK_NOT_SYNCED = クロックが同期されていません +CLOCK_NOT_SYNCED = 時刻が未同期 -DUPLICATE_MESSAGE = 重複メッセージを送信したアドレス +DUPLICATE_MESSAGE = このアドレスは重複メッセージを送信しました DUPLICATE_OPTION = 重複したオプション -GROUP_ALREADY_EXISTS = グループはすでに存在します +GROUP_ALREADY_EXISTS = 既にグループは存在します -GROUP_APPROVAL_DECIDED = グループの承認はすでに決定されています +GROUP_APPROVAL_DECIDED = 既にグループの承認は決定されています -GROUP_APPROVAL_NOT_REQUIRED = グループ承認は必要ありません +GROUP_APPROVAL_NOT_REQUIRED = グループ承認が不必要 GROUP_DOES_NOT_EXIST = グループが存在しません -GROUP_ID_MISMATCH = グループ ID の不一致 +GROUP_ID_MISMATCH = グループ ID が不一致 -GROUP_OWNER_CANNOT_LEAVE = グループ所有者はグループを脱退できません +GROUP_OWNER_CANNOT_LEAVE = グループ所有者はグループを退会出来ません HAVE_EQUALS_WANT = 持っている資産は欲しい資産と同じです INCORRECT_NONCE = 不正な PoW ナンス -INSUFFICIENT_FEE = 料金が不十分です +INSUFFICIENT_FEE = 手数料が不十分です INVALID_ADDRESS = 無効なアドレス @@ -66,9 +66,9 @@ INVALID_AMOUNT = 無効な金額 INVALID_ASSET_OWNER = 無効なアセット所有者 -INVALID_AT_TRANSACTION = 無効な AT トランザクション +INVALID_AT_TRANSACTION = 無効なATトランザクション -INVALID_AT_TYPE_LENGTH = AT の「タイプ」の長さが無効です +INVALID_AT_TYPE_LENGTH = 無効なATの「タイプ」の長さです INVALID_BUT_OK = 無効だがOK @@ -76,11 +76,11 @@ INVALID_CREATION_BYTES = 無効な作成バイト数 INVALID_DATA_LENGTH = 無効なデータ長 -INVALID_DESCRIPTION_LENGTH = 無効な説明の長さ +INVALID_DESCRIPTION_LENGTH = 無効な概要の長さ -INVALID_GROUP_APPROVAL_THRESHOLD = 無効なグループ承認しきい値 +INVALID_GROUP_APPROVAL_THRESHOLD = 無効なグループ承認のしきい値 -INVALID_GROUP_BLOCK_DELAY = 無効なグループ承認ブロックの遅延 +INVALID_GROUP_BLOCK_DELAY = 無効なグループ承認のブロック遅延 INVALID_GROUP_ID = 無効なグループ ID @@ -88,7 +88,7 @@ INVALID_GROUP_OWNER = 無効なグループ所有者 INVALID_LIFETIME = 無効な有効期間 -INVALID_NAME_LENGTH = 名前の長さが無効です +INVALID_NAME_LENGTH = 無効な名前の長さです INVALID_NAME_OWNER = 無効な名前の所有者 @@ -98,9 +98,9 @@ INVALID_OPTIONS_COUNT = 無効なオプションの数 INVALID_ORDER_CREATOR = 無効な注文作成者 -INVALID_PAYMENTS_COUNT = 無効な支払い数 +INVALID_PAYMENTS_COUNT = 無効な入出金数 -INVALID_PUBLIC_KEY = 無効な公開キー +INVALID_PUBLIC_KEY = 無効な公開鍵 INVALID_QUANTITY = 無効な数量 @@ -108,7 +108,7 @@ INVALID_REFERENCE = 無効な参照 INVALID_RETURN = 無効な返品 -INVALID_REWARD_SHARE_PERCENT = 無効な報酬分配率 +INVALID_REWARD_SHARE_PERCENT = 無効な報酬シェア率 INVALID_SELLER = 無効な販売者 @@ -120,19 +120,19 @@ INVALID_TX_GROUP_ID = 無効なトランザクション グループ ID INVALID_VALUE_LENGTH = 無効な「値」の長さ -INVITE_UNKNOWN = グループの招待が不明です +INVITE_UNKNOWN = 不明なグループ招待 -JOIN_REQUEST_EXISTS = グループ参加リクエストはすでに存在します +JOIN_REQUEST_EXISTS = 既にグループ参加リクエストが存在します -MAXIMUM_REWARD_SHARES = このアカウントの特典シェアはすでに最大数に達しています +MAXIMUM_REWARD_SHARES = 既にこのアカウントの報酬シェアは最大です MISSING_CREATOR = 作成者が見つかりません MULTIPLE_NAMES_FORBIDDEN = アカウントごとに複数の登録名は禁止されています -NAME_ALREADY_FOR_SALE = 名前はすでに販売中です +NAME_ALREADY_FOR_SALE = 既に名前は販売中です -NAME_ALREADY_REGISTERED = 名前はすでに登録されています +NAME_ALREADY_REGISTERED = 既に名前は登録されています NAME_BLOCKED = この名前はブロックされています @@ -142,11 +142,11 @@ NAME_NOT_FOR_SALE = 名前は非売品です NAME_NOT_NORMALIZED = 名前は Unicode の「正規化」形式ではありません -NEGATIVE_AMOUNT = 無効/マイナスの金額 +NEGATIVE_AMOUNT = 無効な/負の金額 -NEGATIVE_FEE = 無効/マイナスの料金 +NEGATIVE_FEE = 無効な/負の料金 -NEGATIVE_PRICE = 無効/マイナスの価格 +NEGATIVE_PRICE = 無効な/負の価格 NO_BALANCE = 残高が不足しています @@ -158,38 +158,38 @@ NOT_GROUP_ADMIN = アカウントはグループ管理者ではありません NOT_GROUP_MEMBER = アカウントはグループメンバーではありません -NOT_MINTING_ACCOUNT = アカウントはミントできません +NOT_MINTING_ACCOUNT = アカウントはミント出来ません NOT_YET_RELEASED = 機能はまだリリースされていません OK = OK -ORDER_ALREADY_CLOSED = 資産取引注文はすでに終了しています +ORDER_ALREADY_CLOSED = 既に資産取引注文は終了しています ORDER_DOES_NOT_EXIST = 資産取引注文が存在しません -POLL_ALREADY_EXISTS = ポーリングはすでに存在します +POLL_ALREADY_EXISTS = 既に投票は存在します POLL_DOES_NOT_EXIST = 投票は存在しません -POLL_OPTION_DOES_NOT_EXIST = ポーリング オプションが存在しません +POLL_OPTION_DOES_NOT_EXIST = 投票オプションが存在しません -PUBLIC_KEY_UNKNOWN = 公開鍵が不明です +PUBLIC_KEY_UNKNOWN = 不明な公開鍵 -REWARD_SHARE_UNKNOWN = 報酬シェアが不明 +REWARD_SHARE_UNKNOWN = 不明な報酬シェア -SELF_SHARE_EXISTS = セルフシェア (報酬シェア) はすでに存在します +SELF_SHARE_EXISTS = 既に自己シェア(報酬シェア)が存在します TIMESTAMP_TOO_NEW = タイムスタンプが新しすぎます TIMESTAMP_TOO_OLD = タイムスタンプが古すぎます -TOO_MANY_UNCONFIRMED = アカウントに保留中の未確認トランザクションが多すぎます +TOO_MANY_UNCONFIRMED = アカウントに保留中の未承認トランザクションが多すぎます -TRANSACTION_ALREADY_CONFIRMED = トランザクションはすでに確認されています +TRANSACTION_ALREADY_CONFIRMED = 既にトランザクションは承認されています -TRANSACTION_ALREADY_EXISTS = トランザクションはすでに存在します +TRANSACTION_ALREADY_EXISTS = 既にトランザクションは存在します -TRANSACTION_UNKNOWN = トランザクションが不明です +TRANSACTION_UNKNOWN = 不明なトランザクション -TX_GROUP_ID_MISMATCH = トランザクションのグループ ID が一致しません +TX_GROUP_ID_MISMATCH = トランザクションのグループIDが一致しません From 5928b54a33dc7f2082ae13b733d7afa1b6d3c51c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 17 Jun 2023 13:03:29 +0100 Subject: [PATCH 453/496] Added developer QDN proxy. This allows Q-Apps and websites to be developed and tested in real time, by proxying an existing webserver such as localhost:5173 from React/Vite. The proxy adds all QDN functionality to the existing server in real time. Needs UI integration before all features can be used. --- .../java/org/qortal/api/DevProxyService.java | 173 ++++++++++++++++++ .../resource/DevProxyServerResource.java | 142 ++++++++++++++ .../api/resource/DeveloperResource.java | 96 ++++++++++ .../qortal/controller/DevProxyManager.java | 74 ++++++++ .../java/org/qortal/settings/Settings.java | 20 ++ 5 files changed, 505 insertions(+) create mode 100644 src/main/java/org/qortal/api/DevProxyService.java create mode 100644 src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java create mode 100644 src/main/java/org/qortal/api/resource/DeveloperResource.java create mode 100644 src/main/java/org/qortal/controller/DevProxyManager.java diff --git a/src/main/java/org/qortal/api/DevProxyService.java b/src/main/java/org/qortal/api/DevProxyService.java new file mode 100644 index 00000000..e0bf02db --- /dev/null +++ b/src/main/java/org/qortal/api/DevProxyService.java @@ -0,0 +1,173 @@ +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.server.*; +import org.eclipse.jetty.server.handler.ErrorHandler; +import org.eclipse.jetty.server.handler.InetAccessHandler; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.servlets.CrossOriginFilter; +import org.eclipse.jetty.util.ssl.SslContextFactory; +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.repository.DataException; +import org.qortal.settings.Settings; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.SecureRandom; + +public class DevProxyService { + + private static DevProxyService instance; + + private final ResourceConfig config; + private Server server; + + private DevProxyService() { + this.config = new ResourceConfig(); + this.config.packages("org.qortal.api.proxy.resource", "org.qortal.api.resource"); + this.config.register(OpenApiResource.class); + this.config.register(ApiDefinition.class); + this.config.register(AnnotationPostProcessor.class); + } + + public static DevProxyService getInstance() { + if (instance == null) + instance = new DevProxyService(); + + return instance; + } + + public Iterable> getResources() { + return this.config.getClasses(); + } + + public void start() throws DataException { + try { + // Create API server + + // SSL support if requested + String keystorePathname = Settings.getInstance().getSslKeystorePathname(); + String keystorePassword = Settings.getInstance().getSslKeystorePassword(); + + if (keystorePathname != null && keystorePassword != null) { + // SSL version + if (!Files.isReadable(Path.of(keystorePathname))) + throw new RuntimeException("Failed to start SSL API due to broken keystore"); + + // BouncyCastle-specific SSLContext build + SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE"); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE"); + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC"); + + try (InputStream keystoreStream = Files.newInputStream(Paths.get(keystorePathname))) { + keyStore.load(keystoreStream, keystorePassword.toCharArray()); + } + + keyManagerFactory.init(keyStore, keystorePassword.toCharArray()); + sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom()); + + SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); + sslContextFactory.setSslContext(sslContext); + + this.server = new Server(); + + HttpConfiguration httpConfig = new HttpConfiguration(); + httpConfig.setSecureScheme("https"); + httpConfig.setSecurePort(Settings.getInstance().getDevProxyPort()); + + SecureRequestCustomizer src = new SecureRequestCustomizer(); + httpConfig.addCustomizer(src); + + HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(httpConfig); + SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()); + + ServerConnector portUnifiedConnector = new ServerConnector(this.server, + new DetectorConnectionFactory(sslConnectionFactory), + httpConnectionFactory); + portUnifiedConnector.setHost(Network.getInstance().getBindAddress()); + portUnifiedConnector.setPort(Settings.getInstance().getDevProxyPort()); + + this.server.addConnector(portUnifiedConnector); + } else { + // Non-SSL + InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress()); + InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDevProxyPort()); + this.server = new Server(endpoint); + } + + // Error handler + ErrorHandler errorHandler = new ApiErrorHandler(); + this.server.setErrorHandler(errorHandler); + + // Request logging + if (Settings.getInstance().isDevProxyLoggingEnabled()) { + RequestLogWriter logWriter = new RequestLogWriter("devproxy-requests.log"); + logWriter.setAppend(true); + logWriter.setTimeZone("UTC"); + RequestLog requestLog = new CustomRequestLog(logWriter, CustomRequestLog.EXTENDED_NCSA_FORMAT); + this.server.setRequestLog(requestLog); + } + + // Access handler (currently no whitelist is used) + InetAccessHandler accessHandler = new InetAccessHandler(); + this.server.setHandler(accessHandler); + + // URL rewriting + RewriteHandler rewriteHandler = new RewriteHandler(); + accessHandler.setHandler(rewriteHandler); + + // Context + ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); + context.setContextPath("/"); + rewriteHandler.setHandler(context); + + // Cross-origin resource sharing + FilterHolder corsFilterHolder = new FilterHolder(CrossOriginFilter.class); + corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, "*"); + corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_METHODS_PARAM, "GET, POST, DELETE"); + corsFilterHolder.setInitParameter(CrossOriginFilter.CHAIN_PREFLIGHT_PARAM, "false"); + context.addFilter(corsFilterHolder, "/*", null); + + // API servlet + ServletContainer container = new ServletContainer(this.config); + ServletHolder apiServlet = new ServletHolder(container); + apiServlet.setInitOrder(1); + context.addServlet(apiServlet, "/*"); + + // Start server + this.server.start(); + } catch (Exception e) { + // Failed to start + throw new DataException("Failed to start developer proxy", e); + } + } + + public void stop() { + try { + // Stop server + this.server.stop(); + } catch (Exception e) { + // Failed to stop + } + + this.server = null; + instance = null; + } + +} diff --git a/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java new file mode 100644 index 00000000..d51e6852 --- /dev/null +++ b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java @@ -0,0 +1,142 @@ +package org.qortal.api.proxy.resource; + +import org.qortal.api.ApiError; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.HTMLParser; +import org.qortal.arbitrary.misc.Service; +import org.qortal.controller.DevProxyManager; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Context; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Enumeration; + + +@Path("/") +public class DevProxyServerResource { + + @Context HttpServletRequest request; + @Context HttpServletResponse response; + @Context ServletContext context; + + + @GET + public HttpServletResponse getProxyIndex() { + return this.proxy("/"); + } + + @GET + @Path("{path:.*}") + public HttpServletResponse getProxyPath(@PathParam("path") String inPath) { + return this.proxy(inPath); + } + + private HttpServletResponse proxy(String inPath) { + try { + String source = DevProxyManager.getInstance().getSourceHostAndPort(); + + // Convert localhost / 127.0.0.1 to IPv6 [::1] + if (source.startsWith("localhost") || source.startsWith("127.0.0.1")) { + int port = 80; + String[] parts = source.split(":"); + if (parts.length > 1) { + port = Integer.parseInt(parts[1]); + } + source = String.format("[::1]:%d", port); + } + + if (!inPath.startsWith("/")) { + inPath = "/" + inPath; + } + + String queryString = request.getQueryString() != null ? "?" + request.getQueryString() : ""; + + // Open URL + String urlString = String.format("http://%s%s%s", source, inPath, queryString); + URL url = new URL(urlString); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + con.setRequestMethod(request.getMethod()); + + // Proxy the request headers + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + String headerValue = request.getHeader(headerName); + con.setRequestProperty(headerName, headerValue); + } + + // TODO: proxy any POST parameters from "request" to "con" + + // Proxy the response code + int responseCode = con.getResponseCode(); + response.setStatus(responseCode); + + // Proxy the response headers + for (int i = 0; ; i++) { + String headerKey = con.getHeaderFieldKey(i); + String headerValue = con.getHeaderField(i); + if (headerKey != null && headerValue != null) { + response.addHeader(headerKey, headerValue); + continue; + } + break; + } + + // Read the response body + InputStream inputStream = con.getInputStream(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + byte[] data = outputStream.toByteArray(); // TODO: limit file size that can be read into memory + + // Close the streams + outputStream.close(); + inputStream.close(); + + // Extract filename + String filename = ""; + if (inPath.contains("/")) { + String[] parts = inPath.split("/"); + if (parts.length > 0) { + filename = parts[parts.length - 1]; + } + } + + // Parse and modify output if needed + if (HTMLParser.isHtmlFile(filename)) { + // HTML file - needs to be parsed + HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, "light", true); + htmlParser.addAdditionalHeaderTags(); + response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' ws:; font-src 'self' data:;"); + response.setContentType(con.getContentType()); + response.setContentLength(htmlParser.getData().length); + response.getOutputStream().write(htmlParser.getData()); + } + else { + // Regular file - can be streamed directly + response.addHeader("Content-Security-Policy", "default-src 'self'"); + response.setContentType(con.getContentType()); + response.setContentLength(data.length); + response.getOutputStream().write(data); + } + + } catch (IOException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage()); + } + + return response; + } + +} diff --git a/src/main/java/org/qortal/api/resource/DeveloperResource.java b/src/main/java/org/qortal/api/resource/DeveloperResource.java new file mode 100644 index 00000000..ba534502 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/DeveloperResource.java @@ -0,0 +1,96 @@ +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.controller.DevProxyManager; +import org.qortal.repository.DataException; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + + +@Path("/developer") +@Tag(name = "Developer Tools") +public class DeveloperResource { + + @Context HttpServletRequest request; + @Context HttpServletResponse response; + @Context ServletContext context; + + + @POST + @Path("/proxy/start") + @Operation( + summary = "Start proxy server, for real time QDN app/website development", + requestBody = @RequestBody( + description = "Host and port of source webserver to be proxied", + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + example = "127.0.0.1:5173" + ) + ) + ), + responses = { + @ApiResponse( + description = "Port number of running server", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA}) + public Integer startProxy(String sourceHostAndPort) { + // TODO: API key + DevProxyManager devProxyManager = DevProxyManager.getInstance(); + try { + devProxyManager.setSourceHostAndPort(sourceHostAndPort); + devProxyManager.start(); + return devProxyManager.getPort(); + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage()); + } + } + + @POST + @Path("/proxy/stop") + @Operation( + summary = "Stop proxy server", + responses = { + @ApiResponse( + description = "true if stopped", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "boolean" + ) + ) + ) + } + ) + public boolean stopProxy() { + DevProxyManager devProxyManager = DevProxyManager.getInstance(); + devProxyManager.stop(); + return !devProxyManager.isRunning(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/controller/DevProxyManager.java b/src/main/java/org/qortal/controller/DevProxyManager.java new file mode 100644 index 00000000..a04e87ac --- /dev/null +++ b/src/main/java/org/qortal/controller/DevProxyManager.java @@ -0,0 +1,74 @@ +package org.qortal.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.api.DevProxyService; +import org.qortal.repository.DataException; +import org.qortal.settings.Settings; + +public class DevProxyManager { + + protected static final Logger LOGGER = LogManager.getLogger(DevProxyManager.class); + + private static DevProxyManager instance; + + private boolean running = false; + + private String sourceHostAndPort = "127.0.0.1:5173"; // Default for React/Vite + + private DevProxyManager() { + + } + + public static DevProxyManager getInstance() { + if (instance == null) + instance = new DevProxyManager(); + + return instance; + } + + public void start() throws DataException { + synchronized(this) { + if (this.running) { + // Already running + return; + } + + LOGGER.info(String.format("Starting developer proxy service on port %d", Settings.getInstance().getDevProxyPort())); + DevProxyService devProxyService = DevProxyService.getInstance(); + devProxyService.start(); + this.running = true; + } + } + + public void stop() { + synchronized(this) { + if (!this.running) { + // Not running + return; + } + + LOGGER.info(String.format("Shutting down developer proxy service")); + DevProxyService devProxyService = DevProxyService.getInstance(); + devProxyService.stop(); + this.running = false; + } + } + + public void setSourceHostAndPort(String sourceHostAndPort) { + this.sourceHostAndPort = sourceHostAndPort; + } + + public String getSourceHostAndPort() { + return this.sourceHostAndPort; + } + + public Integer getPort() { + return Settings.getInstance().getDevProxyPort(); + } + + public boolean isRunning() { + return this.running; + } + +} diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index cce3f441..c3d5a0c8 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -47,6 +47,9 @@ public class Settings { private static final int MAINNET_GATEWAY_PORT = 80; private static final int TESTNET_GATEWAY_PORT = 8080; + private static final int MAINNET_DEV_PROXY_PORT = 12393; + private static final int TESTNET_DEV_PROXY_PORT = 62393; + private static final Logger LOGGER = LogManager.getLogger(Settings.class); private static final String SETTINGS_FILENAME = "settings.json"; @@ -107,6 +110,11 @@ public class Settings { private boolean gatewayLoggingEnabled = false; private boolean gatewayLoopbackEnabled = false; + // Developer Proxy + private Integer devProxyPort; + private boolean devProxyLoggingEnabled = false; + + // Specific to this node private boolean wipeUnconfirmedOnStart = false; /** Maximum number of unconfirmed transactions allowed per account */ @@ -649,6 +657,18 @@ public class Settings { } + public int getDevProxyPort() { + if (this.devProxyPort != null) + return this.devProxyPort; + + return this.isTestNet ? TESTNET_DEV_PROXY_PORT : MAINNET_DEV_PROXY_PORT; + } + + public boolean isDevProxyLoggingEnabled() { + return this.devProxyLoggingEnabled; + } + + public boolean getWipeUnconfirmedOnStart() { return this.wipeUnconfirmedOnStart; } From d628b3ab2a303feee7a03a85240a853c3135822c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 17 Jun 2023 13:04:46 +0100 Subject: [PATCH 454/496] Renamed the "usePrefix" parameter to "includeResourceIdInPrefix", and slightly modified its functionality. --- src/main/java/org/qortal/api/HTMLParser.java | 10 +++++----- .../api/domainmap/resource/DomainMapResource.java | 4 ++-- .../qortal/api/gateway/resource/GatewayResource.java | 4 ++-- .../qortal/api/restricted/resource/RenderResource.java | 4 ++-- .../org/qortal/arbitrary/ArbitraryDataRenderer.java | 8 ++++---- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java index cc3102e8..f1794594 100644 --- a/src/main/java/org/qortal/api/HTMLParser.java +++ b/src/main/java/org/qortal/api/HTMLParser.java @@ -24,11 +24,11 @@ public class HTMLParser { private String theme; private boolean usingCustomRouting; - public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data, + public HTMLParser(String resourceId, String inPath, String prefix, boolean includeResourceIdInPrefix, byte[] data, String qdnContext, Service service, String identifier, String theme, boolean usingCustomRouting) { - String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : ""; - this.qdnBase = usePrefix ? String.format("%s/%s", prefix, resourceId) : ""; - this.qdnBaseWithPath = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; + String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : String.format("/%s",inPath); + this.qdnBase = includeResourceIdInPrefix ? String.format("%s/%s", prefix, resourceId) : prefix; + this.qdnBaseWithPath = includeResourceIdInPrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : String.format("%s%s", prefix, inPathWithoutFilename); this.data = data; this.qdnContext = qdnContext; this.resourceId = resourceId; @@ -82,7 +82,7 @@ public class HTMLParser { } public static boolean isHtmlFile(String path) { - if (path.endsWith(".html") || path.endsWith(".htm")) { + if (path.endsWith(".html") || path.endsWith(".htm") || path.equals("")) { return true; } return false; 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 4cb9f8e5..019fb753 100644 --- a/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java +++ b/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java @@ -48,10 +48,10 @@ public class DomainMapResource { } private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, - String inPath, String secret58, String prefix, boolean usePrefix, boolean async) { + String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async) { ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath, - secret58, prefix, usePrefix, async, "domainMap", request, response, context); + secret58, prefix, includeResourceIdInPrefix, 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 9c77753f..5d056f30 100644 --- a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java +++ b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java @@ -90,7 +90,7 @@ public class GatewayResource { } - private HttpServletResponse parsePath(String inPath, String qdnContext, String secret58, boolean usePrefix, boolean async) { + private HttpServletResponse parsePath(String inPath, String qdnContext, String secret58, boolean includeResourceIdInPrefix, boolean async) { if (inPath == null || inPath.equals("")) { // Assume not a real file @@ -157,7 +157,7 @@ public class GatewayResource { } ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(name, ResourceIdType.NAME, service, identifier, outPath, - secret58, prefix, usePrefix, async, qdnContext, request, response, context); + secret58, prefix, includeResourceIdInPrefix, async, qdnContext, 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 7a772f9f..92f72032 100644 --- a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java @@ -157,10 +157,10 @@ public class RenderResource { private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, - String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String theme) { + String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String theme) { ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath, - secret58, prefix, usePrefix, async, "render", request, response, context); + secret58, prefix, includeResourceIdInPrefix, 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 089a99ca..704533c8 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -40,7 +40,7 @@ public class ArbitraryDataRenderer { private String inPath; private final String secret58; private final String prefix; - private final boolean usePrefix; + private final boolean includeResourceIdInPrefix; private final boolean async; private final String qdnContext; private final HttpServletRequest request; @@ -48,7 +48,7 @@ public class ArbitraryDataRenderer { private final ServletContext context; public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String identifier, - String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String qdnContext, + String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String qdnContext, HttpServletRequest request, HttpServletResponse response, ServletContext context) { this.resourceId = resourceId; @@ -58,7 +58,7 @@ public class ArbitraryDataRenderer { this.inPath = inPath; this.secret58 = secret58; this.prefix = prefix; - this.usePrefix = usePrefix; + this.includeResourceIdInPrefix = includeResourceIdInPrefix; this.async = async; this.qdnContext = qdnContext; this.request = request; @@ -159,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, usingCustomRouting); + HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, includeResourceIdInPrefix, 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 fd8d720946a5f7e538699ce0a446631c5daf2638 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 23 Jun 2023 13:30:10 +0100 Subject: [PATCH 455/496] Added support for group encryption in service validation. --- .../org/qortal/arbitrary/misc/Service.java | 5 +++-- .../test/arbitrary/ArbitraryServiceTests.java | 19 +++++++++++++++++++ 2 files changed, 22 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 94ca9252..2b8f8d02 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -186,6 +186,7 @@ public enum Service { private static final ObjectMapper objectMapper = new ObjectMapper(); private static final String encryptedDataPrefix = "qortalEncryptedData"; + private static final String encryptedGroupDataPrefix = "qortalGroupEncryptedData"; Service(int value, boolean requiresValidation, Long maxSize, boolean single, boolean isPrivate, List requiredKeys) { this.value = value; @@ -221,10 +222,10 @@ public enum Service { // Validate private data for single file resources if (this.single) { String dataString = new String(data, StandardCharsets.UTF_8); - if (this.isPrivate && !dataString.startsWith(encryptedDataPrefix)) { + if (this.isPrivate && !dataString.startsWith(encryptedDataPrefix) && !dataString.startsWith(encryptedGroupDataPrefix)) { return ValidationResult.DATA_NOT_ENCRYPTED; } - if (!this.isPrivate && dataString.startsWith(encryptedDataPrefix)) { + if (!this.isPrivate && (dataString.startsWith(encryptedDataPrefix) || dataString.startsWith(encryptedGroupDataPrefix))) { return ValidationResult.DATA_ENCRYPTED; } } diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index 33632b4a..b4c10fac 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -456,6 +456,25 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.OK, service.validate(filePath)); } + @Test + public void testValidPrivateGroupData() throws IOException { + String dataString = "qortalGroupEncryptedDatabMx4fELNTV+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="; From cc8cdcd93af34eddd8dc4f738df230b17f897413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrg=20Schulthess?= Date: Mon, 3 Jul 2023 09:52:07 +0200 Subject: [PATCH 456/496] Fix website sub-folder rendering 404 --- .../java/org/qortal/arbitrary/ArbitraryDataRenderer.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 704533c8..5c6cda63 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -127,6 +127,11 @@ public class ArbitraryDataRenderer { String filename = this.getFilename(unzippedPath, inPath); Path filePath = Paths.get(unzippedPath, filename); boolean usingCustomRouting = false; + if (Files.isDirectory(filePath) && (!inPath.endsWith("/"))) { + inPath = inPath + "/"; + filename = this.getFilename(unzippedPath, inPath); + filePath = Paths.get(unzippedPath, filename); + } // If the file doesn't exist, we may need to route the request elsewhere, or cleanup if (!Files.exists(filePath)) { From c14fca5660c5dc0b833075ef7e978a919965b19d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 8 Jul 2023 11:05:14 +0100 Subject: [PATCH 457/496] Improved filtering of online accounts data. --- .../org/qortal/controller/OnlineAccountsManager.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 224228b8..5e64161d 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -743,8 +743,14 @@ public class OnlineAccountsManager { if (onlineAccounts == null) onlineAccounts = this.latestBlocksOnlineAccounts.get(timestamp); - if (onlineAccounts != null) - blocksOnlineAccounts.removeAll(onlineAccounts); + if (onlineAccounts != null) { + // Remove accounts with matching timestamp, nonce, and public key + final Set finalOnlineAccounts = onlineAccounts; + blocksOnlineAccounts.removeIf(a1 -> finalOnlineAccounts.stream() + .anyMatch(a2 -> a2.getTimestamp() == a1.getTimestamp() && + Objects.equals(a2.getNonce(), a1.getNonce()) && + Arrays.equals(a2.getPublicKey(), a1.getPublicKey()))); + } } /** From fe999a11f4c801dd3713d74b27659c7cbe4bae09 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 1 Jul 2023 14:01:08 +0100 Subject: [PATCH 458/496] Include CANCEL_SELL_NAME transactions when performing a complete rebuild of names. --- .../controller/repository/NamesDatabaseIntegrityCheck.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java index 99eaf105..698ad487 100644 --- a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java +++ b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java @@ -25,7 +25,8 @@ public class NamesDatabaseIntegrityCheck { TransactionType.REGISTER_NAME, TransactionType.UPDATE_NAME, TransactionType.BUY_NAME, - TransactionType.SELL_NAME + TransactionType.SELL_NAME, + TransactionType.CANCEL_SELL_NAME ); private List nameTransactions = new ArrayList<>(); From 5f86ecafd9e36d8037d0539794efc9af1efe24c3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 9 Jul 2023 12:35:46 +0100 Subject: [PATCH 459/496] Refactored developer proxy, and modified IPv6 fallback so that it only occurs on a connection failure. --- .../resource/DevProxyServerResource.java | 170 ++++++++++-------- 1 file changed, 96 insertions(+), 74 deletions(-) diff --git a/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java index d51e6852..7972c551 100644 --- a/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java +++ b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java @@ -16,7 +16,9 @@ import javax.ws.rs.core.Context; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.net.ConnectException; import java.net.HttpURLConnection; +import java.net.ProtocolException; import java.net.URL; import java.util.Enumeration; @@ -44,16 +46,6 @@ public class DevProxyServerResource { try { String source = DevProxyManager.getInstance().getSourceHostAndPort(); - // Convert localhost / 127.0.0.1 to IPv6 [::1] - if (source.startsWith("localhost") || source.startsWith("127.0.0.1")) { - int port = 80; - String[] parts = source.split(":"); - if (parts.length > 1) { - port = Integer.parseInt(parts[1]); - } - source = String.format("[::1]:%d", port); - } - if (!inPath.startsWith("/")) { inPath = "/" + inPath; } @@ -61,76 +53,37 @@ public class DevProxyServerResource { String queryString = request.getQueryString() != null ? "?" + request.getQueryString() : ""; // Open URL - String urlString = String.format("http://%s%s%s", source, inPath, queryString); - URL url = new URL(urlString); - HttpURLConnection con = (HttpURLConnection) url.openConnection(); - con.setRequestMethod(request.getMethod()); + URL url = new URL(String.format("http://%s%s%s", source, inPath, queryString)); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); - // Proxy the request headers - Enumeration headerNames = request.getHeaderNames(); - while (headerNames.hasMoreElements()) { - String headerName = headerNames.nextElement(); - String headerValue = request.getHeader(headerName); - con.setRequestProperty(headerName, headerValue); + // Proxy the request data + this.proxyRequestToConnection(request, con); + + try { + // Make the request and proxy the response code + response.setStatus(con.getResponseCode()); } + catch (ConnectException e) { - // TODO: proxy any POST parameters from "request" to "con" - - // Proxy the response code - int responseCode = con.getResponseCode(); - response.setStatus(responseCode); - - // Proxy the response headers - for (int i = 0; ; i++) { - String headerKey = con.getHeaderFieldKey(i); - String headerValue = con.getHeaderField(i); - if (headerKey != null && headerValue != null) { - response.addHeader(headerKey, headerValue); - continue; + // Tey converting localhost / 127.0.0.1 to IPv6 [::1] + if (source.startsWith("localhost") || source.startsWith("127.0.0.1")) { + int port = 80; + String[] parts = source.split(":"); + if (parts.length > 1) { + port = Integer.parseInt(parts[1]); + } + source = String.format("[::1]:%d", port); } - break; + + // Retry connection + url = new URL(String.format("http://%s%s%s", source, inPath, queryString)); + con = (HttpURLConnection) url.openConnection(); + this.proxyRequestToConnection(request, con); + response.setStatus(con.getResponseCode()); } - // Read the response body - InputStream inputStream = con.getInputStream(); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - byte[] buffer = new byte[4096]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - } - byte[] data = outputStream.toByteArray(); // TODO: limit file size that can be read into memory - - // Close the streams - outputStream.close(); - inputStream.close(); - - // Extract filename - String filename = ""; - if (inPath.contains("/")) { - String[] parts = inPath.split("/"); - if (parts.length > 0) { - filename = parts[parts.length - 1]; - } - } - - // Parse and modify output if needed - if (HTMLParser.isHtmlFile(filename)) { - // HTML file - needs to be parsed - HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, "light", true); - htmlParser.addAdditionalHeaderTags(); - response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' ws:; font-src 'self' data:;"); - response.setContentType(con.getContentType()); - response.setContentLength(htmlParser.getData().length); - response.getOutputStream().write(htmlParser.getData()); - } - else { - // Regular file - can be streamed directly - response.addHeader("Content-Security-Policy", "default-src 'self'"); - response.setContentType(con.getContentType()); - response.setContentLength(data.length); - response.getOutputStream().write(data); - } + // Proxy the response data back to the caller + this.proxyConnectionToResponse(con, response, inPath); } catch (IOException e) { throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage()); @@ -139,4 +92,73 @@ public class DevProxyServerResource { return response; } + private void proxyRequestToConnection(HttpServletRequest request, HttpURLConnection con) throws ProtocolException { + // Proxy the request method + con.setRequestMethod(request.getMethod()); + + // Proxy the request headers + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + String headerValue = request.getHeader(headerName); + con.setRequestProperty(headerName, headerValue); + } + + // TODO: proxy any POST parameters from "request" to "con" + } + + private void proxyConnectionToResponse(HttpURLConnection con, HttpServletResponse response, String inPath) throws IOException { + // Proxy the response headers + for (int i = 0; ; i++) { + String headerKey = con.getHeaderFieldKey(i); + String headerValue = con.getHeaderField(i); + if (headerKey != null && headerValue != null) { + response.addHeader(headerKey, headerValue); + continue; + } + break; + } + + // Read the response body + InputStream inputStream = con.getInputStream(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + byte[] data = outputStream.toByteArray(); // TODO: limit file size that can be read into memory + + // Close the streams + outputStream.close(); + inputStream.close(); + + // Extract filename + String filename = ""; + if (inPath.contains("/")) { + String[] parts = inPath.split("/"); + if (parts.length > 0) { + filename = parts[parts.length - 1]; + } + } + + // Parse and modify output if needed + if (HTMLParser.isHtmlFile(filename)) { + // HTML file - needs to be parsed + HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, "light", true); + htmlParser.addAdditionalHeaderTags(); + response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' ws:; font-src 'self' data:;"); + response.setContentType(con.getContentType()); + response.setContentLength(htmlParser.getData().length); + response.getOutputStream().write(htmlParser.getData()); + } + else { + // Regular file - can be streamed directly + response.addHeader("Content-Security-Policy", "default-src 'self'"); + response.setContentType(con.getContentType()); + response.setContentLength(data.length); + response.getOutputStream().write(data); + } + } + } From 62908f867a322266b22057004735680487785939 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 16 Jul 2023 19:09:08 +0100 Subject: [PATCH 460/496] Bump version to 4.2.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 7c7ac147..f236761e 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.1.3 + 4.2.0 jar true From 29dcd5300233636df0ddd52cb6c63e63caf6ba25 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 16 Jul 2023 20:04:45 +0100 Subject: [PATCH 461/496] Revert "Improved filtering of online accounts data." This reverts commit c14fca5660c5dc0b833075ef7e978a919965b19d. --- .../org/qortal/controller/OnlineAccountsManager.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 5e64161d..224228b8 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -743,14 +743,8 @@ public class OnlineAccountsManager { if (onlineAccounts == null) onlineAccounts = this.latestBlocksOnlineAccounts.get(timestamp); - if (onlineAccounts != null) { - // Remove accounts with matching timestamp, nonce, and public key - final Set finalOnlineAccounts = onlineAccounts; - blocksOnlineAccounts.removeIf(a1 -> finalOnlineAccounts.stream() - .anyMatch(a2 -> a2.getTimestamp() == a1.getTimestamp() && - Objects.equals(a2.getNonce(), a1.getNonce()) && - Arrays.equals(a2.getPublicKey(), a1.getPublicKey()))); - } + if (onlineAccounts != null) + blocksOnlineAccounts.removeAll(onlineAccounts); } /** From 8ae7a1d65be525bdd864806d8470030534ccde9e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Jul 2023 14:28:47 +0100 Subject: [PATCH 462/496] Removed (Get)OnlineAccountsV1 and V2, as these are no longer used. --- .../org/qortal/controller/Controller.java | 7 -- .../message/GetOnlineAccountsMessage.java | 69 ----------- .../message/GetOnlineAccountsV2Message.java | 109 ----------------- .../qortal/network/message/MessageType.java | 6 +- .../message/OnlineAccountsMessage.java | 75 ------------ .../message/OnlineAccountsV2Message.java | 113 ------------------ .../test/network/OnlineAccountsTests.java | 83 ------------- .../test/network/OnlineAccountsV3Tests.java | 35 ------ 8 files changed, 1 insertion(+), 496 deletions(-) delete mode 100644 src/main/java/org/qortal/network/message/GetOnlineAccountsMessage.java delete mode 100644 src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java delete mode 100644 src/main/java/org/qortal/network/message/OnlineAccountsMessage.java delete mode 100644 src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index e1e90486..2cab24ec 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1278,13 +1278,6 @@ public class Controller extends Thread { TransactionImporter.getInstance().onNetworkTransactionSignaturesMessage(peer, message); break; - case GET_ONLINE_ACCOUNTS: - case ONLINE_ACCOUNTS: - case GET_ONLINE_ACCOUNTS_V2: - case ONLINE_ACCOUNTS_V2: - // No longer supported - to be eventually removed - break; - case GET_ONLINE_ACCOUNTS_V3: OnlineAccountsManager.getInstance().onNetworkGetOnlineAccountsV3Message(peer, message); break; diff --git a/src/main/java/org/qortal/network/message/GetOnlineAccountsMessage.java b/src/main/java/org/qortal/network/message/GetOnlineAccountsMessage.java deleted file mode 100644 index ae98cf40..00000000 --- a/src/main/java/org/qortal/network/message/GetOnlineAccountsMessage.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.qortal.network.message; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -import org.qortal.data.network.OnlineAccountData; -import org.qortal.transform.Transformer; - -import com.google.common.primitives.Ints; -import com.google.common.primitives.Longs; - -public class GetOnlineAccountsMessage extends Message { - private static final int MAX_ACCOUNT_COUNT = 5000; - - private List onlineAccounts; - - public GetOnlineAccountsMessage(List onlineAccounts) { - super(MessageType.GET_ONLINE_ACCOUNTS); - - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - try { - bytes.write(Ints.toByteArray(onlineAccounts.size())); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - bytes.write(Longs.toByteArray(onlineAccountData.getTimestamp())); - - bytes.write(onlineAccountData.getPublicKey()); - } - } catch (IOException e) { - throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); - } - - this.dataBytes = bytes.toByteArray(); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - } - - private GetOnlineAccountsMessage(int id, List onlineAccounts) { - super(id, MessageType.GET_ONLINE_ACCOUNTS); - - this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList()); - } - - public List getOnlineAccounts() { - return this.onlineAccounts; - } - - public static Message fromByteBuffer(int id, ByteBuffer bytes) { - final int accountCount = bytes.getInt(); - - List onlineAccounts = new ArrayList<>(accountCount); - - for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) { - long timestamp = bytes.getLong(); - - byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - bytes.get(publicKey); - - onlineAccounts.add(new OnlineAccountData(timestamp, null, publicKey)); - } - - return new GetOnlineAccountsMessage(id, onlineAccounts); - } - -} diff --git a/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java b/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java deleted file mode 100644 index fe6b5d72..00000000 --- a/src/main/java/org/qortal/network/message/GetOnlineAccountsV2Message.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.qortal.network.message; - -import com.google.common.primitives.Ints; -import com.google.common.primitives.Longs; -import org.qortal.data.network.OnlineAccountData; -import org.qortal.transform.Transformer; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * For requesting online accounts info from remote peer, given our list of online accounts. - * - * Different format to V1: - * V1 is: number of entries, then timestamp + pubkey for each entry - * V2 is: groups of: number of entries, timestamp, then pubkey for each entry - * - * Also V2 only builds online accounts message once! - */ -public class GetOnlineAccountsV2Message extends Message { - - private List onlineAccounts; - - public GetOnlineAccountsV2Message(List onlineAccounts) { - super(MessageType.GET_ONLINE_ACCOUNTS_V2); - - // If we don't have ANY online accounts then it's an easier construction... - if (onlineAccounts.isEmpty()) { - // Always supply a number of accounts - this.dataBytes = Ints.toByteArray(0); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - return; - } - - // How many of each timestamp - Map countByTimestamp = new HashMap<>(); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - Long timestamp = onlineAccountData.getTimestamp(); - countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); - } - - // We should know exactly how many bytes to allocate now - int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) - + onlineAccounts.size() * Transformer.PUBLIC_KEY_LENGTH; - - ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); - - try { - for (long timestamp : countByTimestamp.keySet()) { - bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); - - bytes.write(Longs.toByteArray(timestamp)); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - if (onlineAccountData.getTimestamp() == timestamp) - bytes.write(onlineAccountData.getPublicKey()); - } - } - } catch (IOException e) { - throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); - } - - this.dataBytes = bytes.toByteArray(); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - } - - private GetOnlineAccountsV2Message(int id, List onlineAccounts) { - super(id, MessageType.GET_ONLINE_ACCOUNTS_V2); - - this.onlineAccounts = onlineAccounts; - } - - public List getOnlineAccounts() { - return this.onlineAccounts; - } - - public static Message fromByteBuffer(int id, ByteBuffer bytes) { - int accountCount = bytes.getInt(); - - List onlineAccounts = new ArrayList<>(accountCount); - - while (accountCount > 0) { - long timestamp = bytes.getLong(); - - for (int i = 0; i < accountCount; ++i) { - byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - bytes.get(publicKey); - - onlineAccounts.add(new OnlineAccountData(timestamp, null, publicKey)); - } - - if (bytes.hasRemaining()) { - accountCount = bytes.getInt(); - } else { - // we've finished - accountCount = 0; - } - } - - return new GetOnlineAccountsV2Message(id, onlineAccounts); - } - -} diff --git a/src/main/java/org/qortal/network/message/MessageType.java b/src/main/java/org/qortal/network/message/MessageType.java index 4dd4a3c8..6b420e2d 100644 --- a/src/main/java/org/qortal/network/message/MessageType.java +++ b/src/main/java/org/qortal/network/message/MessageType.java @@ -43,11 +43,7 @@ public enum MessageType { BLOCK_SUMMARIES(70, BlockSummariesMessage::fromByteBuffer), GET_BLOCK_SUMMARIES(71, GetBlockSummariesMessage::fromByteBuffer), BLOCK_SUMMARIES_V2(72, BlockSummariesV2Message::fromByteBuffer), - - ONLINE_ACCOUNTS(80, OnlineAccountsMessage::fromByteBuffer), - GET_ONLINE_ACCOUNTS(81, GetOnlineAccountsMessage::fromByteBuffer), - ONLINE_ACCOUNTS_V2(82, OnlineAccountsV2Message::fromByteBuffer), - GET_ONLINE_ACCOUNTS_V2(83, GetOnlineAccountsV2Message::fromByteBuffer), + ONLINE_ACCOUNTS_V3(84, OnlineAccountsV3Message::fromByteBuffer), GET_ONLINE_ACCOUNTS_V3(85, GetOnlineAccountsV3Message::fromByteBuffer), diff --git a/src/main/java/org/qortal/network/message/OnlineAccountsMessage.java b/src/main/java/org/qortal/network/message/OnlineAccountsMessage.java deleted file mode 100644 index e7e4c32c..00000000 --- a/src/main/java/org/qortal/network/message/OnlineAccountsMessage.java +++ /dev/null @@ -1,75 +0,0 @@ -package org.qortal.network.message; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -import org.qortal.data.network.OnlineAccountData; -import org.qortal.transform.Transformer; - -import com.google.common.primitives.Ints; -import com.google.common.primitives.Longs; - -public class OnlineAccountsMessage extends Message { - private static final int MAX_ACCOUNT_COUNT = 5000; - - private List onlineAccounts; - - public OnlineAccountsMessage(List onlineAccounts) { - super(MessageType.ONLINE_ACCOUNTS); - - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - try { - bytes.write(Ints.toByteArray(onlineAccounts.size())); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - bytes.write(Longs.toByteArray(onlineAccountData.getTimestamp())); - - bytes.write(onlineAccountData.getSignature()); - - bytes.write(onlineAccountData.getPublicKey()); - } - } catch (IOException e) { - throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); - } - - this.dataBytes = bytes.toByteArray(); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - } - - private OnlineAccountsMessage(int id, List onlineAccounts) { - super(id, MessageType.ONLINE_ACCOUNTS); - - this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList()); - } - - public List getOnlineAccounts() { - return this.onlineAccounts; - } - - public static Message fromByteBuffer(int id, ByteBuffer bytes) { - final int accountCount = bytes.getInt(); - - List onlineAccounts = new ArrayList<>(accountCount); - - for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) { - long timestamp = bytes.getLong(); - - byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; - bytes.get(signature); - - byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - bytes.get(publicKey); - - OnlineAccountData onlineAccountData = new OnlineAccountData(timestamp, signature, publicKey); - onlineAccounts.add(onlineAccountData); - } - - return new OnlineAccountsMessage(id, onlineAccounts); - } - -} diff --git a/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java b/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java deleted file mode 100644 index 6803e3bf..00000000 --- a/src/main/java/org/qortal/network/message/OnlineAccountsV2Message.java +++ /dev/null @@ -1,113 +0,0 @@ -package org.qortal.network.message; - -import com.google.common.primitives.Ints; -import com.google.common.primitives.Longs; -import org.qortal.data.network.OnlineAccountData; -import org.qortal.transform.Transformer; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * For sending online accounts info to remote peer. - * - * Different format to V1: - * V1 is: number of entries, then timestamp + sig + pubkey for each entry - * V2 is: groups of: number of entries, timestamp, then sig + pubkey for each entry - * - * Also V2 only builds online accounts message once! - */ -public class OnlineAccountsV2Message extends Message { - - private List onlineAccounts; - - public OnlineAccountsV2Message(List onlineAccounts) { - super(MessageType.ONLINE_ACCOUNTS_V2); - - // Shortcut in case we have no online accounts - if (onlineAccounts.isEmpty()) { - this.dataBytes = Ints.toByteArray(0); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - return; - } - - // How many of each timestamp - Map countByTimestamp = new HashMap<>(); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - Long timestamp = onlineAccountData.getTimestamp(); - countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v); - } - - // We should know exactly how many bytes to allocate now - int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH) - + onlineAccounts.size() * (Transformer.SIGNATURE_LENGTH + Transformer.PUBLIC_KEY_LENGTH); - - ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize); - - try { - for (long timestamp : countByTimestamp.keySet()) { - bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp))); - - bytes.write(Longs.toByteArray(timestamp)); - - for (OnlineAccountData onlineAccountData : onlineAccounts) { - if (onlineAccountData.getTimestamp() == timestamp) { - bytes.write(onlineAccountData.getSignature()); - bytes.write(onlineAccountData.getPublicKey()); - } - } - } - } catch (IOException e) { - throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); - } - - this.dataBytes = bytes.toByteArray(); - this.checksumBytes = Message.generateChecksum(this.dataBytes); - } - - private OnlineAccountsV2Message(int id, List onlineAccounts) { - super(id, MessageType.ONLINE_ACCOUNTS_V2); - - this.onlineAccounts = onlineAccounts; - } - - public List getOnlineAccounts() { - return this.onlineAccounts; - } - - public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException { - int accountCount = bytes.getInt(); - - List onlineAccounts = new ArrayList<>(accountCount); - - while (accountCount > 0) { - long timestamp = bytes.getLong(); - - for (int i = 0; i < accountCount; ++i) { - byte[] signature = new byte[Transformer.SIGNATURE_LENGTH]; - bytes.get(signature); - - byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - bytes.get(publicKey); - - onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey)); - } - - if (bytes.hasRemaining()) { - accountCount = bytes.getInt(); - } else { - // we've finished - accountCount = 0; - } - } - - return new OnlineAccountsV2Message(id, onlineAccounts); - } - -} diff --git a/src/test/java/org/qortal/test/network/OnlineAccountsTests.java b/src/test/java/org/qortal/test/network/OnlineAccountsTests.java index c9e646f1..c8220d66 100644 --- a/src/test/java/org/qortal/test/network/OnlineAccountsTests.java +++ b/src/test/java/org/qortal/test/network/OnlineAccountsTests.java @@ -51,89 +51,6 @@ public class OnlineAccountsTests extends Common { } - @Test - public void testGetOnlineAccountsV2() throws MessageException { - List onlineAccountsOut = generateOnlineAccounts(false); - - Message messageOut = new GetOnlineAccountsV2Message(onlineAccountsOut); - - byte[] messageBytes = messageOut.toBytes(); - ByteBuffer byteBuffer = ByteBuffer.wrap(messageBytes); - - GetOnlineAccountsV2Message messageIn = (GetOnlineAccountsV2Message) Message.fromByteBuffer(byteBuffer); - - List onlineAccountsIn = messageIn.getOnlineAccounts(); - - assertEquals("size mismatch", onlineAccountsOut.size(), onlineAccountsIn.size()); - assertTrue("accounts mismatch", onlineAccountsIn.containsAll(onlineAccountsOut)); - - Message oldMessageOut = new GetOnlineAccountsMessage(onlineAccountsOut); - byte[] oldMessageBytes = oldMessageOut.toBytes(); - - long numTimestamps = onlineAccountsOut.stream().mapToLong(OnlineAccountData::getTimestamp).sorted().distinct().count(); - - System.out.println(String.format("For %d accounts split across %d timestamp%s: old size %d vs new size %d", - onlineAccountsOut.size(), - numTimestamps, - numTimestamps != 1 ? "s" : "", - oldMessageBytes.length, - messageBytes.length)); - } - - @Test - public void testOnlineAccountsV2() throws MessageException { - List onlineAccountsOut = generateOnlineAccounts(true); - - Message messageOut = new OnlineAccountsV2Message(onlineAccountsOut); - - byte[] messageBytes = messageOut.toBytes(); - ByteBuffer byteBuffer = ByteBuffer.wrap(messageBytes); - - OnlineAccountsV2Message messageIn = (OnlineAccountsV2Message) Message.fromByteBuffer(byteBuffer); - - List onlineAccountsIn = messageIn.getOnlineAccounts(); - - assertEquals("size mismatch", onlineAccountsOut.size(), onlineAccountsIn.size()); - assertTrue("accounts mismatch", onlineAccountsIn.containsAll(onlineAccountsOut)); - - Message oldMessageOut = new OnlineAccountsMessage(onlineAccountsOut); - byte[] oldMessageBytes = oldMessageOut.toBytes(); - - long numTimestamps = onlineAccountsOut.stream().mapToLong(OnlineAccountData::getTimestamp).sorted().distinct().count(); - - System.out.println(String.format("For %d accounts split across %d timestamp%s: old size %d vs new size %d", - onlineAccountsOut.size(), - numTimestamps, - numTimestamps != 1 ? "s" : "", - oldMessageBytes.length, - messageBytes.length)); - } - - private List generateOnlineAccounts(boolean withSignatures) { - List onlineAccounts = new ArrayList<>(); - - int numTimestamps = RANDOM.nextInt(2) + 1; // 1 or 2 - - for (int t = 0; t < numTimestamps; ++t) { - int numAccounts = RANDOM.nextInt(3000); - - for (int a = 0; a < numAccounts; ++a) { - byte[] sig = null; - if (withSignatures) { - sig = new byte[Transformer.SIGNATURE_LENGTH]; - RANDOM.nextBytes(sig); - } - - byte[] pubkey = new byte[Transformer.PUBLIC_KEY_LENGTH]; - RANDOM.nextBytes(pubkey); - - onlineAccounts.add(new OnlineAccountData(t << 32, sig, pubkey)); - } - } - - return onlineAccounts; - } - @Test public void testOnlineAccountsModulusV1() throws IllegalAccessException, DataException { try (final Repository repository = RepositoryManager.getRepository()) { diff --git a/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java b/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java index 6136c1e1..cc2a54ff 100644 --- a/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java +++ b/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java @@ -26,41 +26,6 @@ public class OnlineAccountsV3Tests { Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); } - @Ignore("For informational use") - @Test - public void compareV2ToV3() throws MessageException { - List onlineAccounts = generateOnlineAccounts(false); - - // How many of each timestamp and leading byte (of public key) - Map> hashesByTimestampThenByte = convertToHashMaps(onlineAccounts); - - byte[] v3DataBytes = new GetOnlineAccountsV3Message(hashesByTimestampThenByte).toBytes(); - int v3ByteSize = v3DataBytes.length; - - byte[] v2DataBytes = new GetOnlineAccountsV2Message(onlineAccounts).toBytes(); - int v2ByteSize = v2DataBytes.length; - - int numTimestamps = hashesByTimestampThenByte.size(); - System.out.printf("For %d accounts split across %d timestamp%s: V2 size %d vs V3 size %d%n", - onlineAccounts.size(), - numTimestamps, - numTimestamps != 1 ? "s" : "", - v2ByteSize, - v3ByteSize - ); - - for (var outerMapEntry : hashesByTimestampThenByte.entrySet()) { - long timestamp = outerMapEntry.getKey(); - - var innerMap = outerMapEntry.getValue(); - - System.out.printf("For timestamp %d: %d / 256 slots used.%n", - timestamp, - innerMap.size() - ); - } - } - private Map> convertToHashMaps(List onlineAccounts) { // How many of each timestamp and leading byte (of public key) Map> hashesByTimestampThenByte = new HashMap<>(); From 3215bb638d2faa0ae6f04210900a0a7ecb73a7c5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Jul 2023 10:44:41 +0100 Subject: [PATCH 463/496] More online accounts improvements --- .../qortal/controller/OnlineAccountsManager.java | 2 +- .../org/qortal/data/network/OnlineAccountData.java | 14 ++++++++------ .../network/message/OnlineAccountsV3Message.java | 3 ++- .../qortal/test/network/OnlineAccountsV3Tests.java | 4 +++- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index 224228b8..25cace2f 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -414,7 +414,7 @@ public class OnlineAccountsManager { boolean isSuperiorEntry = isOnlineAccountsDataSuperior(onlineAccountData); if (isSuperiorEntry) // Remove existing inferior entry so it can be re-added below (it's likely the existing copy is missing a nonce value) - onlineAccounts.remove(onlineAccountData); + onlineAccounts.removeIf(a -> Objects.equals(a.getPublicKey(), onlineAccountData.getPublicKey())); boolean isNewEntry = onlineAccounts.add(onlineAccountData); diff --git a/src/main/java/org/qortal/data/network/OnlineAccountData.java b/src/main/java/org/qortal/data/network/OnlineAccountData.java index bd4842db..a1e1b30f 100644 --- a/src/main/java/org/qortal/data/network/OnlineAccountData.java +++ b/src/main/java/org/qortal/data/network/OnlineAccountData.java @@ -1,6 +1,7 @@ package org.qortal.data.network; import java.util.Arrays; +import java.util.Objects; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @@ -34,10 +35,6 @@ public class OnlineAccountData { this.nonce = nonce; } - public OnlineAccountData(long timestamp, byte[] signature, byte[] publicKey) { - this(timestamp, signature, publicKey, null); - } - public long getTimestamp() { return this.timestamp; } @@ -76,6 +73,10 @@ public class OnlineAccountData { if (otherOnlineAccountData.timestamp != this.timestamp) return false; + // Almost as quick + if (!Objects.equals(otherOnlineAccountData.nonce, this.nonce)) + return false; + if (!Arrays.equals(otherOnlineAccountData.publicKey, this.publicKey)) return false; @@ -88,9 +89,10 @@ public class OnlineAccountData { public int hashCode() { int h = this.hash; if (h == 0) { - this.hash = h = Long.hashCode(this.timestamp) - ^ Arrays.hashCode(this.publicKey); + h = Objects.hash(timestamp, nonce); + h = 31 * h + Arrays.hashCode(publicKey); // We don't use signature because newer aggregate signatures use random nonces + this.hash = h; } return h; } diff --git a/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java b/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java index d554d96c..c057fbce 100644 --- a/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java +++ b/src/main/java/org/qortal/network/message/OnlineAccountsV3Message.java @@ -99,9 +99,10 @@ public class OnlineAccountsV3Message extends Message { bytes.get(publicKey); // Nonce is optional - will be -1 if missing + // ... but we should skip/ignore an online account if it has no nonce Integer nonce = bytes.getInt(); if (nonce < 0) { - nonce = null; + continue; } onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey, nonce)); diff --git a/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java b/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java index cc2a54ff..2c3c01ca 100644 --- a/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java +++ b/src/test/java/org/qortal/test/network/OnlineAccountsV3Tests.java @@ -165,7 +165,9 @@ public class OnlineAccountsV3Tests { byte[] pubkey = new byte[Transformer.PUBLIC_KEY_LENGTH]; RANDOM.nextBytes(pubkey); - onlineAccounts.add(new OnlineAccountData(timestamp, sig, pubkey)); + Integer nonce = RANDOM.nextInt(); + + onlineAccounts.add(new OnlineAccountData(timestamp, sig, pubkey, nonce)); } } From 811b647c88f7a72ba9bd27d62004dc8403a3119e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Jul 2023 18:58:47 +0100 Subject: [PATCH 464/496] Catch UnsupportedAddressTypeException and fall back to IPv4 binding. --- src/main/java/org/qortal/network/Network.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index ca79f367..a3528a66 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -187,7 +187,7 @@ public class Network { this.bindAddress = bindAddress; // Store the selected address, so that it can be used by other parts of the app break; // We don't want to bind to more than one address - } catch (UnknownHostException e) { + } catch (UnknownHostException | UnsupportedAddressTypeException e) { LOGGER.error("Can't bind listen socket to address {}", Settings.getInstance().getBindAddress()); if (i == bindAddresses.size()-1) { // Only throw an exception if all addresses have been tried throw new IOException("Can't bind listen socket to address", e); From f7e1f2fca876d5fa8074e8643070bb7dfb348808 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 28 Jul 2023 21:47:29 +0100 Subject: [PATCH 465/496] Increased timeout for SEARCH_QDN_RESOURCES from 10 to 30 seconds. --- 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 d26b7791..b638c621 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -448,6 +448,10 @@ function getDefaultTimeout(action) { // User may take a long time to accept/deny the popup return 60 * 60 * 1000; + case "SEARCH_QDN_RESOURCES": + // Searching for data can be slow, especially when metadata and statuses are also being included + return 30 * 1000; + case "FETCH_QDN_RESOURCE": // Fetching data can take a while, especially if the status hasn't been checked first return 60 * 1000; From f5c8dfe7661a97cf4585af0c9aa56c26542d0038 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 31 Jul 2023 19:25:26 +0100 Subject: [PATCH 466/496] Added maxTransactionsPerBlock setting (default 25) to reduce minting load on slower machines. This is a short term limit, is well above current usage levels, and can be increased substantially in future once the block minter code has been improved. --- .../qortal/repository/TransactionRepository.java | 2 +- .../transaction/HSQLDBTransactionRepository.java | 13 ++++++++++--- src/main/java/org/qortal/settings/Settings.java | 7 +++++++ .../java/org/qortal/transaction/Transaction.java | 3 ++- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 6cc88290..41986cad 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -314,7 +314,7 @@ public interface TransactionRepository { * @return list of transactions, or empty if none. * @throws DataException */ - public List getUnconfirmedTransactions(EnumSet excludedTxTypes) throws DataException; + public List getUnconfirmedTransactions(EnumSet excludedTxTypes, Integer limit) throws DataException; /** * Remove transaction from unconfirmed transactions pile. diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 740b3e65..60b4e803 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -1429,8 +1429,10 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } @Override - public List getUnconfirmedTransactions(EnumSet excludedTxTypes) throws DataException { + public List getUnconfirmedTransactions(EnumSet excludedTxTypes, Integer limit) throws DataException { StringBuilder sql = new StringBuilder(1024); + List bindParams = new ArrayList<>(); + sql.append("SELECT signature FROM UnconfirmedTransactions "); sql.append("JOIN Transactions USING (signature) "); sql.append("WHERE type NOT IN ("); @@ -1446,12 +1448,17 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } sql.append(")"); - sql.append("ORDER BY created_when, signature"); + sql.append("ORDER BY created_when, signature "); + + if (limit != null) { + sql.append("LIMIT ?"); + bindParams.add(limit); + } List transactions = new ArrayList<>(); // Find transactions with no corresponding row in BlockTransactions - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) { + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { if (resultSet == null) return transactions; diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index c3d5a0c8..ac9b8857 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -146,6 +146,9 @@ public class Settings { /* How many blocks to cache locally. Defaulted to 10, which covers a typical Synchronizer request + a few spare */ private int blockCacheSize = 10; + /** Maximum number of transactions for the block minter to include in a block */ + private int maxTransactionsPerBlock = 25; + /** How long to keep old, full, AT state data (ms). */ private long atStatesMaxLifetime = 5 * 24 * 60 * 60 * 1000L; // milliseconds /** How often to attempt AT state trimming (ms). */ @@ -693,6 +696,10 @@ public class Settings { return this.blockCacheSize; } + public int getMaxTransactionsPerBlock() { + return this.maxTransactionsPerBlock; + } + public boolean isTestNet() { return this.isTestNet; } diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index f0e9b3f6..10834a06 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -641,7 +641,8 @@ public abstract class Transaction { BlockData latestBlockData = repository.getBlockRepository().getLastBlock(); EnumSet excludedTxTypes = EnumSet.of(TransactionType.CHAT, TransactionType.PRESENCE); - List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes); + int limit = Settings.getInstance().getMaxTransactionsPerBlock(); + List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes, limit); unconfirmedTransactions.sort(getDataComparator()); From 94d3664cb092877f8348bea1d6342dbe5a95993d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 31 Jul 2023 19:30:45 +0100 Subject: [PATCH 467/496] Bump version to 4.2.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f236761e..1a046758 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.2.0 + 4.2.1 jar true From 33cfd02c4960f81ea1c2a82986eeab31d146811a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 2 Aug 2023 21:13:33 +0100 Subject: [PATCH 468/496] Fixed issues in commit f5c8dfe --- src/main/java/org/qortal/controller/BlockMinter.java | 9 +++++++++ src/main/java/org/qortal/transaction/Transaction.java | 3 +-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index bc879f23..deb00587 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -484,6 +484,9 @@ public class BlockMinter extends Thread { // Sign to create block's signature, needed by Block.isValid() newBlock.sign(); + // User-defined limit per block + int limit = Settings.getInstance().getMaxTransactionsPerBlock(); + // Attempt to add transactions until block is full, or we run out // If a transaction makes the block invalid then skip it and it'll either expire or be in next block. for (TransactionData transactionData : unconfirmedTransactions) { @@ -496,6 +499,12 @@ public class BlockMinter extends Thread { LOGGER.debug(() -> String.format("Skipping invalid transaction %s during block minting", Base58.encode(transactionData.getSignature()))); newBlock.deleteTransaction(transactionData); } + + // User-defined limit per block + List transactions = newBlock.getTransactions(); + if (transactions != null && transactions.size() >= limit) { + break; + } } } diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index 10834a06..bd91f25a 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -641,8 +641,7 @@ public abstract class Transaction { BlockData latestBlockData = repository.getBlockRepository().getLastBlock(); EnumSet excludedTxTypes = EnumSet.of(TransactionType.CHAT, TransactionType.PRESENCE); - int limit = Settings.getInstance().getMaxTransactionsPerBlock(); - List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes, limit); + List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes, null); unconfirmedTransactions.sort(getDataComparator()); From 528583fe381287c71597ed5a123a66512d6c37b4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 2 Aug 2023 21:32:57 +0100 Subject: [PATCH 469/496] Added logging relating to unconfirmed transactions. --- 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 deb00587..b1ed7e3c 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -380,9 +380,13 @@ public class BlockMinter extends Thread { parentSignatureForLastLowWeightBlock = null; timeOfLastLowWeightBlock = null; + Long unconfirmedStartTime = NTP.getTime(); + // Add unconfirmed transactions addUnconfirmedTransactions(repository, newBlock); + LOGGER.info(String.format("Adding %d unconfirmed transactions took %d ms", newBlock.getTransactions().size(), (NTP.getTime()-unconfirmedStartTime))); + // Sign to create block's signature newBlock.sign(); From 9574100a0880b46122b103dd2a6820e43fa66c8b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 2 Aug 2023 21:36:57 +0100 Subject: [PATCH 470/496] Bump version to 4.2.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1a046758..30ad1e04 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.2.1 + 4.2.2 jar true From fe840bbf0282d85a1e3dae1a0b25245eec08233a Mon Sep 17 00:00:00 2001 From: kennycud Date: Tue, 8 Aug 2023 12:17:29 -0700 Subject: [PATCH 471/496] consolidated shared functionality into BitcoinyTests.java --- .../qortal/test/crosschain/BitcoinTests.java | 126 +++++----------- .../qortal/test/crosschain/BitcoinyTests.java | 130 +++++++++++++++++ .../qortal/test/crosschain/DigibyteTests.java | 119 ++++----------- .../qortal/test/crosschain/DogecoinTests.java | 120 ++++------------ .../qortal/test/crosschain/LitecoinTests.java | 120 ++++------------ .../test/crosschain/PirateChainTests.java | 135 ++++++------------ .../test/crosschain/RavencoinTests.java | 120 ++++------------ 7 files changed, 306 insertions(+), 564 deletions(-) create mode 100644 src/test/java/org/qortal/test/crosschain/BitcoinyTests.java diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java index 1096d7ad..684f1cd6 100644 --- a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java @@ -1,121 +1,59 @@ package org.qortal.test.crosschain; -import static org.junit.Assert.*; - -import java.util.Arrays; - -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.qortal.crosschain.Bitcoin; -import org.qortal.crosschain.ForeignBlockchainException; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; +import org.qortal.crosschain.Bitcoiny; -public class BitcoinTests extends Common { +public class BitcoinTests extends BitcoinyTests { - private Bitcoin bitcoin; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - bitcoin = Bitcoin.getInstance(); + @Override + protected String getCoinName() { + return "Bitcoin"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "BTC"; + } + + @Override + protected Bitcoiny getCoin() { + return Bitcoin.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Bitcoin.resetForTesting(); - bitcoin = null; + } + + @Override + protected String getDeterministicKey58() { + return "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + } + + @Override + protected String getRecipient() { + return "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; } @Test @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - System.out.println(String.format("Starting BTC instance...")); - System.out.println(String.format("BTC instance started")); - - long before = System.currentTimeMillis(); - System.out.println(String.format("Bitcoin median blocktime: %d", bitcoin.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); - - System.out.println(String.format("Bitcoin median blocktime: %d", bitcoin.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); - } + public void testGetMedianBlockTime() {} @Test @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); - - assertNotNull(secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + public void testFindHtlcSecret() {} @Test @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = bitcoin.buildSpend(xprv58, recipient, amount); - assertNotNull(transaction); - - // Check spent key caching doesn't affect outcome - - transaction = bitcoin.buildSpend(xprv58, recipient, amount); - assertNotNull(transaction); - } + public void testBuildSpend() {} @Test @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - Long balance = bitcoin.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(bitcoin.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = bitcoin.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(bitcoin.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } + public void testGetWalletBalance() {} @Test @Ignore("Often fails due to unreliable BTC testnet ElectrumX servers") - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String address = bitcoin.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - + public void testGetUnusedReceiveAddress() {} } diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java new file mode 100644 index 00000000..b29fffd4 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java @@ -0,0 +1,130 @@ +package org.qortal.test.crosschain; + +import org.bitcoinj.core.Transaction; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +import java.util.Arrays; + +import static org.junit.Assert.*; + +public abstract class BitcoinyTests extends Common { + + protected Bitcoiny bitcoiny; + + protected abstract String getCoinName(); + + protected abstract String getCoinSymbol(); + + protected abstract Bitcoiny getCoin(); + + protected abstract void resetCoinForTesting(); + + protected abstract String getDeterministicKey58(); + + protected abstract String getRecipient(); + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + bitcoiny = getCoin(); + } + + @After + public void afterTest() { + resetCoinForTesting(); + bitcoiny = null; + } + + @Test + public void testGetMedianBlockTime() throws ForeignBlockchainException { + System.out.println(String.format("Starting " + getCoinSymbol() + " instance...")); + System.out.println(String.format(getCoinSymbol() + " instance started")); + + long before = System.currentTimeMillis(); + System.out.println(String.format(getCoinName() + " median blocktime: %d", bitcoiny.getMedianBlockTime())); + long afterFirst = System.currentTimeMillis(); + + System.out.println(String.format(getCoinName() + " median blocktime: %d", bitcoiny.getMedianBlockTime())); + long afterSecond = System.currentTimeMillis(); + + long firstPeriod = afterFirst - before; + long secondPeriod = afterSecond - afterFirst; + + System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); + + makeGetMedianBlockTimeAssertions(firstPeriod, secondPeriod); + } + + public void makeGetMedianBlockTimeAssertions(long firstPeriod, long secondPeriod) { + assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + } + + @Test + public void testFindHtlcSecret() throws ForeignBlockchainException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BitcoinyHTLC.findHtlcSecret(bitcoiny, p2shAddress); + + assertNotNull(secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + + @Test + public void testBuildSpend() { + String xprv58 = getDeterministicKey58(); + + String recipient = getRecipient(); + long amount = 1000L; + + Transaction transaction = bitcoiny.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); + + // Check spent key caching doesn't affect outcome + + transaction = bitcoiny.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); + } + + @Test + public void testGetWalletBalance() throws ForeignBlockchainException { + String xprv58 = getDeterministicKey58(); + + Long balance = bitcoiny.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(bitcoiny.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = bitcoiny.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(bitcoiny.format(repeatBalance)); + + assertEquals(balance, repeatBalance); + } + + @Test + public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { + String xprv58 = getDeterministicKey58(); + + String address = bitcoiny.getUnusedReceiveAddress(xprv58); + + assertNotNull(address); + + System.out.println(address); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/DigibyteTests.java b/src/test/java/org/qortal/test/crosschain/DigibyteTests.java index dbe81c82..d95f1bd5 100644 --- a/src/test/java/org/qortal/test/crosschain/DigibyteTests.java +++ b/src/test/java/org/qortal/test/crosschain/DigibyteTests.java @@ -1,115 +1,48 @@ package org.qortal.test.crosschain; -import static org.junit.Assert.*; - -import java.util.Arrays; - -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.Digibyte; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; -public class DigibyteTests extends Common { +public class DigibyteTests extends BitcoinyTests { - private Digibyte digibyte; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - digibyte = Digibyte.getInstance(); + @Override + protected String getCoinName() { + return "Digibyte"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "DGB"; + } + + @Override + protected Bitcoiny getCoin() { + return Digibyte.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Digibyte.resetForTesting(); - digibyte = null; } - @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Digibyte median blocktime: %d", digibyte.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return "xpub661MyMwAqRbcEnabTLX5uebYcsE3uG5y7ve9jn1VK8iY1MaU3YLoLJEe8sTu2YVav5Zka5qf2dmMssfxmXJTqZnazZL2kL7M2tNKwEoC34R"; + } - System.out.println(String.format("Digibyte median blocktime: %d", digibyte.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + @Override + protected String getRecipient() { + return "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; } @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(digibyte, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + public void testFindHtlcSecret() {} @Test @Ignore(value = "No testnet nodes available, so we can't regularly test buildSpend yet") - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + public void testBuildSpend() {} - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = digibyte.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = digibyte.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); } - - @Test - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "xpub661MyMwAqRbcEnabTLX5uebYcsE3uG5y7ve9jn1VK8iY1MaU3YLoLJEe8sTu2YVav5Zka5qf2dmMssfxmXJTqZnazZL2kL7M2tNKwEoC34R"; - - Long balance = digibyte.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(digibyte.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = digibyte.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(digibyte.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } - - @Test - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "xpub661MyMwAqRbcEnabTLX5uebYcsE3uG5y7ve9jn1VK8iY1MaU3YLoLJEe8sTu2YVav5Zka5qf2dmMssfxmXJTqZnazZL2kL7M2tNKwEoC34R"; - - String address = digibyte.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - -} diff --git a/src/test/java/org/qortal/test/crosschain/DogecoinTests.java b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java index 6c070d09..62982437 100644 --- a/src/test/java/org/qortal/test/crosschain/DogecoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java @@ -1,115 +1,47 @@ package org.qortal.test.crosschain; -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.Dogecoin; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; -import java.util.Arrays; +public class DogecoinTests extends BitcoinyTests { -import static org.junit.Assert.*; - -public class DogecoinTests extends Common { - - private Dogecoin dogecoin; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - dogecoin = Dogecoin.getInstance(); + @Override + protected String getCoinName() { + return "Dogecoin"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "DOGE"; + } + + @Override + protected Bitcoiny getCoin() { + return Dogecoin.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Dogecoin.resetForTesting(); - dogecoin = null; } - @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Dogecoin median blocktime: %d", dogecoin.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; + } - System.out.println(String.format("Dogecoin median blocktime: %d", dogecoin.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + @Override + protected String getRecipient() { + return null; } @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(dogecoin, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + public void testFindHtlcSecret() {} @Test @Ignore(value = "No testnet nodes available, so we can't regularly test buildSpend yet") - public void testBuildSpend() { - String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; - - String recipient = "DP1iFao33xdEPa5vaArpj7sykfzKNeiJeX"; - long amount = 1000L; - - Transaction transaction = dogecoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = dogecoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - } - - @Test - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; - - Long balance = dogecoin.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(dogecoin.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = dogecoin.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(dogecoin.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } - - @Test - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; - - String address = dogecoin.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - + public void testBuildSpend() {} } diff --git a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java index 5ea7bc95..66c631e5 100644 --- a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java @@ -1,113 +1,43 @@ package org.qortal.test.crosschain; -import static org.junit.Assert.*; - -import java.util.Arrays; - -import org.bitcoinj.core.Transaction; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.Litecoin; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; -public class LitecoinTests extends Common { +public class LitecoinTests extends BitcoinyTests { - private Litecoin litecoin; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - litecoin = Litecoin.getInstance(); + @Override + protected String getCoinName() { + return "Litecoin"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "LTC"; + } + + @Override + protected Bitcoiny getCoin() { + return Litecoin.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Litecoin.resetForTesting(); - litecoin = null; } - @Test - public void testGetMedianBlockTime() throws ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Litecoin median blocktime: %d", litecoin.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + } - System.out.println(String.format("Litecoin median blocktime: %d", litecoin.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + @Override + protected String getRecipient() { + return "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; } @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } - - @Test - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = litecoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = litecoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - } - - @Test - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - Long balance = litecoin.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(litecoin.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = litecoin.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(litecoin.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } - - @Test - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String address = litecoin.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - + public void testFindHtlcSecret() {} } diff --git a/src/test/java/org/qortal/test/crosschain/PirateChainTests.java b/src/test/java/org/qortal/test/crosschain/PirateChainTests.java index 9502e45a..b212aea1 100644 --- a/src/test/java/org/qortal/test/crosschain/PirateChainTests.java +++ b/src/test/java/org/qortal/test/crosschain/PirateChainTests.java @@ -3,57 +3,53 @@ package org.qortal.test.crosschain; import cash.z.wallet.sdk.rpc.CompactFormats.*; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; import org.qortal.transform.TransformationException; -import java.util.Arrays; import java.util.List; import static org.junit.Assert.*; import static org.qortal.crosschain.BitcoinyHTLC.Status.*; -public class PirateChainTests extends Common { +public class PirateChainTests extends BitcoinyTests { - private PirateChain pirateChain; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); - pirateChain = PirateChain.getInstance(); + @Override + protected String getCoinName() { + return "PirateChain"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "ARRR"; + } + + @Override + protected Bitcoiny getCoin() { + return PirateChain.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Litecoin.resetForTesting(); - pirateChain = null; } - @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Pirate Chain median blocktime: %d", pirateChain.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return null; + } - System.out.println(String.format("Pirate Chain median blocktime: %d", pirateChain.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); + @Override + protected String getRecipient() { + return null; + } - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("1st call should take less than 5 seconds", firstPeriod < 5000L); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + public void makeGetMedianBlockTimeAssertions(long firstPeriod, long secondPeriod) { + assertTrue("1st call should take less than 5 seconds", firstPeriod < 5000L); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); } @Test @@ -62,7 +58,7 @@ public class PirateChainTests extends Common { int count = 20; long before = System.currentTimeMillis(); - List compactBlocks = pirateChain.getCompactBlocks(startHeight, count); + List compactBlocks = ((PirateChain) bitcoiny).getCompactBlocks(startHeight, count); long after = System.currentTimeMillis(); System.out.println(String.format("Retrieval took: %d ms", after-before)); @@ -82,7 +78,7 @@ public class PirateChainTests extends Common { Bytes.reverse(txBytes); String txHashBE = HashCode.fromBytes(txBytes).toString(); - byte[] rawTransaction = pirateChain.getBlockchainProvider().getRawTransaction(txHashBE); + byte[] rawTransaction = bitcoiny.getBlockchainProvider().getRawTransaction(txHashBE); assertNotNull(rawTransaction); } @@ -121,7 +117,7 @@ public class PirateChainTests extends Common { String p2shAddress = "ba6Q5HWrWtmfU2WZqQbrFdRYsafA45cUAt"; long p2shFee = 10000; final long minimumAmount = 10000 + p2shFee; - BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(pirateChain.getBlockchainProvider(), p2shAddress, minimumAmount); + BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddress, minimumAmount); assertEquals(FUNDED, htlcStatus); } @@ -130,7 +126,7 @@ public class PirateChainTests extends Common { String p2shAddress = "bYZrzSSgGp8aEGvihukoMGU8sXYrx19Wka"; long p2shFee = 10000; final long minimumAmount = 10000 + p2shFee; - BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(pirateChain.getBlockchainProvider(), p2shAddress, minimumAmount); + BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddress, minimumAmount); assertEquals(REDEEMED, htlcStatus); } @@ -139,14 +135,14 @@ public class PirateChainTests extends Common { String p2shAddress = "bE49izfVxz8odhu8c2BcUaVFUnt7NLFRgv"; long p2shFee = 10000; final long minimumAmount = 10000 + p2shFee; - BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(pirateChain.getBlockchainProvider(), p2shAddress, minimumAmount); + BitcoinyHTLC.Status htlcStatus = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddress, minimumAmount); assertEquals(REFUNDED, htlcStatus); } @Test public void testGetTxidForUnspentAddress() throws ForeignBlockchainException { String p2shAddress = "ba6Q5HWrWtmfU2WZqQbrFdRYsafA45cUAt"; - String txid = PirateChainHTLC.getFundingTxid(pirateChain.getBlockchainProvider(), p2shAddress); + String txid = PirateChainHTLC.getFundingTxid(bitcoiny.getBlockchainProvider(), p2shAddress); // Reverse the byte order of the txid used by block explorers, to get to big-endian form byte[] expectedTxidLE = HashCode.fromString("fea4b0c1abcf8f0f3ddc2fa2f9438501ee102aad62a9ff18a5ce7d08774755c0").asBytes(); @@ -161,7 +157,7 @@ public class PirateChainTests extends Common { String p2shAddress = "ba6Q5HWrWtmfU2WZqQbrFdRYsafA45cUAt"; long p2shFee = 10000; final long minimumAmount = 10000 + p2shFee; - String txid = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddress, minimumAmount); + String txid = PirateChainHTLC.getUnspentFundingTxid(bitcoiny.getBlockchainProvider(), p2shAddress, minimumAmount); // Reverse the byte order of the txid used by block explorers, to get to big-endian form byte[] expectedTxidLE = HashCode.fromString("fea4b0c1abcf8f0f3ddc2fa2f9438501ee102aad62a9ff18a5ce7d08774755c0").asBytes(); @@ -174,7 +170,7 @@ public class PirateChainTests extends Common { @Test public void testGetTxidForSpentAddress() throws ForeignBlockchainException { String p2shAddress = "bE49izfVxz8odhu8c2BcUaVFUnt7NLFRgv"; //"t3KtVxeEb8srJofo6atMEpMpEP6TjEi8VqA"; - String txid = PirateChainHTLC.getFundingTxid(pirateChain.getBlockchainProvider(), p2shAddress); + String txid = PirateChainHTLC.getFundingTxid(bitcoiny.getBlockchainProvider(), p2shAddress); // Reverse the byte order of the txid used by block explorers, to get to big-endian form byte[] expectedTxidLE = HashCode.fromString("fb386fc8eea0fbf3ea37047726b92c39441652b32d8d62a274331687f7a1eca8").asBytes(); @@ -187,7 +183,7 @@ public class PirateChainTests extends Common { @Test public void testGetTransactionsForAddress() throws ForeignBlockchainException { String p2shAddress = "bE49izfVxz8odhu8c2BcUaVFUnt7NLFRgv"; //"t3KtVxeEb8srJofo6atMEpMpEP6TjEi8VqA"; - List transactions = pirateChain.getBlockchainProvider() + List transactions = bitcoiny.getBlockchainProvider() .getAddressBitcoinyTransactions(p2shAddress, false); assertEquals(2, transactions.size()); @@ -232,66 +228,17 @@ public class PirateChainTests extends Common { @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(pirateChain, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + public void testFindHtlcSecret() {} @Test @Ignore(value = "Needs adapting for Pirate Chain") - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = pirateChain.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = pirateChain.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - } + public void testBuildSpend() {} @Test @Ignore(value = "Needs adapting for Pirate Chain") - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - Long balance = pirateChain.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(pirateChain.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = pirateChain.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(pirateChain.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } + public void testGetWalletBalance() {} @Test @Ignore(value = "Needs adapting for Pirate Chain") - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String address = pirateChain.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - -} + public void testGetUnusedReceiveAddress() {} +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/crosschain/RavencoinTests.java b/src/test/java/org/qortal/test/crosschain/RavencoinTests.java index 866c41a2..d7581f74 100644 --- a/src/test/java/org/qortal/test/crosschain/RavencoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/RavencoinTests.java @@ -1,115 +1,47 @@ package org.qortal.test.crosschain; -import static org.junit.Assert.*; - -import java.util.Arrays; - -import org.bitcoinj.core.Transaction; -import org.bitcoinj.store.BlockStoreException; -import org.junit.After; -import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.Ravencoin; -import org.qortal.crosschain.BitcoinyHTLC; -import org.qortal.repository.DataException; -import org.qortal.test.common.Common; -public class RavencoinTests extends Common { +public class RavencoinTests extends BitcoinyTests { - private Ravencoin ravencoin; - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); // TestNet3 - ravencoin = Ravencoin.getInstance(); + @Override + protected String getCoinName() { + return "Ravencoin"; } - @After - public void afterTest() { + @Override + protected String getCoinSymbol() { + return "RVN"; + } + + @Override + protected Bitcoiny getCoin() { + return Ravencoin.getInstance(); + } + + @Override + protected void resetCoinForTesting() { Ravencoin.resetForTesting(); - ravencoin = null; } - @Test - public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { - long before = System.currentTimeMillis(); - System.out.println(String.format("Ravencoin median blocktime: %d", ravencoin.getMedianBlockTime())); - long afterFirst = System.currentTimeMillis(); + @Override + protected String getDeterministicKey58() { + return "xpub661MyMwAqRbcEt3Ge1wNmkagyb1J7yTQu4Kquvy77Ycg2iPoh7Urg8s9Jdwp7YmrqGkDKJpUVjsZXSSsQgmAVUC17ZVQQeoWMzm7vDTt1y7"; + } - System.out.println(String.format("Ravencoin median blocktime: %d", ravencoin.getMedianBlockTime())); - long afterSecond = System.currentTimeMillis(); - - long firstPeriod = afterFirst - before; - long secondPeriod = afterSecond - afterFirst; - - System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); - - assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); - assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + @Override + protected String getRecipient() { + return null; } @Test @Ignore(value = "Doesn't work, to be fixed later") - public void testFindHtlcSecret() throws ForeignBlockchainException { - // This actually exists on TEST3 but can take a while to fetch - String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BitcoinyHTLC.findHtlcSecret(ravencoin, p2shAddress); - - assertNotNull("secret not found", secret); - assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); - } + public void testFindHtlcSecret() {} @Test @Ignore(value = "No testnet nodes available, so we can't regularly test buildSpend yet") - public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - long amount = 1000L; - - Transaction transaction = ravencoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - - // Check spent key caching doesn't affect outcome - - transaction = ravencoin.buildSpend(xprv58, recipient, amount); - assertNotNull("insufficient funds", transaction); - } - - @Test - public void testGetWalletBalance() throws ForeignBlockchainException { - String xprv58 = "xpub661MyMwAqRbcEt3Ge1wNmkagyb1J7yTQu4Kquvy77Ycg2iPoh7Urg8s9Jdwp7YmrqGkDKJpUVjsZXSSsQgmAVUC17ZVQQeoWMzm7vDTt1y7"; - - Long balance = ravencoin.getWalletBalance(xprv58); - - assertNotNull(balance); - - System.out.println(ravencoin.format(balance)); - - // Check spent key caching doesn't affect outcome - - Long repeatBalance = ravencoin.getWalletBalance(xprv58); - - assertNotNull(repeatBalance); - - System.out.println(ravencoin.format(repeatBalance)); - - assertEquals(balance, repeatBalance); - } - - @Test - public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "xpub661MyMwAqRbcEt3Ge1wNmkagyb1J7yTQu4Kquvy77Ycg2iPoh7Urg8s9Jdwp7YmrqGkDKJpUVjsZXSSsQgmAVUC17ZVQQeoWMzm7vDTt1y7"; - - String address = ravencoin.getUnusedReceiveAddress(xprv58); - - assertNotNull(address); - - System.out.println(address); - } - + public void testBuildSpend() {} } From d9147b3af39d5f4d063a9e7521fd8195154d61fa Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 12 Aug 2023 10:24:55 +0100 Subject: [PATCH 472/496] Cache the online accounts validation result, to speed up block minting. --- src/main/java/org/qortal/block/Block.java | 17 +++++++++++++++++ .../java/org/qortal/controller/BlockMinter.java | 3 +++ 2 files changed, 20 insertions(+) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index e030e6a6..cf227d4a 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -130,6 +130,9 @@ public class Block { /** Locally-generated AT fees */ protected long ourAtFees; // Generated locally + /** Cached online accounts validation decision, to avoid revalidating when true */ + private boolean onlineAccountsAlreadyValid = false; + @FunctionalInterface private interface BlockRewardDistributor { long distribute(long amount, Map balanceChanges) throws DataException; @@ -563,6 +566,13 @@ public class Block { } + /** + * Force online accounts to be revalidated, e.g. at final stage of block minting. + */ + public void clearOnlineAccountsValidationCache() { + this.onlineAccountsAlreadyValid = false; + } + // More information /** @@ -1043,6 +1053,10 @@ public class Block { if (this.blockData.getHeight() != null && this.blockData.getHeight() == 1) return ValidationResult.OK; + // Don't bother revalidating if accounts have already been validated in this block + if (this.onlineAccountsAlreadyValid) + return ValidationResult.OK; + // Expand block's online accounts indexes into actual accounts ConciseSet accountIndexes = BlockTransformer.decodeOnlineAccounts(this.blockData.getEncodedOnlineAccounts()); // We use count of online accounts to validate decoded account indexes @@ -1130,6 +1144,9 @@ public class Block { // All online accounts valid, so save our list of online accounts for potential later use this.cachedOnlineRewardShares = onlineRewardShares; + // Remember that the accounts are valid, to speed up subsequent checks + this.onlineAccountsAlreadyValid = true; + return ValidationResult.OK; } diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index b1ed7e3c..35c89778 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -562,6 +562,9 @@ public class BlockMinter extends Thread { // Sign to create block's signature newBlock.sign(); + // Ensure online accounts are fully re-validated in this final check + newBlock.clearOnlineAccountsValidationCache(); + // Is newBlock still valid? ValidationResult validationResult = newBlock.isValid(); if (validationResult != ValidationResult.OK) From 897c44ffe87843c839deb1bb71be7544717cdb35 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 12 Aug 2023 11:14:21 +0100 Subject: [PATCH 473/496] Optimized transaction importing by using a temporary cache of unconfirmed transactions. --- .../org/qortal/controller/TransactionImporter.java | 12 ++++++++++++ .../java/org/qortal/transaction/Transaction.java | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/TransactionImporter.java b/src/main/java/org/qortal/controller/TransactionImporter.java index 5c70f369..4f3b6f2f 100644 --- a/src/main/java/org/qortal/controller/TransactionImporter.java +++ b/src/main/java/org/qortal/controller/TransactionImporter.java @@ -47,6 +47,9 @@ public class TransactionImporter extends Thread { /** Map of recent invalid unconfirmed transactions. Key is base58 transaction signature, value is do-not-request expiry timestamp. */ private final Map invalidUnconfirmedTransactions = Collections.synchronizedMap(new HashMap<>()); + /** Cached list of unconfirmed transactions, used when counting per creator. This is replaced regularly */ + public static List unconfirmedTransactionsCache = null; + public static synchronized TransactionImporter getInstance() { if (instance == null) { @@ -254,6 +257,12 @@ public class TransactionImporter extends Thread { int processedCount = 0; try (final Repository repository = RepositoryManager.getRepository()) { + // Use a single copy of the unconfirmed transactions list for each cycle, to speed up constant lookups + // when counting unconfirmed transactions by creator. + List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + unconfirmedTransactions.removeIf(t -> t.getType() == Transaction.TransactionType.CHAT); + unconfirmedTransactionsCache = unconfirmedTransactions; + // Import transactions with valid signatures try { for (int i = 0; i < sigValidTransactions.size(); ++i) { @@ -317,6 +326,9 @@ public class TransactionImporter extends Thread { } finally { LOGGER.debug("Finished importing {} incoming transaction{}", processedCount, (processedCount == 1 ? "" : "s")); blockchainLock.unlock(); + + // Clear the unconfirmed transaction cache so new data can be populated in the next cycle + unconfirmedTransactionsCache = null; } } catch (DataException e) { LOGGER.error("Repository issue while importing incoming transactions", e); diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index bd91f25a..a9eb6ae7 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -13,6 +13,7 @@ import org.qortal.account.PublicKeyAccount; import org.qortal.asset.Asset; import org.qortal.block.BlockChain; import org.qortal.controller.Controller; +import org.qortal.controller.TransactionImporter; import org.qortal.crypto.Crypto; import org.qortal.data.block.BlockData; import org.qortal.data.group.GroupApprovalData; @@ -617,7 +618,10 @@ public abstract class Transaction { } private int countUnconfirmedByCreator(PublicKeyAccount creator) throws DataException { - List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + List unconfirmedTransactions = TransactionImporter.getInstance().unconfirmedTransactionsCache; + if (unconfirmedTransactions == null) { + unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + } // We exclude CHAT transactions as they never get included into blocks and // have spam/DoS prevention by requiring proof of work From 278dca75e81ca0edb601def0ccec7f6b82f9d476 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 12 Aug 2023 15:18:29 +0100 Subject: [PATCH 474/496] Increase minimum fee at a future undecided timestamp. --- .../java/org/qortal/block/BlockChain.java | 21 +-- .../org/qortal/transaction/Transaction.java | 2 +- src/main/resources/blockchain.json | 6 +- .../java/org/qortal/test/MessageTests.java | 2 +- .../common/transaction/TestTransaction.java | 4 +- .../org/qortal/test/naming/BuySellTests.java | 2 +- .../org/qortal/test/naming/MiscTests.java | 132 +++++++++++++++++- .../test-chain-v2-block-timestamps.json | 5 +- .../test-chain-v2-disable-reference.json | 5 +- .../test-chain-v2-founder-rewards.json | 5 +- .../test-chain-v2-leftover-reward.json | 5 +- src/test/resources/test-chain-v2-minting.json | 5 +- .../test-chain-v2-qora-holder-extremes.json | 5 +- .../test-chain-v2-qora-holder-reduction.json | 5 +- .../resources/test-chain-v2-qora-holder.json | 5 +- .../test-chain-v2-reward-levels.json | 5 +- .../test-chain-v2-reward-scaling.json | 5 +- .../test-chain-v2-reward-shares.json | 5 +- .../test-chain-v2-self-sponsorship-algo.json | 5 +- src/test/resources/test-chain-v2.json | 8 +- 20 files changed, 205 insertions(+), 32 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 218fb14d..d79203da 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -48,9 +48,6 @@ public class BlockChain { /** Transaction expiry period, starting from transaction's timestamp, in milliseconds. */ private long transactionExpiryPeriod; - @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) - private long unitFee; - private int maxBytesPerUnitFee; /** Maximum acceptable timestamp disagreement offset in milliseconds. */ @@ -89,6 +86,7 @@ public class BlockChain { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long fee; } + private List unitFees; private List nameRegistrationUnitFees; /** Map of which blockchain features are enabled when (height/timestamp) */ @@ -346,10 +344,6 @@ public class BlockChain { return this.isTestChain; } - public long getUnitFee() { - return this.unitFee; - } - public int getMaxBytesPerUnitFee() { return this.maxBytesPerUnitFee; } @@ -547,13 +541,22 @@ public class BlockChain { throw new IllegalStateException(String.format("No block timing info available for height %d", ourHeight)); } + public long getUnitFeeAtTimestamp(long ourTimestamp) { + for (int i = unitFees.size() - 1; i >= 0; --i) + if (unitFees.get(i).timestamp <= ourTimestamp) + return unitFees.get(i).fee; + + // Shouldn't happen, but set a sensible default just in case + return 100000; + } + public long getNameRegistrationUnitFeeAtTimestamp(long ourTimestamp) { for (int i = nameRegistrationUnitFees.size() - 1; i >= 0; --i) if (nameRegistrationUnitFees.get(i).timestamp <= ourTimestamp) return nameRegistrationUnitFees.get(i).fee; - // Default to system-wide unit fee - return this.getUnitFee(); + // Shouldn't happen, but set a sensible default just in case + return 100000; } public int getMaxRewardSharesAtTimestamp(long ourTimestamp) { diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index a9eb6ae7..f750aff5 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -378,7 +378,7 @@ public abstract class Transaction { * @return */ public long getUnitFee(Long timestamp) { - return BlockChain.getInstance().getUnitFee(); + return BlockChain.getInstance().getUnitFeeAtTimestamp(timestamp); } /** diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index c6151204..7d203004 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -3,8 +3,12 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.001", + "unitFees": [ + { "timestamp": 0, "fee": "0.001" }, + { "timestamp": 9999999999999, "fee": "0.01" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.001" }, { "timestamp": 1645372800000, "fee": "5" }, { "timestamp": 1651420800000, "fee": "1.25" } ], diff --git a/src/test/java/org/qortal/test/MessageTests.java b/src/test/java/org/qortal/test/MessageTests.java index f08c7b2f..4d0ecfcc 100644 --- a/src/test/java/org/qortal/test/MessageTests.java +++ b/src/test/java/org/qortal/test/MessageTests.java @@ -85,7 +85,7 @@ public class MessageTests extends Common { byte[] randomReference = new byte[64]; random.nextBytes(randomReference); - long minimumFee = BlockChain.getInstance().getUnitFee(); + long minimumFee = BlockChain.getInstance().getUnitFeeAtTimestamp(System.currentTimeMillis()); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); diff --git a/src/test/java/org/qortal/test/common/transaction/TestTransaction.java b/src/test/java/org/qortal/test/common/transaction/TestTransaction.java index 11fdf58e..b580ecd3 100644 --- a/src/test/java/org/qortal/test/common/transaction/TestTransaction.java +++ b/src/test/java/org/qortal/test/common/transaction/TestTransaction.java @@ -7,13 +7,15 @@ import org.qortal.block.BlockChain; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; +import org.qortal.utils.NTP; public abstract class TestTransaction { protected static final Random random = new Random(); public static BaseTransactionData generateBase(PrivateKeyAccount account, int txGroupId) throws DataException { - return new BaseTransactionData(System.currentTimeMillis(), txGroupId, account.getLastReference(), account.getPublicKey(), BlockChain.getInstance().getUnitFee(), null); + long timestamp = System.currentTimeMillis(); + return new BaseTransactionData(timestamp, txGroupId, account.getLastReference(), account.getPublicKey(), BlockChain.getInstance().getUnitFeeAtTimestamp(timestamp), null); } public static BaseTransactionData generateBase(PrivateKeyAccount account) throws DataException { diff --git a/src/test/java/org/qortal/test/naming/BuySellTests.java b/src/test/java/org/qortal/test/naming/BuySellTests.java index 4530820e..f0e97b94 100644 --- a/src/test/java/org/qortal/test/naming/BuySellTests.java +++ b/src/test/java/org/qortal/test/naming/BuySellTests.java @@ -44,7 +44,7 @@ public class BuySellTests extends Common { bob = Common.getTestAccount(repository, "bob"); name = "test name" + " " + random.nextInt(1_000_000); - price = random.nextInt(1000) * Amounts.MULTIPLIER; + price = (random.nextInt(1000) + 1) * Amounts.MULTIPLIER; } @After diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 2bcd098d..91eb3dc9 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -20,6 +20,7 @@ import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.test.common.*; import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.PaymentTransaction; import org.qortal.transaction.RegisterNameTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -329,15 +330,19 @@ public class MiscTests extends Common { public void testRegisterNameFeeIncrease() throws Exception { try (final Repository repository = RepositoryManager.getRepository()) { - // Set nameRegistrationUnitFeeTimestamp to a time far in the future + // Add original fee to nameRegistrationUnitFees + UnitFeesByTimestamp originalFee = new UnitFeesByTimestamp(); + originalFee.timestamp = 0L; + originalFee.fee = new AmountTypeAdapter().unmarshal("0.1"); + + // Add a time far in the future to nameRegistrationUnitFees UnitFeesByTimestamp futureFeeIncrease = new UnitFeesByTimestamp(); futureFeeIncrease.timestamp = 9999999999999L; // 20 Nov 2286 futureFeeIncrease.fee = new AmountTypeAdapter().unmarshal("5"); - FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(futureFeeIncrease), true); + FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(originalFee, futureFeeIncrease), true); assertEquals(futureFeeIncrease.fee, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); // Validate unit fees pre and post timestamp - assertEquals(10000000, BlockChain.getInstance().getUnitFee()); // 0.1 QORT assertEquals(10000000, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(futureFeeIncrease.timestamp - 1)); // 0.1 QORT assertEquals(500000000, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); // 5 QORT @@ -362,7 +367,7 @@ public class MiscTests extends Common { futureFeeIncrease.timestamp = now + (60 * 60 * 1000L); // 1 hour in the future futureFeeIncrease.fee = new AmountTypeAdapter().unmarshal("10"); - FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(pastFeeIncrease, futureFeeIncrease), true); + FieldUtils.writeField(BlockChain.getInstance(), "nameRegistrationUnitFees", Arrays.asList(originalFee, pastFeeIncrease, futureFeeIncrease), true); assertEquals(pastFeeIncrease.fee, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(pastFeeIncrease.timestamp)); assertEquals(futureFeeIncrease.fee, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); @@ -387,4 +392,123 @@ public class MiscTests extends Common { } } + // test reading the name registration fee schedule from blockchain.json / test-chain-v2.json + @Test + public void testRegisterNameFeeScheduleInTestchainData() throws Exception { + try (final Repository repository = RepositoryManager.getRepository()) { + + final long expectedFutureFeeIncreaseTimestamp = 9999999999999L; // 20 Nov 2286, as per test-chain-v2.json + final long expectedFutureFeeIncreaseValue = new AmountTypeAdapter().unmarshal("5"); + + assertEquals(expectedFutureFeeIncreaseValue, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp)); + + // Validate unit fees pre and post timestamp + assertEquals(10000000, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp - 1)); // 0.1 QORT + assertEquals(500000000, BlockChain.getInstance().getNameRegistrationUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp)); // 5 QORT + + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + assertEquals(10000000L, transactionData.getFee().longValue()); + TransactionUtils.signAndMint(repository, transactionData, alice); + } + } + + + + // test general unit fee increase + @Test + public void testUnitFeeIncrease() throws Exception { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Add original fee to unitFees + UnitFeesByTimestamp originalFee = new UnitFeesByTimestamp(); + originalFee.timestamp = 0L; + originalFee.fee = new AmountTypeAdapter().unmarshal("0.1"); + + // Add a time far in the future to unitFees + UnitFeesByTimestamp futureFeeIncrease = new UnitFeesByTimestamp(); + futureFeeIncrease.timestamp = 9999999999999L; // 20 Nov 2286 + futureFeeIncrease.fee = new AmountTypeAdapter().unmarshal("1"); + FieldUtils.writeField(BlockChain.getInstance(), "unitFees", Arrays.asList(originalFee, futureFeeIncrease), true); + assertEquals(futureFeeIncrease.fee, BlockChain.getInstance().getUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); + + // Validate unit fees pre and post timestamp + assertEquals(10000000, BlockChain.getInstance().getUnitFeeAtTimestamp(futureFeeIncrease.timestamp - 1)); // 0.1 QORT + assertEquals(100000000, BlockChain.getInstance().getUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); // 1 QORT + + // Payment + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + PaymentTransactionData transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 100000); + transactionData.setFee(new PaymentTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + assertEquals(10000000L, transactionData.getFee().longValue()); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Set fee increase to a time in the past + Long now = NTP.getTime(); + UnitFeesByTimestamp pastFeeIncrease = new UnitFeesByTimestamp(); + pastFeeIncrease.timestamp = now - 1000L; // 1 second ago + pastFeeIncrease.fee = new AmountTypeAdapter().unmarshal("3"); + + // Set another increase in the future + futureFeeIncrease = new UnitFeesByTimestamp(); + futureFeeIncrease.timestamp = now + (60 * 60 * 1000L); // 1 hour in the future + futureFeeIncrease.fee = new AmountTypeAdapter().unmarshal("10"); + + FieldUtils.writeField(BlockChain.getInstance(), "unitFees", Arrays.asList(originalFee, pastFeeIncrease, futureFeeIncrease), true); + assertEquals(originalFee.fee, BlockChain.getInstance().getUnitFeeAtTimestamp(originalFee.timestamp)); + assertEquals(pastFeeIncrease.fee, BlockChain.getInstance().getUnitFeeAtTimestamp(pastFeeIncrease.timestamp)); + assertEquals(futureFeeIncrease.fee, BlockChain.getInstance().getUnitFeeAtTimestamp(futureFeeIncrease.timestamp)); + + // Send another payment transaction + // Fee should be determined automatically + transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 50000); + assertEquals(300000000L, transactionData.getFee().longValue()); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(alice); + ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be valid", ValidationResult.OK == result); + + // Now try fetching and setting fee manually + transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 50000); + transactionData.setFee(new PaymentTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + assertEquals(300000000L, transactionData.getFee().longValue()); + transaction = Transaction.fromData(repository, transactionData); + transaction.sign(alice); + result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be valid", ValidationResult.OK == result); + } + } + + // test reading the fee schedule from blockchain.json / test-chain-v2.json + @Test + public void testFeeScheduleInTestchainData() throws Exception { + try (final Repository repository = RepositoryManager.getRepository()) { + + final long expectedFutureFeeIncreaseTimestamp = 9999999999999L; // 20 Nov 2286, as per test-chain-v2.json + final long expectedFutureFeeIncreaseValue = new AmountTypeAdapter().unmarshal("1"); + + assertEquals(expectedFutureFeeIncreaseValue, BlockChain.getInstance().getUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp)); + + // Validate unit fees pre and post timestamp + assertEquals(10000000, BlockChain.getInstance().getUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp - 1)); // 0.1 QORT + assertEquals(100000000, BlockChain.getInstance().getUnitFeeAtTimestamp(expectedFutureFeeIncreaseTimestamp)); // 1 QORT + + // Payment + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + PaymentTransactionData transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 100000); + transactionData.setFee(new PaymentTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + assertEquals(10000000L, transactionData.getFee().longValue()); + TransactionUtils.signAndMint(repository, transactionData, alice); + } + } + } diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 3b4de702..25915e9a 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index c93fbb78..ae499491 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 1b068932..e6b327b1 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index aef76cc2..9fda9b1f 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index db6d8a0b..4ff3ec3c 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 2452d4d2..306fd9a3 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 23193729..6128baa7 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 9d81632b..9b6bccda 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index 81609595..1ffa8baf 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 21a5b7a7..6c3b3276 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 6119ac48..09627e1f 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.1" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index dc5f3961..81cda1e8 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -4,8 +4,11 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 0, - "unitFee": "0.00000001", + "unitFees": [ + { "timestamp": 0, "fee": "0.00000001" } + ], "nameRegistrationUnitFees": [ + { "timestamp": 0, "fee": "0.00000001" }, { "timestamp": 1645372800000, "fee": "5" } ], "requireGroupForApproval": false, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index d0c460df..2e96e911 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -4,9 +4,13 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.1", + "unitFees": [ + { "timestamp": 0, "fee": "0.1" }, + { "timestamp": 9999999999999, "fee": "1" } + ], "nameRegistrationUnitFees": [ - { "timestamp": 1645372800000, "fee": "5" } + { "timestamp": 0, "fee": "0.1" }, + { "timestamp": 9999999999999, "fee": "5" } ], "requireGroupForApproval": false, "minAccountLevelToRewardShare": 5, From e244a5f882552313b960301b0b0800ed0971d3e4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 12 Aug 2023 16:09:41 +0100 Subject: [PATCH 475/496] Removed legacy code that is no longer needed. --- .../java/org/qortal/controller/Synchronizer.java | 16 +++------------- src/main/java/org/qortal/settings/Settings.java | 2 +- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 2dad62e7..804bacbb 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -229,13 +229,6 @@ public class Synchronizer extends Thread { peers.removeIf(Controller.hasOldVersion); checkRecoveryModeForPeers(peers); - if (recoveryMode) { - // Needs a mutable copy of the unmodifiableList - peers = new ArrayList<>(Network.getInstance().getImmutableHandshakedPeers()); - peers.removeIf(Controller.hasOnlyGenesisBlock); - peers.removeIf(Controller.hasMisbehaved); - peers.removeIf(Controller.hasOldVersion); - } // Check we have enough peers to potentially synchronize if (peers.size() < Settings.getInstance().getMinBlockchainPeers()) @@ -262,10 +255,7 @@ public class Synchronizer extends Thread { peers.removeIf(Controller.hasInferiorChainTip); // Remove any peers that are no longer on a recent block since the last check - // Except for times when we're in recovery mode, in which case we need to keep them - if (!recoveryMode) { - peers.removeIf(Controller.hasNoRecentBlock); - } + peers.removeIf(Controller.hasNoRecentBlock); final int peersRemoved = peersBeforeComparison - peers.size(); if (peersRemoved > 0 && peers.size() > 0) @@ -1340,8 +1330,8 @@ public class Synchronizer extends Thread { return SynchronizationResult.INVALID_DATA; } - // Final check to make sure the peer isn't out of date (except for when we're in recovery mode) - if (!recoveryMode && peer.getChainTipData() != null) { + // Final check to make sure the peer isn't out of date + if (peer.getChainTipData() != null) { final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); final Long peerLastBlockTimestamp = peer.getChainTipData().getTimestamp(); if (peerLastBlockTimestamp == null || peerLastBlockTimestamp < minLatestBlockTimestamp) { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index ac9b8857..8d4eab56 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -227,7 +227,7 @@ public class Settings { private int maxRetries = 2; /** The number of seconds of no activity before recovery mode begins */ - public long recoveryModeTimeout = 24 * 60 * 60 * 1000L; + public long recoveryModeTimeout = 9999999999999L; /** Minimum peer version number required in order to sync with them */ private String minPeerVersion = "4.1.2"; From 640a4728764aab34cb11bc48f3a877b1393a18af Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 12 Aug 2023 18:55:27 +0100 Subject: [PATCH 476/496] Unit fee increase set to 1692118800000 --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 7d203004..1cf4f010 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -5,7 +5,7 @@ "maxBytesPerUnitFee": 1024, "unitFees": [ { "timestamp": 0, "fee": "0.001" }, - { "timestamp": 9999999999999, "fee": "0.01" } + { "timestamp": 1692118800000, "fee": "0.01" } ], "nameRegistrationUnitFees": [ { "timestamp": 0, "fee": "0.001" }, From eea288411215cc38e75269bd6abef7809ee2a901 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 12 Aug 2023 18:59:00 +0100 Subject: [PATCH 477/496] Bump version to 4.2.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 30ad1e04..b18cd8ae 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.2.2 + 4.2.3 jar true From dd9d3fdb22ded705260016ca4e9427fe6da3fd59 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 12 Aug 2023 19:27:19 +0100 Subject: [PATCH 478/496] Add to the unconfirmed transactions cache when importing a transaction. --- src/main/java/org/qortal/controller/TransactionImporter.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/controller/TransactionImporter.java b/src/main/java/org/qortal/controller/TransactionImporter.java index 4f3b6f2f..fa35cbbe 100644 --- a/src/main/java/org/qortal/controller/TransactionImporter.java +++ b/src/main/java/org/qortal/controller/TransactionImporter.java @@ -295,6 +295,11 @@ public class TransactionImporter extends Thread { case OK: { LOGGER.debug(() -> String.format("Imported %s transaction %s", transactionData.getType().name(), Base58.encode(transactionData.getSignature()))); + + // Add to the unconfirmed transactions cache + if (transactionData.getType() != Transaction.TransactionType.CHAT && unconfirmedTransactionsCache != null) { + unconfirmedTransactionsCache.add(transactionData); + } break; } From ecfb9a7d6dc511db3f0d2f28dc763b759bf89135 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 12 Aug 2023 19:34:59 +0100 Subject: [PATCH 479/496] Bump version to 4.2.4 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b18cd8ae..5ba103e6 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.2.3 + 4.2.4 jar true From 6bf2b999136c85c94ba08ba6723c6a0646fcce5a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 18 Aug 2023 15:37:24 +0100 Subject: [PATCH 480/496] Wait until unconfirmed transactions are considered to be valid before broadcasting them. --- .../controller/TransactionImporter.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/TransactionImporter.java b/src/main/java/org/qortal/controller/TransactionImporter.java index fa35cbbe..6c846f3b 100644 --- a/src/main/java/org/qortal/controller/TransactionImporter.java +++ b/src/main/java/org/qortal/controller/TransactionImporter.java @@ -218,12 +218,6 @@ public class TransactionImporter extends Thread { LOGGER.debug("Finished validating signatures in incoming transactions queue (valid this round: {}, total pending import: {})...", validatedCount, sigValidTransactions.size()); } - if (!newlyValidSignatures.isEmpty()) { - LOGGER.debug("Broadcasting {} newly valid signatures ahead of import", newlyValidSignatures.size()); - Message newTransactionSignatureMessage = new TransactionSignaturesMessage(newlyValidSignatures); - Network.getInstance().broadcast(broadcastPeer -> newTransactionSignatureMessage); - } - } catch (DataException e) { LOGGER.error("Repository issue while processing incoming transactions", e); } @@ -263,6 +257,9 @@ public class TransactionImporter extends Thread { unconfirmedTransactions.removeIf(t -> t.getType() == Transaction.TransactionType.CHAT); unconfirmedTransactionsCache = unconfirmedTransactions; + // A list of signatures were imported in this round + List newlyImportedSignatures = new ArrayList<>(); + // Import transactions with valid signatures try { for (int i = 0; i < sigValidTransactions.size(); ++i) { @@ -300,6 +297,10 @@ public class TransactionImporter extends Thread { if (transactionData.getType() != Transaction.TransactionType.CHAT && unconfirmedTransactionsCache != null) { unconfirmedTransactionsCache.add(transactionData); } + + // Signature imported in this round + newlyImportedSignatures.add(transactionData.getSignature()); + break; } @@ -328,6 +329,12 @@ public class TransactionImporter extends Thread { // Transaction has been processed, even if only to reject it removeIncomingTransaction(transactionData.getSignature()); } + + if (!newlyImportedSignatures.isEmpty()) { + LOGGER.debug("Broadcasting {} newly imported signatures", newlyImportedSignatures.size()); + Message newTransactionSignatureMessage = new TransactionSignaturesMessage(newlyImportedSignatures); + Network.getInstance().broadcast(broadcastPeer -> newTransactionSignatureMessage); + } } finally { LOGGER.debug("Finished importing {} incoming transaction{}", processedCount, (processedCount == 1 ? "" : "s")); blockchainLock.unlock(); From b0224651c2b030ec68a80c25127d38eb8c500596 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 18 Aug 2023 20:32:44 +0100 Subject: [PATCH 481/496] Always use rate limiter for metadata requests, and sleep for a random amount of time between fetching metadata items. --- src/main/java/org/qortal/api/resource/ArbitraryResource.java | 2 +- .../qortal/controller/arbitrary/ArbitraryDataManager.java | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index c617b517..e7a20d0e 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -724,7 +724,7 @@ public class ArbitraryResource { ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier); try { - ArbitraryDataTransactionMetadata transactionMetadata = ArbitraryMetadataManager.getInstance().fetchMetadata(resource, false); + ArbitraryDataTransactionMetadata transactionMetadata = ArbitraryMetadataManager.getInstance().fetchMetadata(resource, true); if (transactionMetadata != null) { ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata, true); if (resourceMetadata != null) { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java index 9284e672..470fbda9 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataManager.java @@ -275,7 +275,10 @@ public class ArbitraryDataManager extends Thread { int offset = 0; while (!isStopping) { - Thread.sleep(1000L); + final int minSeconds = 3; + final int maxSeconds = 10; + final int randomSleepTime = new Random().nextInt((maxSeconds - minSeconds + 1)) + minSeconds; + Thread.sleep(randomSleepTime * 1000L); // Any arbitrary transactions we want to fetch data for? try (final Repository repository = RepositoryManager.getRepository()) { From 7bb61ec564a49808c07859ba5c9e42f9f7872a83 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 19 Aug 2023 13:57:26 +0100 Subject: [PATCH 482/496] Update various transaction types at a future unknown timestamp. - PUBLICIZE transactions are no longer possible. - ARBITRARY transactions are now only possible using a fee. - MESSAGE transactions only confirm when they are being sent to an AT. Messages to regular addresses (or no recipient) will expire after 24 hours. - Difficulty for confirmed MESSAGE transactions increases from 14 to 16. - Difficulty for unconfirmed MESSAGE transactions decreases from 14 to 12. --- src/main/java/org/qortal/block/Block.java | 8 + .../java/org/qortal/block/BlockChain.java | 8 + .../transaction/ArbitraryTransaction.java | 20 +- .../qortal/transaction/ChatTransaction.java | 6 + .../transaction/MessageTransaction.java | 40 +++- .../transaction/PresenceTransaction.java | 6 + .../transaction/PublicizeTransaction.java | 7 + .../org/qortal/transaction/Transaction.java | 19 +- src/main/resources/blockchain.json | 1 + .../java/org/qortal/test/MemoryPoWTests.java | 9 +- .../java/org/qortal/test/MessageTests.java | 225 +++++++++++++++--- .../ArbitraryTransactionMetadataTests.java | 17 +- .../arbitrary/ArbitraryTransactionTests.java | 55 +---- .../qortal/test/common/ArbitraryUtils.java | 11 +- .../org/qortal/test/naming/MiscTests.java | 7 +- .../test-chain-v2-block-timestamps.json | 1 + .../test-chain-v2-disable-reference.json | 1 + .../test-chain-v2-founder-rewards.json | 1 + .../test-chain-v2-leftover-reward.json | 1 + src/test/resources/test-chain-v2-minting.json | 1 + .../test-chain-v2-qora-holder-extremes.json | 1 + .../test-chain-v2-qora-holder-reduction.json | 1 + .../resources/test-chain-v2-qora-holder.json | 1 + .../test-chain-v2-reward-levels.json | 1 + .../test-chain-v2-reward-scaling.json | 1 + .../test-chain-v2-reward-shares.json | 1 + .../test-chain-v2-self-sponsorship-algo.json | 1 + src/test/resources/test-chain-v2.json | 1 + 28 files changed, 341 insertions(+), 111 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index cf227d4a..16c061da 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -84,6 +84,7 @@ public class Block { TRANSACTION_PROCESSING_FAILED(53), TRANSACTION_ALREADY_PROCESSED(54), TRANSACTION_NEEDS_APPROVAL(55), + TRANSACTION_NOT_CONFIRMABLE(56), AT_STATES_MISMATCH(61), ONLINE_ACCOUNTS_INVALID(70), ONLINE_ACCOUNT_UNKNOWN(71), @@ -1251,6 +1252,13 @@ public class Block { || transaction.getDeadline() <= this.blockData.getTimestamp()) return ValidationResult.TRANSACTION_TIMESTAMP_INVALID; + // After feature trigger, check that this transaction is confirmable + if (transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + if (!transaction.isConfirmable()) { + return ValidationResult.TRANSACTION_NOT_CONFIRMABLE; + } + } + // Check transaction isn't already included in a block if (this.repository.getTransactionRepository().isConfirmed(transactionData.getSignature())) return ValidationResult.TRANSACTION_ALREADY_PROCESSED; diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index d79203da..540e6cf4 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -209,6 +209,9 @@ public class BlockChain { /** Snapshot timestamp for self sponsorship algo V1 */ private long selfSponsorshipAlgoV1SnapshotTimestamp; + /** Feature-trigger timestamp to modify behaviour of various transactions that support mempow */ + private long mempowTransactionUpdatesTimestamp; + /** Max reward shares by block height */ public static class MaxRewardSharesByTimestamp { public long timestamp; @@ -370,6 +373,11 @@ public class BlockChain { return this.selfSponsorshipAlgoV1SnapshotTimestamp; } + // Feature-trigger timestamp to modify behaviour of various transactions that support mempow + public long getMemPoWTransactionUpdatesTimestamp() { + return this.mempowTransactionUpdatesTimestamp; + } + /** Returns true if approval-needing transaction types require a txGroupId other than NO_GROUP. */ public boolean getRequireGroupForApproval() { return this.requireGroupForApproval; diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index 7034d7b8..a3f4827b 100644 --- a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java +++ b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java @@ -1,6 +1,5 @@ package org.qortal.transaction; -import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -8,7 +7,6 @@ import java.util.stream.Collectors; 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; @@ -88,8 +86,14 @@ public class ArbitraryTransaction extends Transaction { if (this.transactionData.getFee() < 0) return ValidationResult.NEGATIVE_FEE; - // After the feature trigger, we require the fee to be sufficient if it's not 0. - // If the fee is zero, then the nonce is validated in isSignatureValid() as an alternative to a fee + // As of the mempow transaction updates timestamp, a nonce is no longer supported, so a valid fee must be included + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + // Validate the fee + return super.isFeeValid(); + } + + // After the earlier "optional fee" feature trigger, we required the fee to be sufficient if it wasn't 0. + // If the fee was zero, then the nonce was validated in isSignatureValid() as an alternative to a fee if (this.arbitraryTransactionData.getTimestamp() >= BlockChain.getInstance().getArbitraryOptionalFeeTimestamp() && this.arbitraryTransactionData.getFee() != 0L) { return super.isFeeValid(); } @@ -214,7 +218,13 @@ public class ArbitraryTransaction extends Transaction { // Clear nonce from transactionBytes ArbitraryTransactionTransformer.clearNonce(transactionBytes); - // As of feature-trigger timestamp, we only require a nonce when the fee is zero + // As of the mempow transaction updates timestamp, a nonce is no longer supported, so a fee must be included + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + // Require that the fee is a positive number. Fee checking itself is performed in isFeeValid() + return (this.arbitraryTransactionData.getFee() > 0L); + } + + // As of the earlier "optional fee" feature-trigger timestamp, we only required a nonce when the fee was zero boolean beforeFeatureTrigger = this.arbitraryTransactionData.getTimestamp() < BlockChain.getInstance().getArbitraryOptionalFeeTimestamp(); if (beforeFeatureTrigger || this.arbitraryTransactionData.getFee() == 0L) { // We only need to check nonce for recent transactions due to PoW verification overhead diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index f6e26802..3d968461 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -148,6 +148,12 @@ public class ChatTransaction extends Transaction { // Nothing to do } + @Override + public boolean isConfirmable() { + // CHAT transactions can't go into blocks + return false; + } + @Override public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import diff --git a/src/main/java/org/qortal/transaction/MessageTransaction.java b/src/main/java/org/qortal/transaction/MessageTransaction.java index a9d3a01c..b61c3d11 100644 --- a/src/main/java/org/qortal/transaction/MessageTransaction.java +++ b/src/main/java/org/qortal/transaction/MessageTransaction.java @@ -33,7 +33,9 @@ public class MessageTransaction extends Transaction { public static final int MAX_DATA_SIZE = 4000; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY = 14; // leading zero bits + public static final int POW_DIFFICULTY_V1 = 14; // leading zero bits + public static final int POW_DIFFICULTY_V2_CONFIRMABLE = 16; // leading zero bits + public static final int POW_DIFFICULTY_V2_UNCONFIRMABLE = 12; // leading zero bits // Properties @@ -109,7 +111,17 @@ public class MessageTransaction extends Transaction { MessageTransactionTransformer.clearNonce(transactionBytes); // Calculate nonce - this.messageTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY)); + this.messageTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, getPoWDifficulty())); + } + + public int getPoWDifficulty() { + // The difficulty changes at the "mempow transactions updates" timestamp + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + // If this message is confirmable then require a higher difficulty + return this.isConfirmable() ? POW_DIFFICULTY_V2_CONFIRMABLE : POW_DIFFICULTY_V2_UNCONFIRMABLE; + } + // Before feature trigger timestamp, so use existing difficulty value + return POW_DIFFICULTY_V1; } /** @@ -183,6 +195,18 @@ public class MessageTransaction extends Transaction { return super.hasValidReference(); } + @Override + public boolean isConfirmable() { + // After feature trigger timestamp, only messages to an AT address can confirm + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + if (this.messageTransactionData.getRecipient() == null || !this.messageTransactionData.getRecipient().toUpperCase().startsWith("A")) { + // Message isn't to an AT address, so this transaction is unconfirmable + return false; + } + } + return true; + } + @Override public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import @@ -235,7 +259,7 @@ public class MessageTransaction extends Transaction { MessageTransactionTransformer.clearNonce(transactionBytes); // Check nonce - return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce); + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, getPoWDifficulty(), nonce); } @Override @@ -256,6 +280,11 @@ public class MessageTransaction extends Transaction { @Override public void process() throws DataException { + // Only certain MESSAGE transactions are able to confirm + if (!this.isConfirmable()) { + throw new DataException("Unconfirmable MESSAGE transactions should never be processed"); + } + // If we have no amount then there's nothing to do if (this.messageTransactionData.getAmount() == 0L) return; @@ -280,6 +309,11 @@ public class MessageTransaction extends Transaction { @Override public void orphan() throws DataException { + // Only certain MESSAGE transactions are able to confirm + if (!this.isConfirmable()) { + throw new DataException("Unconfirmable MESSAGE transactions should never be orphaned"); + } + // If we have no amount then there's nothing to do if (this.messageTransactionData.getAmount() == 0L) return; diff --git a/src/main/java/org/qortal/transaction/PresenceTransaction.java b/src/main/java/org/qortal/transaction/PresenceTransaction.java index 8076997c..56a9f633 100644 --- a/src/main/java/org/qortal/transaction/PresenceTransaction.java +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -155,6 +155,12 @@ public class PresenceTransaction extends Transaction { // Nothing to do } + @Override + public boolean isConfirmable() { + // PRESENCE transactions can't go into blocks + return false; + } + @Override public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import diff --git a/src/main/java/org/qortal/transaction/PublicizeTransaction.java b/src/main/java/org/qortal/transaction/PublicizeTransaction.java index 76fef00b..44f93e6e 100644 --- a/src/main/java/org/qortal/transaction/PublicizeTransaction.java +++ b/src/main/java/org/qortal/transaction/PublicizeTransaction.java @@ -7,6 +7,7 @@ import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; import org.qortal.asset.Asset; +import org.qortal.block.BlockChain; import org.qortal.crypto.MemoryPoW; import org.qortal.data.transaction.PublicizeTransactionData; import org.qortal.data.transaction.TransactionData; @@ -89,6 +90,12 @@ public class PublicizeTransaction extends Transaction { @Override public ValidationResult isValid() throws DataException { + // Disable completely after feature-trigger timestamp, at the same time that mempow difficulties are being increased. + // It could be enabled again in the future, but preferably with an enforced minimum fee instead of allowing a mempow nonce. + if (this.transactionData.getTimestamp() >= BlockChain.getInstance().getMemPoWTransactionUpdatesTimestamp()) { + return ValidationResult.NOT_SUPPORTED; + } + // There can be only one List signatures = this.repository.getTransactionRepository().getSignaturesMatchingCriteria( TransactionType.PUBLICIZE, diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index f750aff5..e0ed1f82 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -248,7 +248,8 @@ public abstract class Transaction { GROUP_APPROVAL_REQUIRED(98), ACCOUNT_NOT_TRANSFERABLE(99), INVALID_BUT_OK(999), - NOT_YET_RELEASED(1000); + NOT_YET_RELEASED(1000), + NOT_SUPPORTED(1001); public final int value; @@ -636,7 +637,7 @@ public abstract class Transaction { } /** - * Returns sorted, unconfirmed transactions, excluding invalid. + * Returns sorted, unconfirmed transactions, excluding invalid and unconfirmable. * * @return sorted, unconfirmed transactions * @throws DataException @@ -654,7 +655,8 @@ public abstract class Transaction { TransactionData transactionData = unconfirmedTransactionsIterator.next(); Transaction transaction = Transaction.fromData(repository, transactionData); - if (transaction.isStillValidUnconfirmed(latestBlockData.getTimestamp()) != ValidationResult.OK) + // Must be confirmable and valid + if (!transaction.isConfirmable() || transaction.isStillValidUnconfirmed(latestBlockData.getTimestamp()) != ValidationResult.OK) unconfirmedTransactionsIterator.remove(); } @@ -892,6 +894,17 @@ public abstract class Transaction { /* To be optionally overridden */ } + /** + * Returns whether transaction is 'confirmable' - i.e. is of a type that + * can be included in a block. Some transactions are 'unconfirmable' + * and therefore must remain in the mempool until they expire. + * @return + */ + public boolean isConfirmable() { + /* To be optionally overridden */ + return true; + } + /** * Returns whether transaction can be added to the blockchain. *

diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 1cf4f010..996ae2c1 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -29,6 +29,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, "selfSponsorshipAlgoV1SnapshotTimestamp": 1670230000000, + "mempowTransactionUpdatesTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, diff --git a/src/test/java/org/qortal/test/MemoryPoWTests.java b/src/test/java/org/qortal/test/MemoryPoWTests.java index 662fab19..3b0045e5 100644 --- a/src/test/java/org/qortal/test/MemoryPoWTests.java +++ b/src/test/java/org/qortal/test/MemoryPoWTests.java @@ -3,6 +3,8 @@ package org.qortal.test; import org.junit.Ignore; import org.junit.Test; import org.qortal.crypto.MemoryPoW; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; import static org.junit.Assert.*; @@ -39,13 +41,14 @@ public class MemoryPoWTests { } @Test - public void testMultipleComputes() { + public void testMultipleComputes() throws DataException { + Common.useDefaultSettings(); Random random = new Random(); - final int sampleSize = 20; + final int sampleSize = 10; final long stddevDivisor = sampleSize * (sampleSize - 1); - for (int difficulty = 8; difficulty < 16; difficulty += 2) { + for (int difficulty = 8; difficulty <= 16; difficulty++) { byte[] data = new byte[256]; long[] times = new long[sampleSize]; diff --git a/src/test/java/org/qortal/test/MessageTests.java b/src/test/java/org/qortal/test/MessageTests.java index 4d0ecfcc..3e549235 100644 --- a/src/test/java/org/qortal/test/MessageTests.java +++ b/src/test/java/org/qortal/test/MessageTests.java @@ -1,5 +1,6 @@ package org.qortal.test; +import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -14,12 +15,9 @@ import org.qortal.group.Group.ApprovalThreshold; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.GroupUtils; -import org.qortal.test.common.TestAccount; -import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.*; import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.TransactionType; @@ -139,7 +137,7 @@ public class MessageTests extends Common { } @Test - public void withRecipentWithAmount() throws DataException { + public void withRecipientWithAmount() throws DataException { testMessage(Group.NO_GROUP, recipient, 123L, Asset.QORT); } @@ -153,6 +151,132 @@ public class MessageTests extends Common { testMessage(1, null, 0L, null); } + @Test + public void atRecipientNoFeeWithNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String atRecipient = deployAt(); + MessageTransaction transaction = testFeeNonce(repository, false, true, atRecipient, true); + + // Transaction should be confirmable because it's to an AT, and therefore should be present in a block + assertTrue(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertTrue(isTransactionConfirmed(repository, transaction)); + assertEquals(16, transaction.getPoWDifficulty()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void regularRecipientNoFeeWithNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + + MessageTransaction transaction = testFeeNonce(repository, false, true, recipient, true); + + // Transaction shouldn't be confirmable because it's not to an AT, and therefore shouldn't be present in a block + assertFalse(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertFalse(isTransactionConfirmed(repository, transaction)); + assertEquals(12, transaction.getPoWDifficulty()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void noRecipientNoFeeWithNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + + MessageTransaction transaction = testFeeNonce(repository, false, true, null, true); + + // Transaction shouldn't be confirmable because it's not to an AT, and therefore shouldn't be present in a block + assertFalse(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertFalse(isTransactionConfirmed(repository, transaction)); + assertEquals(12, transaction.getPoWDifficulty()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void atRecipientWithFeeNoNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String atRecipient = deployAt(); + MessageTransaction transaction = testFeeNonce(repository, true, false, atRecipient, true); + + // Transaction should be confirmable because it's to an AT, and therefore should be present in a block + assertTrue(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertTrue(isTransactionConfirmed(repository, transaction)); + assertEquals(16, transaction.getPoWDifficulty()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void regularRecipientWithFeeNoNonce() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + + MessageTransaction transaction = testFeeNonce(repository, true, false, recipient, true); + + // Transaction shouldn't be confirmable because it's not to an AT, and therefore shouldn't be present in a block + assertFalse(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertFalse(isTransactionConfirmed(repository, transaction)); + assertEquals(12, transaction.getPoWDifficulty()); + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void atRecipientNoFeeWithNonceLegacyDifficulty() throws DataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Set mempowTransactionUpdatesTimestamp to a high value, so that it hasn't activated key + FieldUtils.writeField(BlockChain.getInstance(), "mempowTransactionUpdatesTimestamp", Long.MAX_VALUE, true); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String atRecipient = deployAt(); + MessageTransaction transaction = testFeeNonce(repository, false, true, atRecipient, true); + + // Transaction should be confirmable because all MESSAGE transactions confirmed prior to the feature trigger + assertTrue(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertTrue(isTransactionConfirmed(repository, transaction)); + assertEquals(14, transaction.getPoWDifficulty()); // Legacy difficulty was 14 in all cases + + BlockUtils.orphanLastBlock(repository); + } + } + + @Test + public void regularRecipientNoFeeWithNonceLegacyDifficulty() throws DataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Set mempowTransactionUpdatesTimestamp to a high value, so that it hasn't activated key + FieldUtils.writeField(BlockChain.getInstance(), "mempowTransactionUpdatesTimestamp", Long.MAX_VALUE, true); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + MessageTransaction transaction = testFeeNonce(repository, false, true, recipient, true); + + // Transaction should be confirmable because all MESSAGE transactions confirmed prior to the feature trigger + assertTrue(transaction.isConfirmable()); + TransactionUtils.signAndMint(repository, transaction.getTransactionData(), alice); + assertTrue(isTransactionConfirmed(repository, transaction)); // All MESSAGE transactions would confirm before feature trigger + assertEquals(14, transaction.getPoWDifficulty()); // Legacy difficulty was 14 in all cases + + BlockUtils.orphanLastBlock(repository); + } + } + @Test public void serializationTests() throws DataException, TransformationException { // with recipient, with amount @@ -165,6 +289,24 @@ public class MessageTests extends Common { testSerialization(null, 0L, null); } + private String deployAt() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + String address = deployAtTransaction.getATAccount().getAddress(); + assertNotNull(address); + return address; + } + } + + private boolean isTransactionConfirmed(Repository repository, MessageTransaction transaction) throws DataException { + TransactionData queriedTransactionData = repository.getTransactionRepository().fromSignature(transaction.getTransactionData().getSignature()); + return queriedTransactionData.getBlockHeight() != null && queriedTransactionData.getBlockHeight() > 0; + } + private boolean isValid(int txGroupId, String recipient, long amount, Long assetId) throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { TestAccount alice = Common.getTestAccount(repository, "alice"); @@ -195,41 +337,48 @@ public class MessageTests extends Common { return messageTransaction.hasValidReference(); } - private void testFeeNonce(boolean withFee, boolean withNonce, boolean isValid) throws DataException { + + private MessageTransaction testFeeNonce(boolean withFee, boolean withNonce, boolean isValid) throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { - TestAccount alice = Common.getTestAccount(repository, "alice"); - - int txGroupId = 0; - int nonce = 0; - long amount = 0; - long assetId = Asset.QORT; - byte[] data = new byte[1]; - boolean isText = false; - boolean isEncrypted = false; - - MessageTransactionData transactionData = new MessageTransactionData(TestTransaction.generateBase(alice, txGroupId), - version, nonce, recipient, amount, assetId, data, isText, isEncrypted); - - MessageTransaction transaction = new MessageTransaction(repository, transactionData); - - if (withFee) - transactionData.setFee(transaction.calcRecommendedFee()); - else - transactionData.setFee(0L); - - if (withNonce) { - transaction.computeNonce(); - } else { - transactionData.setNonce(-1); - } - - transaction.sign(alice); - - assertEquals(isValid, transaction.isSignatureValid()); + return testFeeNonce(repository, withFee, withNonce, recipient, isValid); } } - private void testMessage(int txGroupId, String recipient, long amount, Long assetId) throws DataException { + private MessageTransaction testFeeNonce(Repository repository, boolean withFee, boolean withNonce, String recipient, boolean isValid) throws DataException { + TestAccount alice = Common.getTestAccount(repository, "alice"); + + int txGroupId = 0; + int nonce = 0; + long amount = 0; + long assetId = Asset.QORT; + byte[] data = new byte[1]; + boolean isText = false; + boolean isEncrypted = false; + + MessageTransactionData transactionData = new MessageTransactionData(TestTransaction.generateBase(alice, txGroupId), + version, nonce, recipient, amount, assetId, data, isText, isEncrypted); + + MessageTransaction transaction = new MessageTransaction(repository, transactionData); + + if (withFee) + transactionData.setFee(transaction.calcRecommendedFee()); + else + transactionData.setFee(0L); + + if (withNonce) { + transaction.computeNonce(); + } else { + transactionData.setNonce(-1); + } + + transaction.sign(alice); + + assertEquals(isValid, transaction.isSignatureValid()); + + return transaction; + } + + private MessageTransaction testMessage(int txGroupId, String recipient, long amount, Long assetId) throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { TestAccount alice = Common.getTestAccount(repository, "alice"); @@ -244,6 +393,8 @@ public class MessageTests extends Common { TransactionUtils.signAndMint(repository, transactionData, alice); BlockUtils.orphanLastBlock(repository); + + return new MessageTransaction(repository, transactionData); } } diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java index 47c68b25..9ac73166 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionMetadataTests.java @@ -11,6 +11,7 @@ import org.qortal.arbitrary.ArbitraryDataReader; import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; +import org.qortal.block.BlockChain; import org.qortal.controller.arbitrary.ArbitraryDataManager; import org.qortal.data.arbitrary.ArbitraryResourceMetadata; import org.qortal.data.transaction.ArbitraryTransactionData; @@ -24,6 +25,7 @@ import org.qortal.test.common.TransactionUtils; import org.qortal.test.common.transaction.TestTransaction; import org.qortal.transaction.RegisterNameTransaction; import org.qortal.utils.Base58; +import org.qortal.utils.NTP; import java.io.IOException; import java.nio.file.Files; @@ -106,8 +108,9 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -157,8 +160,9 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -220,8 +224,9 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -272,8 +277,9 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the chunk count is correct @@ -316,8 +322,9 @@ public class ArbitraryTransactionMetadataTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); // Check the metadata is correct diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java index 855aeafd..089f0475 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryTransactionTests.java @@ -10,6 +10,7 @@ import org.qortal.arbitrary.ArbitraryDataTransactionBuilder; import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; +import org.qortal.block.BlockChain; import org.qortal.controller.arbitrary.ArbitraryDataManager; import org.qortal.crypto.Crypto; import org.qortal.data.PaymentData; @@ -50,51 +51,6 @@ public class ArbitraryTransactionTests extends Common { Common.useDefaultSettings(); } - @Test - public void testDifficultyTooLow() throws IllegalAccessException, DataException, IOException { - 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 - - // Register the name to Alice - RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); - registerNameTransactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(registerNameTransactionData.getTimestamp())); - TransactionUtils.signAndMint(repository, registerNameTransactionData, alice); - - // Set difficulty to 1 - FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); - - // Create PUT transaction - Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength); - ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize); - - // Check that nonce validation succeeds - byte[] signature = arbitraryDataFile.getSignature(); - TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); - ArbitraryTransaction transaction = new ArbitraryTransaction(repository, transactionData); - assertTrue(transaction.isSignatureValid()); - - // Increase difficulty to 15 - FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 15, true); - - // Make sure the nonce validation fails - // Note: there is a very tiny chance this could succeed due to being extremely lucky - // and finding a high difficulty nonce in the first couple of cycles. It will be rare - // enough that we shouldn't need to account for it. - assertFalse(transaction.isSignatureValid()); - - // Reduce difficulty back to 1, to double check - FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); - assertTrue(transaction.isSignatureValid()); - - } - } - @Test public void testNonceAndFee() throws IllegalAccessException, DataException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { @@ -497,8 +453,9 @@ public class ArbitraryTransactionTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength, true); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, null, null, null, null); byte[] signature = arbitraryDataFile.getSignature(); @@ -556,8 +513,9 @@ public class ArbitraryTransactionTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength, true); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, title, description, tags, category); byte[] signature = arbitraryDataFile.getSignature(); @@ -614,8 +572,9 @@ public class ArbitraryTransactionTests extends Common { // Create PUT transaction Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength, true); + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name, - identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true, + identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, fee, false, null, null, null, null); byte[] signature = arbitraryDataFile.getSignature(); diff --git a/src/test/java/org/qortal/test/common/ArbitraryUtils.java b/src/test/java/org/qortal/test/common/ArbitraryUtils.java index 1741d22c..e08eb0ac 100644 --- a/src/test/java/org/qortal/test/common/ArbitraryUtils.java +++ b/src/test/java/org/qortal/test/common/ArbitraryUtils.java @@ -5,10 +5,12 @@ import org.qortal.arbitrary.ArbitraryDataFile; import org.qortal.arbitrary.ArbitraryDataTransactionBuilder; import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; +import org.qortal.block.BlockChain; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.transaction.Transaction; +import org.qortal.utils.NTP; import java.io.BufferedWriter; import java.io.File; @@ -20,16 +22,15 @@ import java.nio.file.Paths; import java.util.List; import java.util.Random; -import static org.junit.Assert.assertEquals; - public class ArbitraryUtils { public static ArbitraryDataFile createAndMintTxn(Repository repository, String publicKey58, Path path, String name, String identifier, ArbitraryTransactionData.Method method, Service service, PrivateKeyAccount account, int chunkSize) throws DataException { + long fee = BlockChain.getInstance().getUnitFeeAtTimestamp(NTP.getTime()); return ArbitraryUtils.createAndMintTxn(repository, publicKey58, path, name, identifier, method, service, - account, chunkSize, 0L, true, null, null, null, null); + account, chunkSize, fee, false, null, null, null, null); } public static ArbitraryDataFile createAndMintTxn(Repository repository, String publicKey58, Path path, String name, String identifier, @@ -47,7 +48,9 @@ public class ArbitraryUtils { } ArbitraryTransactionData transactionData = txnBuilder.getArbitraryTransactionData(); Transaction.ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, account); - assertEquals(Transaction.ValidationResult.OK, result); + if (result != Transaction.ValidationResult.OK) { + throw new DataException(String.format("Arbitrary transaction invalid: %s", result.toString())); + } BlockUtils.mintBlock(repository); // We need a new ArbitraryDataFile instance because the files will have been moved to the signature's folder diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 91eb3dc9..401b03b9 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -444,6 +444,7 @@ public class MiscTests extends Common { // Payment PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloe = Common.getTestAccount(repository, "chloe"); PaymentTransactionData transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 100000); transactionData.setFee(new PaymentTransaction(null, null).getUnitFee(transactionData.getTimestamp())); @@ -473,16 +474,16 @@ public class MiscTests extends Common { Transaction transaction = Transaction.fromData(repository, transactionData); transaction.sign(alice); ValidationResult result = transaction.importAsUnconfirmed(); - assertTrue("Transaction should be valid", ValidationResult.OK == result); + assertEquals("Transaction should be valid", ValidationResult.OK, result); // Now try fetching and setting fee manually - transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), bob.getAddress(), 50000); + transactionData = new PaymentTransactionData(TestTransaction.generateBase(alice), chloe.getAddress(), 50000); transactionData.setFee(new PaymentTransaction(null, null).getUnitFee(transactionData.getTimestamp())); assertEquals(300000000L, transactionData.getFee().longValue()); transaction = Transaction.fromData(repository, transactionData); transaction.sign(alice); result = transaction.importAsUnconfirmed(); - assertTrue("Transaction should be valid", ValidationResult.OK == result); + assertEquals("Transaction should be valid", ValidationResult.OK, result); } } diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 25915e9a..7059e035 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -18,6 +18,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index ae499491..1016bc17 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -22,6 +22,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index e6b327b1..5f29bc97 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index 9fda9b1f..86f2def1 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index 4ff3ec3c..b2da6489 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 306fd9a3..2933a63d 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 6128baa7..40e40673 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 9b6bccda..8ceafe63 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index 1ffa8baf..68a79ed4 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 6c3b3276..cc02a73e 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 09627e1f..5c508188 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -22,6 +22,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index 81cda1e8..244d2491 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -23,6 +23,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 2e96e911..9168a0de 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -24,6 +24,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 0, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, From 379b850bbd3be835c497bff43a28de81d134c766 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 19 Aug 2023 14:12:12 +0100 Subject: [PATCH 483/496] Increase default maxTransactionsPerBlock to 50 now that the process is faster. Can be increased much further in the future. --- 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 8d4eab56..ab1936c7 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -147,7 +147,7 @@ public class Settings { private int blockCacheSize = 10; /** Maximum number of transactions for the block minter to include in a block */ - private int maxTransactionsPerBlock = 25; + private int maxTransactionsPerBlock = 50; /** How long to keep old, full, AT state data (ms). */ private long atStatesMaxLifetime = 5 * 24 * 60 * 60 * 1000L; // milliseconds From 1f7a60dfd852fe3828b9ad1d647c92b1b5f6676e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 19 Aug 2023 20:27:05 +0100 Subject: [PATCH 484/496] Fixed long term issue preventing trade bot statuses from being logged correctly. --- src/main/java/org/qortal/controller/tradebot/TradeBot.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 96eeaf36..147554dd 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -333,7 +333,7 @@ public class TradeBot implements Listener { SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState), MessageType.INFO); if (logMessageSupplier != null) - LOGGER.info(logMessageSupplier); + LOGGER.info(logMessageSupplier.get()); LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState)); From 4feb8f46c81913926eeb7e942825db72f9c609f8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 19 Aug 2023 20:42:15 +0100 Subject: [PATCH 485/496] Updated testchain.json to use new unitFees structure. --- testnet/testchain.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testnet/testchain.json b/testnet/testchain.json index aef9ed9a..3b31c3e3 100644 --- a/testnet/testchain.json +++ b/testnet/testchain.json @@ -4,7 +4,9 @@ "transactionExpiryPeriod": 86400000, "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, - "unitFee": "0.001", + "unitFees": [ + { "timestamp": 0, "fee": "0.001" } + ], "nameRegistrationUnitFees": [ { "timestamp": 0, "fee": "1.25" } ], From 3c8574a466989e63b2d7eeada15d429e1aded7a2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 20 Aug 2023 12:42:49 +0100 Subject: [PATCH 486/496] Trade bot improvements - Add async responder thread from @catbref which was previously only in place for LTC - Log when computing mempow nonces - Skip transaction import if signature is invalid - Added checks to a message test to mimic trade bot transaction lookups --- .../tradebot/BitcoinACCTv3TradeBot.java | 71 ++++++++++++++----- .../tradebot/DigibyteACCTv3TradeBot.java | 71 ++++++++++++++----- .../tradebot/DogecoinACCTv1TradeBot.java | 71 ++++++++++++++----- .../tradebot/DogecoinACCTv3TradeBot.java | 71 ++++++++++++++----- .../tradebot/LitecoinACCTv3TradeBot.java | 47 +++++++++--- .../tradebot/PirateChainACCTv3TradeBot.java | 71 ++++++++++++++----- .../tradebot/RavencoinACCTv3TradeBot.java | 71 ++++++++++++++----- .../java/org/qortal/test/MessageTests.java | 9 +++ 8 files changed, 371 insertions(+), 111 deletions(-) diff --git a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java index 9033e717..9ab97be9 100644 --- a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java index 171e818b..4b1ba7bb 100644 --- a/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java index d37a6650..52e7bb24 100644 --- a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java index 996097f3..b57b9354 100644 --- a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java index a4ae921e..b5631f0b 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java @@ -325,15 +325,24 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // reset repository state to prevent deadlock threadsRepository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); } } catch (DataException e) { LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); @@ -556,15 +565,25 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -676,15 +695,25 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java index 9834df20..4b5126d9 100644 --- a/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java @@ -20,6 +20,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -320,20 +321,36 @@ public class PirateChainACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddressT3)); @@ -561,15 +578,25 @@ public class PirateChainACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -681,15 +708,25 @@ public class PirateChainACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java index 80fe7932..ed71d0e3 100644 --- a/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,36 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); @@ -548,15 +565,25 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", outgoingMessageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); outgoingMessageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) outgoingMessageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), outgoingMessageTransaction.getPoWDifficulty()); outgoingMessageTransaction.sign(sender); // reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (outgoingMessageTransaction.isSignatureValid()) { + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } @@ -668,15 +695,25 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); messageTransaction.sign(sender); // Reset repository state to prevent deadlock repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: signature invalid", messageRecipient)); return; } } diff --git a/src/test/java/org/qortal/test/MessageTests.java b/src/test/java/org/qortal/test/MessageTests.java index 3e549235..c76c715e 100644 --- a/src/test/java/org/qortal/test/MessageTests.java +++ b/src/test/java/org/qortal/test/MessageTests.java @@ -29,6 +29,7 @@ import org.qortal.utils.NTP; import static org.junit.Assert.*; +import java.util.List; import java.util.Random; public class MessageTests extends Common { @@ -173,6 +174,10 @@ public class MessageTests extends Common { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + // Transaction should not be present in db yet + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, recipient, null, null, null); + assertTrue(messageTransactionsData.isEmpty()); + MessageTransaction transaction = testFeeNonce(repository, false, true, recipient, true); // Transaction shouldn't be confirmable because it's not to an AT, and therefore shouldn't be present in a block @@ -181,6 +186,10 @@ public class MessageTests extends Common { assertFalse(isTransactionConfirmed(repository, transaction)); assertEquals(12, transaction.getPoWDifficulty()); + // Transaction should be found when trade bot searches for it + messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, recipient, null, null, null); + assertEquals(1, messageTransactionsData.size()); + BlockUtils.orphanLastBlock(repository); } } From 086a9afa0ee0e9a0bc9f88f0b07583b277ccd8a1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 20 Aug 2023 16:52:49 +0100 Subject: [PATCH 487/496] Testnet mempowTransactionUpdatesTimestamp set to 1692554400000 --- testnet/testchain.json | 1 + 1 file changed, 1 insertion(+) diff --git a/testnet/testchain.json b/testnet/testchain.json index 3b31c3e3..089bd693 100644 --- a/testnet/testchain.json +++ b/testnet/testchain.json @@ -26,6 +26,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 0, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 1692554400000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From 34382b6e69b5587caa42e7d3f14791e75b50e79b Mon Sep 17 00:00:00 2001 From: Phillip Lang Martinez Date: Tue, 22 Aug 2023 19:58:24 -0500 Subject: [PATCH 488/496] add endpoint --- .../org/qortal/api/resource/AtResource.java | 36 +++++++++++ .../data/transaction/CreationRequest.java | 63 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/main/java/org/qortal/data/transaction/CreationRequest.java diff --git a/src/main/java/org/qortal/api/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java index 29a2344d..4a5fbbdb 100644 --- a/src/main/java/org/qortal/api/resource/AtResource.java +++ b/src/main/java/org/qortal/api/resource/AtResource.java @@ -27,6 +27,7 @@ import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; +import org.qortal.data.transaction.CreationRequest; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -156,6 +157,41 @@ public class AtResource { } } + @POST + @Path("/createMachineState") + @Operation( + summary = "Create MachineState bytes from the provided parameters", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CreationRequest.class + ) + ) + ), + responses = { + @ApiResponse( + description = "MachineState bytes", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + public byte[] createMachineState(CreationRequest request) { + return MachineState.toCreationBytes( + request.getCiyamAtVersion(), + request.getCodeBytes(), + request.getDataBytes(), + request.getNumCallStackPages(), + request.getNumUserStackPages(), + request.getMinActivationAmount() + ); + } @POST @Operation( summary = "Build raw, unsigned, DEPLOY_AT transaction", diff --git a/src/main/java/org/qortal/data/transaction/CreationRequest.java b/src/main/java/org/qortal/data/transaction/CreationRequest.java new file mode 100644 index 00000000..e5b6ffdb --- /dev/null +++ b/src/main/java/org/qortal/data/transaction/CreationRequest.java @@ -0,0 +1,63 @@ +package org.qortal.data.transaction; + +public class CreationRequest { + + private short ciyamAtVersion; + private byte[] codeBytes; + private byte[] dataBytes; + private short numCallStackPages; + private short numUserStackPages; + private long minActivationAmount; + + // Default constructor for JSON deserialization + public CreationRequest() {} + + // Getters and setters + public short getCiyamAtVersion() { + return ciyamAtVersion; + } + + public void setCiyamAtVersion(short ciyamAtVersion) { + this.ciyamAtVersion = ciyamAtVersion; + } + + public byte[] getCodeBytes() { + return codeBytes; + } + + public void setCodeBytes(byte[] codeBytes) { + this.codeBytes = codeBytes; + } + + public byte[] getDataBytes() { + return dataBytes; + } + + public void setDataBytes(byte[] dataBytes) { + this.dataBytes = dataBytes; + } + + public short getNumCallStackPages() { + return numCallStackPages; + } + + public void setNumCallStackPages(short numCallStackPages) { + this.numCallStackPages = numCallStackPages; + } + + public short getNumUserStackPages() { + return numUserStackPages; + } + + public void setNumUserStackPages(short numUserStackPages) { + this.numUserStackPages = numUserStackPages; + } + + public long getMinActivationAmount() { + return minActivationAmount; + } + + public void setMinActivationAmount(long minActivationAmount) { + this.minActivationAmount = minActivationAmount; + } +} \ No newline at end of file From 24ff3ab58174e3f31232f6ddf0fee60d9ce7c9a5 Mon Sep 17 00:00:00 2001 From: Phillip Lang Martinez Date: Tue, 22 Aug 2023 22:46:58 -0500 Subject: [PATCH 489/496] base64 to byte array --- .../org/qortal/api/resource/AtResource.java | 1 + .../data/transaction/CreationRequest.java | 22 ++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java index 4a5fbbdb..fd045b7d 100644 --- a/src/main/java/org/qortal/api/resource/AtResource.java +++ b/src/main/java/org/qortal/api/resource/AtResource.java @@ -38,6 +38,7 @@ import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.utils.Base58; +import java.util.Base64; @Path("/at") @Tag(name = "Automated Transactions") diff --git a/src/main/java/org/qortal/data/transaction/CreationRequest.java b/src/main/java/org/qortal/data/transaction/CreationRequest.java index e5b6ffdb..efdfc738 100644 --- a/src/main/java/org/qortal/data/transaction/CreationRequest.java +++ b/src/main/java/org/qortal/data/transaction/CreationRequest.java @@ -1,10 +1,12 @@ package org.qortal.data.transaction; -public class CreationRequest { +import java.util.Base64; + +public class CreationRequest { private short ciyamAtVersion; - private byte[] codeBytes; - private byte[] dataBytes; + private String codeBytesBase64; + private String dataBytesBase64; private short numCallStackPages; private short numUserStackPages; private long minActivationAmount; @@ -22,19 +24,19 @@ public class CreationRequest { } public byte[] getCodeBytes() { - return codeBytes; + return Base64.getDecoder().decode(this.codeBytesBase64); } - public void setCodeBytes(byte[] codeBytes) { - this.codeBytes = codeBytes; + public void setCodeBytesBase64(String codeBytesBase64) { + this.codeBytesBase64 = codeBytesBase64; } public byte[] getDataBytes() { - return dataBytes; + return Base64.getDecoder().decode(this.dataBytesBase64); } - public void setDataBytes(byte[] dataBytes) { - this.dataBytes = dataBytes; + public void setDataBytesBase64(String dataBytesBase64) { + this.dataBytesBase64 = dataBytesBase64; } public short getNumCallStackPages() { @@ -60,4 +62,4 @@ public class CreationRequest { public void setMinActivationAmount(long minActivationAmount) { this.minActivationAmount = minActivationAmount; } -} \ No newline at end of file +} From b051f9be89422391deaaf85aa7666d5bbc766070 Mon Sep 17 00:00:00 2001 From: Phillip Lang Martinez Date: Wed, 23 Aug 2023 14:41:50 -0500 Subject: [PATCH 490/496] fix strings in request --- .../org/qortal/api/resource/AtResource.java | 36 +++++++++++++------ .../data/transaction/CreationRequest.java | 19 ++++++++-- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java index fd045b7d..80a6e299 100644 --- a/src/main/java/org/qortal/api/resource/AtResource.java +++ b/src/main/java/org/qortal/api/resource/AtResource.java @@ -9,6 +9,7 @@ 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.util.List; import javax.servlet.http.HttpServletRequest; @@ -39,10 +40,13 @@ import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.utils.Base58; import java.util.Base64; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.databind.ObjectMapper; @Path("/at") @Tag(name = "Automated Transactions") public class AtResource { + private static final Logger logger = LoggerFactory.getLogger(AtResource.class); @Context HttpServletRequest request; @@ -183,15 +187,27 @@ public class AtResource { ) } ) - public byte[] createMachineState(CreationRequest request) { - return MachineState.toCreationBytes( - request.getCiyamAtVersion(), - request.getCodeBytes(), - request.getDataBytes(), - request.getNumCallStackPages(), - request.getNumUserStackPages(), - request.getMinActivationAmount() - ); + public String createMachineState(String jsonBody) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + CreationRequest request = objectMapper.readValue(jsonBody, CreationRequest.class); + + logger.info("ciyamAtVersion: {}", request.getCiyamAtVersion()); + logger.info("codeBytes: {}", request.getCodeBytes()); + logger.info("codeBytes: {}", request.getNumUserStackPages()); + logger.info("codeBytes: {}", request.getDataBytes()); + logger.info("codeBytes: {}", request.getNumCallStackPages()); + logger.info("codeBytes: {}", request.getMinActivationAmount()); + byte[] creationBytes = MachineState.toCreationBytes( + request.getCiyamAtVersion(), + request.getCodeBytes(), + request.getDataBytes(), + request.getNumCallStackPages(), + request.getNumUserStackPages(), + request.getMinActivationAmount() + ); + return Base58.encode(creationBytes); + + } @POST @Operation( diff --git a/src/main/java/org/qortal/data/transaction/CreationRequest.java b/src/main/java/org/qortal/data/transaction/CreationRequest.java index efdfc738..9d50458c 100644 --- a/src/main/java/org/qortal/data/transaction/CreationRequest.java +++ b/src/main/java/org/qortal/data/transaction/CreationRequest.java @@ -1,10 +1,11 @@ package org.qortal.data.transaction; import java.util.Base64; - +import com.fasterxml.jackson.annotation.JsonProperty; public class CreationRequest { private short ciyamAtVersion; + @JsonProperty("codeBytesBase64") private String codeBytesBase64; private String dataBytesBase64; private short numCallStackPages; @@ -24,15 +25,27 @@ public class CreationRequest { } public byte[] getCodeBytes() { - return Base64.getDecoder().decode(this.codeBytesBase64); + if (this.codeBytesBase64 != null) { + return Base64.getDecoder().decode(this.codeBytesBase64); + } + return new byte[0]; } public void setCodeBytesBase64(String codeBytesBase64) { this.codeBytesBase64 = codeBytesBase64; } + public String getCodeBytes2() { + return codeBytesBase64; + + } + + public byte[] getDataBytes() { - return Base64.getDecoder().decode(this.dataBytesBase64); + if (this.dataBytesBase64 != null) { + return Base64.getDecoder().decode(this.dataBytesBase64); + } + return new byte[0]; } public void setDataBytesBase64(String dataBytesBase64) { From 26ac7e5be5a5269fd7c10a639c5ca0b3db761fbe Mon Sep 17 00:00:00 2001 From: kennycud Date: Thu, 24 Aug 2023 15:47:45 -0700 Subject: [PATCH 491/496] consolidated shared functionality into ACCTTests.java --- .../org/qortal/test/crosschain/ACCTTests.java | 790 ++++++++++++++++++ .../bitcoinv1/BitcoinACCTv1Tests.java | 699 +--------------- .../bitcoinv3/BitcoinACCTv3Tests.java | 775 +---------------- .../digibytev3/DigibyteACCTv3Tests.java | 775 +---------------- .../dogecoinv3/DogecoinACCTv3Tests.java | 775 +---------------- .../litecoinv1/LitecoinACCTv1Tests.java | 774 +---------------- .../litecoinv3/LitecoinACCTv3Tests.java | 777 +---------------- .../piratechainv3/PirateChainACCTv3Tests.java | 777 +---------------- .../ravencoinv3/RavencoinACCTv3Tests.java | 775 +---------------- 9 files changed, 1058 insertions(+), 5859 deletions(-) create mode 100644 src/test/java/org/qortal/test/crosschain/ACCTTests.java diff --git a/src/test/java/org/qortal/test/crosschain/ACCTTests.java b/src/test/java/org/qortal/test/crosschain/ACCTTests.java new file mode 100644 index 00000000..6af27a96 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/ACCTTests.java @@ -0,0 +1,790 @@ +package org.qortal.test.crosschain; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.block.Block; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.AcctMode; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +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; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.utils.Amounts; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.function.Function; + +import static org.junit.Assert.*; + +public abstract class ACCTTests extends Common { + + public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); + public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a + public static final int tradeTimeout = 20; // blocks + public static final long redeemAmount = 80_40200000L; + public static final long fundingAmount = 123_45600000L; + public static final long foreignAmount = 864200L; // 0.00864200 foreign units + + protected static final Random RANDOM = new Random(); + + protected abstract byte[] getPublicKey(); + + protected abstract byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout); + + protected abstract ACCT getInstance(); + + protected abstract int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA); + + protected abstract byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout); + + protected abstract byte[] buildRedeemMessage(byte[] secretA, String address); + + protected abstract byte[] getCodeBytesHash(); + + protected abstract String getSymbol(); + + protected abstract String getName(); + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testCompile() { + PrivateKeyAccount tradeAccount = createTradeAccount(null); + + byte[] creationBytes = buildQortalAT(tradeAccount.getAddress(), getPublicKey(), redeemAmount, foreignAmount, tradeTimeout); + assertNotNull(creationBytes); + + System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + } + + + @Test + public void testDeploy() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = fundingAmount; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + expectedBalance = deployersInitialBalance; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = 0; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancel() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Send creator's address to AT, instead of typical partner's address + byte[] messageData = getInstance().buildCancelMessage(deployer.getAddress()); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + + // Check balances + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance - messageFee; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancelInvalidLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Instead of sending creator's address to AT, send too-short/invalid message + byte[] messageData = new byte[7]; + RANDOM.nextBytes(messageData); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testTradingInfoProcessing() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + + // AT should be in TRADE mode + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check hashOfSecretA was extracted correctly + assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); + + // Check trade partner Qortal address was extracted correctly + assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); + + // Check trade partner's Foreign Coin PKH was extracted correctly + assertTrue(Arrays.equals(getPublicKey(), tradeData.partnerForeignPKH)); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) + @SuppressWarnings("unused") + @Test + public void testIncorrectTradeSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); + + BlockUtils.mintBlock(repository); + + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + + // AT should still be in OFFER mode + assertEquals(AcctMode.OFFERING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testAutomaticTradeRefund() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + // Check refund + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in REFUNDED mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REFUNDED, tradeData.mode); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, from correct account + messageData = buildRedeemMessage(secretA, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in REDEEMED mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REDEEMED, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); + + // Orphan redeem + BlockUtils.orphanLastBlock(repository); + + // Check balances + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); + + // Check AT state + ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); + + assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretIncorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, but from wrong account + messageData = buildRedeemMessage(secretA, partner.getAddress()); + messageTransaction = sendMessage(repository, bystander, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testIncorrectSecretCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send incorrect secret to AT, from correct account + byte[] wrongSecret = new byte[32]; + RANDOM.nextBytes(wrongSecret); + messageData = buildRedeemMessage(wrongSecret, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = buildTradeMessage(partner.getAddress(), getPublicKey(), hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length + messageData = Bytes.concat(secretA); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should be in TRADING mode + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testDescribeDeployed() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + List executableAts = repository.getATRepository().getAllExecutableATs(); + + for (ATData atData : executableAts) { + String atAddress = atData.getATAddress(); + byte[] codeBytes = atData.getCodeBytes(); + byte[] codeHash = Crypto.digest(codeBytes); + + System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", + atAddress, + codeBytes.length, + (codeBytes.length != 1 ? "s": ""), + HashCode.fromBytes(codeHash))); + + // Not one of ours? + if (!Arrays.equals(codeHash, getCodeBytesHash())) + continue; + + describeAt(repository, atAddress); + } + } + } + + protected int calcTestLockTimeA(long messageTimestamp) { + return (int) (messageTimestamp / 1000L + tradeTimeout * 60); + } + + protected DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = buildQortalAT(tradeAddress, getPublicKey(), redeemAmount, foreignAmount, tradeTimeout); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-" + getSymbol() + " cross-chain trade"; + String description = String.format("Qortal-" + getName() + " cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-" + getSymbol() + " ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + protected MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } + + protected void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + int refundTimeout = tradeTimeout / 2 + 1; // close enough + + // AT should automatically refund deployer after 'refundTimeout' blocks + for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) + BlockUtils.mintBlock(repository); + + // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + } + + protected void describeAt(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = getInstance().populateTradeData(repository, atData); + + Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); + int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); + + System.out.print(String.format("%s:\n" + + "\tmode: %s\n" + + "\tcreator: %s,\n" + + "\tcreation timestamp: %s,\n" + + "\tcurrent balance: %s QORT,\n" + + "\tis finished: %b,\n" + + "\tredeem payout: %s QORT,\n" + + "\texpected " + getName() + ": %s " + getSymbol() + ",\n" + + "\tcurrent block height: %d,\n", + tradeData.qortalAtAddress, + tradeData.mode, + tradeData.qortalCreator, + epochMilliFormatter.apply(tradeData.creationTimestamp), + Amounts.prettyAmount(tradeData.qortBalance), + atData.getIsFinished(), + Amounts.prettyAmount(tradeData.qortAmount), + Amounts.prettyAmount(tradeData.expectedForeignAmount), + currentBlockHeight)); + + describeRefundAt(tradeData, epochMilliFormatter); + } + + protected void describeRefundAt(CrossChainTradeData tradeData, Function epochMilliFormatter) { + if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { + System.out.println(String.format("\trefund timeout: %d minutes,\n" + + "\trefund height: block %d,\n" + + "\tHASH160 of secret-A: %s,\n" + + "\t" + getName() + " P2SH-A nLockTime: %d (%s),\n" + + "\ttrade partner: %s\n" + + "\tpartner's receiving address: %s", + tradeData.refundTimeout, + tradeData.tradeRefundHeight, + HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), + tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), + tradeData.qortalPartnerAddress, + tradeData.qortalPartnerReceivingAddress)); + } + } + + protected PrivateKeyAccount createTradeAccount(Repository repository) { + // We actually use a known test account with funds to avoid PoW compute + return Common.getTestAccount(repository, "alice"); + } +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java index 4487e874..cc33eb43 100644 --- a/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java @@ -2,507 +2,89 @@ package org.qortal.test.crosschain.bitcoinv1; import static org.junit.Assert.*; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; import java.util.function.Function; -import org.junit.Before; import org.junit.Test; import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; -import org.qortal.block.Block; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crosschain.AcctMode; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -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; import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; +import org.qortal.test.crosschain.ACCTTests; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -public class BitcoinACCTv1Tests extends Common { +public class BitcoinACCTv1Tests extends ACCTTests { - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] secretB = "This string is roughly 32 bytes?".getBytes(); public static final byte[] hashOfSecretB = Crypto.hash160(secretB); // 31f0dd71decf59bbc8ef0661f4030479255cfa58 public static final byte[] bitcoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long bitcoinAmount = 864200L; // 0.00864200 BTC - private static final Random RANDOM = new Random(); + private static final String SYMBOL = "BTC"; - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + private static final String NAME = "Bitcoin"; + + @Override + protected byte[] getPublicKey() { + return bitcoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return BitcoinACCTv1.buildQortalAT(address,publicKey, hashOfSecretB, redeemAmount, foreignAmount,tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); + @Override + protected ACCT getInstance() { + return BitcoinACCTv1.getInstance(); + } - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + } - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return BitcoinACCTv1.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); + } - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return BitcoinACCTv1.buildRedeemMessage(secretA,secretB,address); + } - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + @Override + protected byte[] getCodeBytesHash() { + return BitcoinACCTv1.CODE_BYTES_HASH; + } - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); + @Override + protected String getSymbol() { + return SYMBOL; + } - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getName() { + return NAME; } @SuppressWarnings("unused") + @Override @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = BitcoinACCTv1.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } - } - - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Bitcoin PKH was extracted correctly - assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } - } - - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretsCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secrets to AT, from correct account - messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretsIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secrets to AT, but from wrong account - messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretsCorrectSender() throws DataException { + public void testIncorrectSecretCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount tradeAccount = createTradeAccount(repository); @@ -582,197 +164,8 @@ public class BitcoinACCTv1Tests extends Common { } } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretsCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secrets to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA, secretB); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, BitcoinACCTv1.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-BTC cross-chain trade"; - String description = String.format("Qortal-Bitcoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-BTC ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout * 3 / 4 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tHASH160 of secret-B: %s,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected bitcoin: %s BTC,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - + @Override + protected void describeRefundAt(CrossChainTradeData tradeData, Function epochMilliFormatter) { if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { System.out.println(String.format("\trefund height: block %d,\n" + "\tHASH160 of secret-A: %s,\n" @@ -786,10 +179,4 @@ public class BitcoinACCTv1Tests extends Common { tradeData.qortalPartnerAddress)); } } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java index 01345727..5e0048bf 100644 --- a/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv3/BitcoinACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.bitcoinv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.BitcoinACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -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; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class BitcoinACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class BitcoinACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] bitcoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long bitcoinAmount = 864200L; // 0.00864200 BTC + private static final String SYMBOL = "BTC"; + private static final String NAME = "Bitcoin"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return bitcoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = BitcoinACCTv3.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, redeemAmount, bitcoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return BitcoinACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return BitcoinACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = BitcoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return BitcoinACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Bitcoin PKH was extracted correctly - assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return BitcoinACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return BitcoinACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = BitcoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = BitcoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = BitcoinACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = BitcoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = BitcoinACCTv3.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, BitcoinACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = BitcoinACCTv3.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, redeemAmount, bitcoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-BTC cross-chain trade"; - String description = String.format("Qortal-Bitcoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-BTC ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected Bitcoin: %s BTC,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tBitcoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java index d13aba4c..01ead678 100644 --- a/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/digibytev3/DigibyteACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.digibytev3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.DigibyteACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -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; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class DigibyteACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class DigibyteACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] digibytePublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long digibyteAmount = 864200L; // 0.00864200 DGB + private static final String SYMBOL = "DGB"; + private static final String NAME = "DigiByte"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return digibytePublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = DigibyteACCTv3.buildQortalAT(tradeAccount.getAddress(), digibytePublicKeyHash, redeemAmount, digibyteAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return DigibyteACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return DigibyteACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = DigibyteACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return DigibyteACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's digibyte PKH was extracted correctly - assertTrue(Arrays.equals(digibytePublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return DigibyteACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return DigibyteACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = DigibyteACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = DigibyteACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = DigibyteACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DigibyteACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DigibyteACCTv3.buildTradeMessage(partner.getAddress(), digibytePublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, DigibyteACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = DigibyteACCTv3.buildQortalAT(tradeAddress, digibytePublicKeyHash, redeemAmount, digibyteAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-DGB cross-chain trade"; - String description = String.format("Qortal-Digibyte cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-DGB ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DigibyteACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected digibyte: %s DGB,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tDigibyte P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/dogecoinv3/DogecoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/dogecoinv3/DogecoinACCTv3Tests.java index 7056e433..551173f7 100644 --- a/src/test/java/org/qortal/test/crosschain/dogecoinv3/DogecoinACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/dogecoinv3/DogecoinACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.dogecoinv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.DogecoinACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -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; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class DogecoinACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class DogecoinACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] dogecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long dogecoinAmount = 864200L; // 0.00864200 DOGE + private static final String SYMBOL = "DOGE"; + private static final String NAME = "Dogecoin"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return dogecoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = DogecoinACCTv3.buildQortalAT(tradeAccount.getAddress(), dogecoinPublicKeyHash, redeemAmount, dogecoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return DogecoinACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return DogecoinACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = DogecoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return DogecoinACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's dogecoin PKH was extracted correctly - assertTrue(Arrays.equals(dogecoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return DogecoinACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return DogecoinACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = DogecoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = DogecoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = DogecoinACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = DogecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = DogecoinACCTv3.buildTradeMessage(partner.getAddress(), dogecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, DogecoinACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = DogecoinACCTv3.buildQortalAT(tradeAddress, dogecoinPublicKeyHash, redeemAmount, dogecoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-DOGE cross-chain trade"; - String description = String.format("Qortal-Dogecoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-DOGE ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = DogecoinACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected dogecoin: %s DOGE,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tDogecoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java index 609ff5f3..91a450d0 100644 --- a/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java @@ -1,770 +1,60 @@ package org.qortal.test.crosschain.litecoinv1; -import static org.junit.Assert.*; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; - -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.LitecoinACCTv1; -import org.qortal.crosschain.AcctMode; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -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; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -public class LitecoinACCTv1Tests extends Common { +public class LitecoinACCTv1Tests extends ACCTTests { - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] litecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long litecoinAmount = 864200L; // 0.00864200 LTC + private static final String SYMBOL = "LTC"; - private static final Random RANDOM = new Random(); + private static final String NAME = "Litecoin"; - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return litecoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAccount.getAddress(), litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return LitecoinACCTv1.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return LitecoinACCTv1.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = LitecoinACCTv1.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return LitecoinACCTv1.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Litecoin PKH was extracted correctly - assertTrue(Arrays.equals(litecoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return LitecoinACCTv1.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return LitecoinACCTv1.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = LitecoinACCTv1.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = LitecoinACCTv1.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = LitecoinACCTv1.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, LitecoinACCTv1.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAddress, litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-LTC cross-chain trade"; - String description = String.format("Qortal-Litecoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-LTC ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected Litecoin: %s LTC,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tLitecoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv3/LitecoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/litecoinv3/LitecoinACCTv3Tests.java index 009af5ea..a1a0bfcc 100644 --- a/src/test/java/org/qortal/test/crosschain/litecoinv3/LitecoinACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/litecoinv3/LitecoinACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.litecoinv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; -import org.qortal.crosschain.LitecoinACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -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; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class LitecoinACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class LitecoinACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] litecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long litecoinAmount = 864200L; // 0.00864200 LTC + private static final String SYMBOL = "LTC"; + private static final String NAME = "Litecoin"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return litecoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = LitecoinACCTv3.buildQortalAT(tradeAccount.getAddress(), litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return LitecoinACCTv1.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return LitecoinACCTv1.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = LitecoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return LitecoinACCTv1.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Litecoin PKH was extracted correctly - assertTrue(Arrays.equals(litecoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return LitecoinACCTv1.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return LitecoinACCTv1.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = LitecoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = LitecoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = LitecoinACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = LitecoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = LitecoinACCTv3.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, LitecoinACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = LitecoinACCTv3.buildQortalAT(tradeAddress, litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-LTC cross-chain trade"; - String description = String.format("Qortal-Litecoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-LTC ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected Litecoin: %s LTC,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tLitecoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/piratechainv3/PirateChainACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/piratechainv3/PirateChainACCTv3Tests.java index f9ac9de1..18099872 100644 --- a/src/test/java/org/qortal/test/crosschain/piratechainv3/PirateChainACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/piratechainv3/PirateChainACCTv3Tests.java @@ -1,771 +1,58 @@ package org.qortal.test.crosschain.piratechainv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.PirateChainACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -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; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class PirateChainACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class PirateChainACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] pirateChainPublicKey = HashCode.fromString("aabb00bb11bb22bb33bb44bb55bb66bb77bb88bb99cc00cc11cc22cc33cc44cc55").asBytes(); // 33 bytes - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long arrrAmount = 864200L; // 0.00864200 ARRR + private static final String SYMBOL = "ARRR"; + private static final String NAME = "Pirate Chain"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return pirateChainPublicKey; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = PirateChainACCTv3.buildQortalAT(tradeAccount.getAddress(), pirateChainPublicKey, redeemAmount, arrrAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return PirateChainACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return PirateChainACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = PirateChainACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return PirateChainACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - System.out.println(String.format("pirateChainPublicKey: %s", HashCode.fromBytes(pirateChainPublicKey))); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's Litecoin PKH was extracted correctly - assertTrue(Arrays.equals(pirateChainPublicKey, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return PirateChainACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return PirateChainACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = PirateChainACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = PirateChainACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = PirateChainACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = PirateChainACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = PirateChainACCTv3.buildTradeMessage(partner.getAddress(), pirateChainPublicKey, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, PirateChainACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = PirateChainACCTv3.buildQortalAT(tradeAddress, pirateChainPublicKey, redeemAmount, arrrAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-ARRR cross-chain trade"; - String description = String.format("Qortal-PirateChain cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-ARRR ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = PirateChainACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected ARRR: %s ARRR,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tPirate Chain P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } diff --git a/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java b/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java index 012d5f5d..1af0f7d6 100644 --- a/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java +++ b/src/test/java/org/qortal/test/crosschain/ravencoinv3/RavencoinACCTv3Tests.java @@ -1,769 +1,58 @@ package org.qortal.test.crosschain.ravencoinv3; import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.junit.Before; -import org.junit.Test; -import org.qortal.account.Account; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; -import org.qortal.block.Block; -import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.RavencoinACCTv3; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -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; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.utils.Amounts; +import org.qortal.test.crosschain.ACCTTests; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.function.Function; +public class RavencoinACCTv3Tests extends ACCTTests { -import static org.junit.Assert.*; - -public class RavencoinACCTv3Tests extends Common { - - public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final byte[] ravencoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 20; // blocks - public static final long redeemAmount = 80_40200000L; - public static final long fundingAmount = 123_45600000L; - public static final long ravencoinAmount = 864200L; // 0.00864200 RVN + private static final String SYMBOL = "RVN"; + private static final String NAME = "Ravencoin"; - private static final Random RANDOM = new Random(); - - @Before - public void beforeTest() throws DataException { - Common.useDefaultSettings(); + @Override + protected byte[] getPublicKey() { + return ravencoinPublicKeyHash; } - @Test - public void testCompile() { - PrivateKeyAccount tradeAccount = createTradeAccount(null); - - byte[] creationBytes = RavencoinACCTv3.buildQortalAT(tradeAccount.getAddress(), ravencoinPublicKeyHash, redeemAmount, ravencoinAmount, tradeTimeout); - assertNotNull(creationBytes); - - System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + @Override + protected byte[] buildQortalAT(String address, byte[] publicKey, long redeemAmount, long foreignAmount, int tradeTimeout) { + return RavencoinACCTv3.buildQortalAT(address, publicKey, redeemAmount, foreignAmount, tradeTimeout); } - @Test - public void testDeploy() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = fundingAmount; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = deployersInitialBalance; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = 0; - actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); - - assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - - expectedBalance = partnersInitialBalance; - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - } + @Override + protected ACCT getInstance() { + return RavencoinACCTv3.getInstance(); } - @SuppressWarnings("unused") - @Test - public void testOfferCancel() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Send creator's address to AT, instead of typical partner's address - byte[] messageData = RavencoinACCTv3.getInstance().buildCancelMessage(deployer.getAddress()); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - - // Check balances - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance - messageFee; - actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected int calcRefundTimeout(long partnersOfferMessageTransactionTimestamp, int lockTimeA) { + return RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); } - @SuppressWarnings("unused") - @Test - public void testOfferCancelInvalidLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - // Instead of sending creator's address to AT, send too-short/invalid message - byte[] messageData = new byte[7]; - RANDOM.nextBytes(messageData); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - long messageFee = messageTransaction.getTransactionData().getFee(); - - // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in CANCELLED mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.CANCELLED, tradeData.mode); - } + @Override + protected byte[] buildTradeMessage(String address, byte[] publicKey, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + return RavencoinACCTv3.buildTradeMessage(address, publicKey, hashOfSecretA, lockTimeA, refundTimeout); } - @SuppressWarnings("unused") - @Test - public void testTradingInfoProcessing() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should be in TRADE mode - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check hashOfSecretA was extracted correctly - assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - - // Check trade partner Qortal address was extracted correctly - assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - - // Check trade partner's ravencoin PKH was extracted correctly - assertTrue(Arrays.equals(ravencoinPublicKeyHash, tradeData.partnerForeignPKH)); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected byte[] buildRedeemMessage(byte[] secretA, String address) { + return RavencoinACCTv3.buildRedeemMessage(secretA, address); } - // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) - @SuppressWarnings("unused") - @Test - public void testIncorrectTradeSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - BlockUtils.mintBlock(repository); - - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - - // AT should still be in OFFER mode - assertEquals(AcctMode.OFFERING, tradeData.mode); - } + @Override + protected byte[] getCodeBytesHash() { + return RavencoinACCTv3.CODE_BYTES_HASH; } - @SuppressWarnings("unused") - @Test - public void testAutomaticTradeRefund() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - Block postDeploymentBlock = BlockUtils.mintBlock(repository); - int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); - - // Check refund - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REFUNDED mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REFUNDED, tradeData.mode); - - // Test orphaning - BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); - - // Check balances - long expectedBalance = deployersPostDeploymentBalance; - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); - } + @Override + protected String getSymbol() { + return SYMBOL; } - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account - messageData = RavencoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertTrue(atData.getIsFinished()); - - // AT should be in REDEEMED mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.REDEEMED, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); - - // Orphan redeem - BlockUtils.orphanLastBlock(repository); - - // Check balances - expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); - - // Check AT state - ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); - - assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); - } + @Override + protected String getName() { + return NAME; } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretIncorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, but from wrong account - messageData = RavencoinACCTv3.buildRedeemMessage(secretA, partner.getAddress()); - messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - // Check balances - long expectedBalance = partnersInitialBalance; - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testIncorrectSecretCorrectSender() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send incorrect secret to AT, from correct account - byte[] wrongSecret = new byte[32]; - RANDOM.nextBytes(wrongSecret); - messageData = RavencoinACCTv3.buildRedeemMessage(wrongSecret, partner.getAddress()); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should still be in TRADE mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - - long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = partner.getConfirmedBalance(Asset.QORT); - - assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); - - // Check eventual refund - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); - } - } - - @SuppressWarnings("unused") - @Test - public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int refundTimeout = RavencoinACCTv3.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); - - // Send trade info to AT - byte[] messageData = RavencoinACCTv3.buildTradeMessage(partner.getAddress(), ravencoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); - - // Give AT time to process message - BlockUtils.mintBlock(repository); - - // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length - messageData = Bytes.concat(secretA); - messageTransaction = sendMessage(repository, partner, messageData, atAddress); - - // AT should NOT send funds in the next block - ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); - BlockUtils.mintBlock(repository); - - describeAt(repository, atAddress); - - // Check AT is NOT finished - ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); - - // AT should be in TRADING mode - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - assertEquals(AcctMode.TRADING, tradeData.mode); - } - } - - @SuppressWarnings("unused") - @Test - public void testDescribeDeployed() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount tradeAccount = createTradeAccount(repository); - - PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); - - List executableAts = repository.getATRepository().getAllExecutableATs(); - - for (ATData atData : executableAts) { - String atAddress = atData.getATAddress(); - byte[] codeBytes = atData.getCodeBytes(); - byte[] codeHash = Crypto.digest(codeBytes); - - System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", - atAddress, - codeBytes.length, - (codeBytes.length != 1 ? "s": ""), - HashCode.fromBytes(codeHash))); - - // Not one of ours? - if (!Arrays.equals(codeHash, RavencoinACCTv3.CODE_BYTES_HASH)) - continue; - - describeAt(repository, atAddress); - } - } - } - - private int calcTestLockTimeA(long messageTimestamp) { - return (int) (messageTimestamp / 1000L + tradeTimeout * 60); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { - byte[] creationBytes = RavencoinACCTv3.buildQortalAT(tradeAddress, ravencoinPublicKeyHash, redeemAmount, ravencoinAmount, tradeTimeout); - - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "QORT-RVN cross-chain trade"; - String description = String.format("Qortal-Ravencoin cross-chain trade"); - String atType = "ACCT"; - String tags = "QORT-RVN ACCT"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - - private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = sender.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); - System.exit(2); - } - - Long fee = null; - int version = 4; - int nonce = 0; - long amount = 0; - Long assetId = null; // because amount is zero - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); - TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); - - MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, messageTransactionData, sender); - - return messageTransaction; - } - - private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = tradeTimeout / 2 + 1; // close enough - - // AT should automatically refund deployer after 'refundTimeout' blocks - for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) - BlockUtils.mintBlock(repository); - - // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - } - - private void describeAt(Repository repository, String atAddress) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = RavencoinACCTv3.getInstance().populateTradeData(repository, atData); - - Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); - - System.out.print(String.format("%s:\n" - + "\tmode: %s\n" - + "\tcreator: %s,\n" - + "\tcreation timestamp: %s,\n" - + "\tcurrent balance: %s QORT,\n" - + "\tis finished: %b,\n" - + "\tredeem payout: %s QORT,\n" - + "\texpected ravencoin: %s RVN,\n" - + "\tcurrent block height: %d,\n", - tradeData.qortalAtAddress, - tradeData.mode, - tradeData.qortalCreator, - epochMilliFormatter.apply(tradeData.creationTimestamp), - Amounts.prettyAmount(tradeData.qortBalance), - atData.getIsFinished(), - Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedForeignAmount), - currentBlockHeight)); - - if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { - System.out.println(String.format("\trefund timeout: %d minutes,\n" - + "\trefund height: block %d,\n" - + "\tHASH160 of secret-A: %s,\n" - + "\tRavencoin P2SH-A nLockTime: %d (%s),\n" - + "\ttrade partner: %s\n" - + "\tpartner's receiving address: %s", - tradeData.refundTimeout, - tradeData.tradeRefundHeight, - HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), - tradeData.qortalPartnerAddress, - tradeData.qortalPartnerReceivingAddress)); - } - } - - private PrivateKeyAccount createTradeAccount(Repository repository) { - // We actually use a known test account with funds to avoid PoW compute - return Common.getTestAccount(repository, "alice"); - } - } From 3fbcc50503aa68b08301c1b5620c37a1ce7c33ae Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 25 Aug 2023 11:01:48 +0100 Subject: [PATCH 492/496] Fixed deserialization issues with CreationRequest, added validation, and made small code tweaks for consistency with other endpoints. --- .../qortal/api/model/AtCreationRequest.java | 102 ++++++++++++++++++ .../org/qortal/api/resource/AtResource.java | 55 +++++----- .../data/transaction/CreationRequest.java | 78 -------------- 3 files changed, 129 insertions(+), 106 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/AtCreationRequest.java delete mode 100644 src/main/java/org/qortal/data/transaction/CreationRequest.java diff --git a/src/main/java/org/qortal/api/model/AtCreationRequest.java b/src/main/java/org/qortal/api/model/AtCreationRequest.java new file mode 100644 index 00000000..14ccdaa2 --- /dev/null +++ b/src/main/java/org/qortal/api/model/AtCreationRequest.java @@ -0,0 +1,102 @@ +package org.qortal.api.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlTransient; + +import org.bouncycastle.util.encoders.Base64; +import org.bouncycastle.util.encoders.DecoderException; + +@XmlAccessorType(XmlAccessType.FIELD) +public class AtCreationRequest { + + @Schema(description = "CIYAM AT version", example = "2") + private short ciyamAtVersion; + + @Schema(description = "base64-encoded code bytes", example = "") + private String codeBytesBase64; + + @Schema(description = "base64-encoded data bytes", example = "") + private String dataBytesBase64; + + private short numCallStackPages; + private short numUserStackPages; + private long minActivationAmount; + + // Default constructor for JSON deserialization + public AtCreationRequest() {} + + // Getters and setters + public short getCiyamAtVersion() { + return ciyamAtVersion; + } + + public void setCiyamAtVersion(short ciyamAtVersion) { + this.ciyamAtVersion = ciyamAtVersion; + } + + + public String getCodeBytesBase64() { + return this.codeBytesBase64; + } + + @XmlTransient + @Schema(hidden = true) + public byte[] getCodeBytes() { + if (this.codeBytesBase64 != null) { + try { + return Base64.decode(this.codeBytesBase64); + } + catch (DecoderException e) { + return null; + } + } + return null; + } + + + public String getDataBytesBase64() { + return this.dataBytesBase64; + } + + @XmlTransient + @Schema(hidden = true) + public byte[] getDataBytes() { + if (this.dataBytesBase64 != null) { + try { + return Base64.decode(this.dataBytesBase64); + } + catch (DecoderException e) { + return null; + } + } + return null; + } + + + public short getNumCallStackPages() { + return numCallStackPages; + } + + public void setNumCallStackPages(short numCallStackPages) { + this.numCallStackPages = numCallStackPages; + } + + public short getNumUserStackPages() { + return numUserStackPages; + } + + public void setNumUserStackPages(short numUserStackPages) { + this.numUserStackPages = numUserStackPages; + } + + public long getMinActivationAmount() { + return minActivationAmount; + } + + public void setMinActivationAmount(long minActivationAmount) { + this.minActivationAmount = minActivationAmount; + } +} diff --git a/src/main/java/org/qortal/api/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java index 80a6e299..13bfec83 100644 --- a/src/main/java/org/qortal/api/resource/AtResource.java +++ b/src/main/java/org/qortal/api/resource/AtResource.java @@ -9,7 +9,6 @@ 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.util.List; import javax.servlet.http.HttpServletRequest; @@ -28,7 +27,7 @@ import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; -import org.qortal.data.transaction.CreationRequest; +import org.qortal.api.model.AtCreationRequest; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -39,10 +38,11 @@ import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.utils.Base58; -import java.util.Base64; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.ObjectMapper; + + @Path("/at") @Tag(name = "Automated Transactions") public class AtResource { @@ -163,21 +163,21 @@ public class AtResource { } @POST - @Path("/createMachineState") + @Path("/create") @Operation( - summary = "Create MachineState bytes from the provided parameters", + summary = "Create base58-encoded AT creation bytes from the provided parameters", requestBody = @RequestBody( required = true, content = @Content( mediaType = MediaType.APPLICATION_JSON, schema = @Schema( - implementation = CreationRequest.class + implementation = AtCreationRequest.class ) ) ), responses = { @ApiResponse( - description = "MachineState bytes", + description = "AT creation bytes suitable for use in a DEPLOY_AT transaction", content = @Content( mediaType = MediaType.TEXT_PLAIN, schema = @Schema( @@ -187,27 +187,26 @@ public class AtResource { ) } ) - public String createMachineState(String jsonBody) throws IOException { - ObjectMapper objectMapper = new ObjectMapper(); - CreationRequest request = objectMapper.readValue(jsonBody, CreationRequest.class); - - logger.info("ciyamAtVersion: {}", request.getCiyamAtVersion()); - logger.info("codeBytes: {}", request.getCodeBytes()); - logger.info("codeBytes: {}", request.getNumUserStackPages()); - logger.info("codeBytes: {}", request.getDataBytes()); - logger.info("codeBytes: {}", request.getNumCallStackPages()); - logger.info("codeBytes: {}", request.getMinActivationAmount()); - byte[] creationBytes = MachineState.toCreationBytes( - request.getCiyamAtVersion(), - request.getCodeBytes(), - request.getDataBytes(), - request.getNumCallStackPages(), - request.getNumUserStackPages(), - request.getMinActivationAmount() - ); - return Base58.encode(creationBytes); - + public String create(AtCreationRequest atCreationRequest) { + if (atCreationRequest.getCiyamAtVersion() < 2) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "ciyamAtVersion must be at least 2"); + } + if (atCreationRequest.getCodeBytes() == null) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Valid codeBytesBase64 must be supplied"); + } + if (atCreationRequest.getDataBytes() == null) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Valid dataBytesBase64 must be supplied"); + } + byte[] creationBytes = MachineState.toCreationBytes( + atCreationRequest.getCiyamAtVersion(), + atCreationRequest.getCodeBytes(), + atCreationRequest.getDataBytes(), + atCreationRequest.getNumCallStackPages(), + atCreationRequest.getNumUserStackPages(), + atCreationRequest.getMinActivationAmount() + ); + return Base58.encode(creationBytes); } @POST @Operation( diff --git a/src/main/java/org/qortal/data/transaction/CreationRequest.java b/src/main/java/org/qortal/data/transaction/CreationRequest.java deleted file mode 100644 index 9d50458c..00000000 --- a/src/main/java/org/qortal/data/transaction/CreationRequest.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.qortal.data.transaction; - -import java.util.Base64; -import com.fasterxml.jackson.annotation.JsonProperty; -public class CreationRequest { - - private short ciyamAtVersion; - @JsonProperty("codeBytesBase64") - private String codeBytesBase64; - private String dataBytesBase64; - private short numCallStackPages; - private short numUserStackPages; - private long minActivationAmount; - - // Default constructor for JSON deserialization - public CreationRequest() {} - - // Getters and setters - public short getCiyamAtVersion() { - return ciyamAtVersion; - } - - public void setCiyamAtVersion(short ciyamAtVersion) { - this.ciyamAtVersion = ciyamAtVersion; - } - - public byte[] getCodeBytes() { - if (this.codeBytesBase64 != null) { - return Base64.getDecoder().decode(this.codeBytesBase64); - } - return new byte[0]; - } - - public void setCodeBytesBase64(String codeBytesBase64) { - this.codeBytesBase64 = codeBytesBase64; - } - public String getCodeBytes2() { - return codeBytesBase64; - - } - - - - public byte[] getDataBytes() { - if (this.dataBytesBase64 != null) { - return Base64.getDecoder().decode(this.dataBytesBase64); - } - return new byte[0]; - } - - public void setDataBytesBase64(String dataBytesBase64) { - this.dataBytesBase64 = dataBytesBase64; - } - - public short getNumCallStackPages() { - return numCallStackPages; - } - - public void setNumCallStackPages(short numCallStackPages) { - this.numCallStackPages = numCallStackPages; - } - - public short getNumUserStackPages() { - return numUserStackPages; - } - - public void setNumUserStackPages(short numUserStackPages) { - this.numUserStackPages = numUserStackPages; - } - - public long getMinActivationAmount() { - return minActivationAmount; - } - - public void setMinActivationAmount(long minActivationAmount) { - this.minActivationAmount = minActivationAmount; - } -} From 760788e82b76ab8f99e33c45c111bca226f48188 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 25 Aug 2023 12:12:32 +0100 Subject: [PATCH 493/496] Updated tools. --- tools/approve-auto-update.sh | 2 +- tools/approve-dev-transaction.sh | 2 +- tools/publish-auto-update-v5.pl | 163 ------------------------------- tools/publish-auto-update.pl | 20 +++- tools/tx.pl | 4 +- 5 files changed, 19 insertions(+), 172 deletions(-) delete mode 100755 tools/publish-auto-update-v5.pl diff --git a/tools/approve-auto-update.sh b/tools/approve-auto-update.sh index cbfa280d..c34b6de7 100755 --- a/tools/approve-auto-update.sh +++ b/tools/approve-auto-update.sh @@ -50,7 +50,7 @@ tx_json=$( cat < 2; - -my $port = $opt{p} || 12391; -my $privkey = shift @ARGV; -my $commit_hash = shift @ARGV; - -my $git_dir = `git rev-parse --show-toplevel`; -die("Cannot determine git top level dir\n") unless $git_dir; - -chomp $git_dir; -chdir($git_dir) || die("Can't change directory to $git_dir: $!\n"); - -open(POM, '<', 'pom.xml') || die ("Can't open 'pom.xml': $!\n"); -my $project; -while () { - if (m/(\w+)<.artifactId>/o) { - $project = $1; - last; - } -} -close(POM); - -my $apikey = read_file('apikey.txt'); - -# Do we need to determine commit hash? -unless ($commit_hash) { - # determine git branch - my $branch_name = ` git symbolic-ref -q HEAD `; - chomp $branch_name; - $branch_name =~ s|^refs/heads/||; # ${branch_name##refs/heads/} - - # short-form commit hash on base branch (non-auto-update) - $commit_hash ||= `git show --no-patch --format=%h`; - die("Can't find commit hash\n") if ! defined $commit_hash; - chomp $commit_hash; - printf "Commit hash on '%s' branch: %s\n", $branch_name, $commit_hash; -} else { - printf "Using given commit hash: %s\n", $commit_hash; -} - -# build timestamp / commit timestamp on base branch -my $timestamp = `git show --no-patch --format=%ct ${commit_hash}`; -die("Can't determine commit timestamp\n") if ! defined $timestamp; -$timestamp *= 1000; # Convert to milliseconds - -# locate sha256 utility -my $SHA256 = `which sha256sum || which sha256`; -chomp $SHA256; -die("Can't find sha256sum or sha256\n") unless length($SHA256) > 0; - -# SHA256 of actual update file -my $sha256 = `git show auto-update-${commit_hash}:${project}.update | ${SHA256} | head -c 64`; -die("Can't calculate SHA256 of ${project}.update\n") unless $sha256 =~ m/(\S{64})/; -chomp $sha256; - -# long-form commit hash of HEAD on auto-update branch -my $update_hash = `git rev-parse refs/heads/auto-update-${commit_hash}`; -die("Can't find commit hash for HEAD on auto-update-${commit_hash} branch\n") if ! defined $update_hash; -chomp $update_hash; - -printf "Build timestamp (ms): %d / 0x%016x\n", $timestamp, $timestamp; -printf "Auto-update commit hash: %s\n", $update_hash; -printf "SHA256 of ${project}.update: %s\n", $sha256; - -my $tx_type = 10; -my $tx_timestamp = time() * 1000; -my $tx_group_id = 1; -my $service = 1; -printf "\nARBITRARY(%d) transaction with timestamp %d, txGroupID %d and service %d\n", $tx_type, $tx_timestamp, $tx_group_id, $service; - -my $data_hex = sprintf "%016x%s%s", $timestamp, $update_hash, $sha256; -printf "\nARBITRARY transaction data payload: %s\n", $data_hex; - -my $n_payments = 0; -my $data_type = 1; # RAW_DATA -my $data_length = length($data_hex) / 2; # two hex chars per byte -my $fee = 0; -my $nonce = 0; -my $name_length = 0; -my $identifier_length = 0; -my $method = 0; # PUT -my $secret_length = 0; -my $compression = 0; # None -my $metadata_hash_length = 0; - -die("Something's wrong: data length is not 60 bytes!\n") if $data_length != 60; - -my $pubkey = `curl --silent --url http://localhost:${port}/utils/publickey --data ${privkey}`; -die("Can't convert private key to public key:\n$pubkey\n") unless $pubkey =~ m/^\w{44}$/; -printf "\nPublic key: %s\n", $pubkey; - -my $pubkey_hex = `curl --silent --url http://localhost:${port}/utils/frombase58 --data ${pubkey}`; -die("Can't convert base58 public key to hex:\n$pubkey_hex\n") unless $pubkey_hex =~ m/^[A-Za-z0-9]{64}$/; -printf "Public key hex: %s\n", $pubkey_hex; - -my $address = `curl --silent --url http://localhost:${port}/addresses/convert/${pubkey}`; -die("Can't convert base58 public key to address:\n$address\n") unless $address =~ m/^\w{33,34}$/; -printf "Address: %s\n", $address; - -my $reference = `curl --silent --url http://localhost:${port}/addresses/lastreference/${address}`; -die("Can't fetch last reference for $address:\n$reference\n") unless $reference =~ m/^\w{87,88}$/; -printf "Last reference: %s\n", $reference; - -my $reference_hex = `curl --silent --url http://localhost:${port}/utils/frombase58 --data ${reference}`; -die("Can't convert base58 reference to hex:\n$reference_hex\n") unless $reference_hex =~ m/^[A-Za-z0-9]{128}$/; -printf "Last reference hex: %s\n", $reference_hex; - -my $raw_tx_hex = sprintf("%08x%016x%08x%s%s%08x%08x%08x%08x%08x%08x%08x%08x%02x%08x%s%08x%08x%016x", $tx_type, $tx_timestamp, $tx_group_id, $reference_hex, $pubkey_hex, $nonce, $name_length, $identifier_length, $method, $secret_length, $compression, $n_payments, $service, $data_type, $data_length, $data_hex, $data_length, $metadata_hash_length, $fee); -printf "\nRaw transaction hex:\n%s\n", $raw_tx_hex; - -my $raw_tx = `curl --silent --url http://localhost:${port}/utils/tobase58/${raw_tx_hex}`; -die("Can't convert raw transaction hex to base58:\n$raw_tx\n") unless $raw_tx =~ m/^\w{300,320}$/; # Roughly 305 to 320 base58 chars -printf "\nRaw transaction (base58):\n%s\n", $raw_tx; - -my $computed_tx = `curl --silent -X POST --url http://localhost:${port}/arbitrary/compute -H "X-API-KEY: ${apikey}" -d "${raw_tx}"`; -die("Can't compute nonce for transaction:\n$computed_tx\n") unless $computed_tx =~ m/^\w{300,320}$/; # Roughly 300 to 320 base58 chars -printf "\nRaw computed transaction (base58):\n%s\n", $computed_tx; - -my $sign_data = qq|' { "privateKey": "${privkey}", "transactionBytes": "${computed_tx}" } '|; -my $signed_tx = `curl --silent -H "accept: text/plain" -H "Content-Type: application/json" --url http://localhost:${port}/transactions/sign --data ${sign_data}`; -die("Can't sign raw transaction:\n$signed_tx\n") unless $signed_tx =~ m/^\w{390,410}$/; # +90ish longer than $raw_tx -printf "\nSigned transaction:\n%s\n", $signed_tx; - -# Check we can actually fetch update -my $origin = `git remote get-url origin`; -die("Unable to get github url for 'origin'?\n") unless $origin && $origin =~ m/:(.*)\.git$/; -my $repo = $1; -my $update_url = "https://github.com/${repo}/raw/${update_hash}/${project}.update"; - -my $fetch_result = `curl --silent -o /dev/null --location --range 0-1 --head --write-out '%{http_code}' --url ${update_url}`; -die("\nUnable to fetch update from ${update_url}\n") if $fetch_result ne '200'; -printf "\nUpdate fetchable from ${update_url}\n"; - -# Flush STDOUT after every output -$| = 1; -print "\n"; -for (my $delay = 5; $delay > 0; --$delay) { - printf "\rSubmitting transaction in %d second%s... CTRL-C to abort ", $delay, ($delay != 1 ? 's' : ''); - sleep 1; -} - -printf "\rSubmitting transaction NOW... \n"; -my $result = `curl --silent --url http://localhost:${port}/transactions/process --data ${signed_tx}`; -chomp $result; -die("Transaction wasn't accepted:\n$result\n") unless $result eq 'true'; - -my $decoded_tx = `curl --silent -H "Content-Type: application/json" --url http://localhost:${port}/transactions/decode --data ${signed_tx}`; -printf "\nTransaction accepted:\n$decoded_tx\n"; diff --git a/tools/publish-auto-update.pl b/tools/publish-auto-update.pl index ad43b2f4..9e6b885b 100755 --- a/tools/publish-auto-update.pl +++ b/tools/publish-auto-update.pl @@ -4,6 +4,7 @@ use strict; use warnings; use POSIX; use Getopt::Std; +use File::Slurp; sub usage() { die("usage: $0 [-p api-port] dev-private-key [short-commit-hash]\n"); @@ -34,6 +35,8 @@ while () { } close(POM); +my $apikey = read_file('apikey.txt'); + # Do we need to determine commit hash? unless ($commit_hash) { # determine git branch @@ -84,9 +87,16 @@ my $data_hex = sprintf "%016x%s%s", $timestamp, $update_hash, $sha256; printf "\nARBITRARY transaction data payload: %s\n", $data_hex; my $n_payments = 0; -my $is_raw = 1; # RAW_DATA +my $data_type = 1; # RAW_DATA my $data_length = length($data_hex) / 2; # two hex chars per byte -my $fee = 0.001 * 1e8; +my $fee = 0.01 * 1e8; +my $nonce = 0; +my $name_length = 0; +my $identifier_length = 0; +my $method = 0; # PUT +my $secret_length = 0; +my $compression = 0; # None +my $metadata_hash_length = 0; die("Something's wrong: data length is not 60 bytes!\n") if $data_length != 60; @@ -110,16 +120,16 @@ my $reference_hex = `curl --silent --url http://localhost:${port}/utils/frombase die("Can't convert base58 reference to hex:\n$reference_hex\n") unless $reference_hex =~ m/^[A-Za-z0-9]{128}$/; printf "Last reference hex: %s\n", $reference_hex; -my $raw_tx_hex = sprintf("%08x%016x%08x%s%s%08x%08x%02x%08x%s%016x", $tx_type, $tx_timestamp, $tx_group_id, $reference_hex, $pubkey_hex, $n_payments, $service, $is_raw, $data_length, $data_hex, $fee); +my $raw_tx_hex = sprintf("%08x%016x%08x%s%s%08x%08x%08x%08x%08x%08x%08x%08x%02x%08x%s%08x%08x%016x", $tx_type, $tx_timestamp, $tx_group_id, $reference_hex, $pubkey_hex, $nonce, $name_length, $identifier_length, $method, $secret_length, $compression, $n_payments, $service, $data_type, $data_length, $data_hex, $data_length, $metadata_hash_length, $fee); printf "\nRaw transaction hex:\n%s\n", $raw_tx_hex; my $raw_tx = `curl --silent --url http://localhost:${port}/utils/tobase58/${raw_tx_hex}`; -die("Can't convert raw transaction hex to base58:\n$raw_tx\n") unless $raw_tx =~ m/^\w{255,265}$/; # Roughly 255 to 265 base58 chars +die("Can't convert raw transaction hex to base58:\n$raw_tx\n") unless $raw_tx =~ m/^\w{300,320}$/; # Roughly 305 to 320 base58 chars printf "\nRaw transaction (base58):\n%s\n", $raw_tx; my $sign_data = qq|' { "privateKey": "${privkey}", "transactionBytes": "${raw_tx}" } '|; my $signed_tx = `curl --silent -H "accept: text/plain" -H "Content-Type: application/json" --url http://localhost:${port}/transactions/sign --data ${sign_data}`; -die("Can't sign raw transaction:\n$signed_tx\n") unless $signed_tx =~ m/^\w{345,355}$/; # +90ish longer than $raw_tx +die("Can't sign raw transaction:\n$signed_tx\n") unless $signed_tx =~ m/^\w{390,410}$/; # +90ish longer than $raw_tx printf "\nSigned transaction:\n%s\n", $signed_tx; # Check we can actually fetch update diff --git a/tools/tx.pl b/tools/tx.pl index 1cb3dd5b..7cdf444b 100755 --- a/tools/tx.pl +++ b/tools/tx.pl @@ -40,7 +40,7 @@ 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 $DEFAULT_FEE = 0.01; our %TRANSACTION_TYPES = ( payment => { @@ -92,7 +92,7 @@ our %TRANSACTION_TYPES = ( }, remove_group_admin => { url => 'groups/removeadmin', - required => [qw(groupId txGroupId member)], + required => [qw(groupId txGroupId admin)], key_name => 'ownerPublicKey', }, group_approval => { From ad51073f25a0582c1b7555b470ea83517faa25d6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 25 Aug 2023 16:11:43 +0100 Subject: [PATCH 494/496] mempowTransactionUpdatesTimestamp set to Fri Sep 01 2023 09:00:00 UTC --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 996ae2c1..9a26c99e 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -29,7 +29,7 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, "selfSponsorshipAlgoV1SnapshotTimestamp": 1670230000000, - "mempowTransactionUpdatesTimestamp": 9999999999999, + "mempowTransactionUpdatesTimestamp": 1693558800000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From a08f10ece3307cb93596970f6f30d64f692a28f3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 25 Aug 2023 16:13:23 +0100 Subject: [PATCH 495/496] Bump version to 4.3.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5ba103e6..fbcd40a5 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 4.2.4 + 4.3.0 jar true From eb6a834fd96014f5dcd2b70aca28bc0aff1596af Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 1 Sep 2023 10:47:45 +0100 Subject: [PATCH 496/496] Default minPeerVersion set to 4.3.0 --- 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 ab1936c7..bdff9506 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -230,7 +230,7 @@ public class Settings { public long recoveryModeTimeout = 9999999999999L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "4.1.2"; + private String minPeerVersion = "4.3.0"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */

+ * Selected block for the initial run on the "self sponsorship detection algorithm" + */ +public final class SelfSponsorshipAlgoV1Block { + + private static final Logger LOGGER = LogManager.getLogger(SelfSponsorshipAlgoV1Block.class); + + + private SelfSponsorshipAlgoV1Block() { + /* Do not instantiate */ + } + + public static void processAccountPenalties(Block block) throws DataException { + LOGGER.info("Running algo for block processing - this will take a while..."); + logPenaltyStats(block.repository); + long startTime = System.currentTimeMillis(); + Set penalties = getAccountPenalties(block.repository, -5000000); + block.repository.getAccountRepository().updateBlocksMintedPenalties(penalties); + long totalTime = System.currentTimeMillis() - startTime; + String hash = getHash(penalties.stream().map(p -> p.getAddress()).collect(Collectors.toList())); + LOGGER.info("{} penalty addresses processed (hash: {}). Total time taken: {} seconds", penalties.size(), hash, (int)(totalTime / 1000.0f)); + logPenaltyStats(block.repository); + + int updatedCount = updateAccountLevels(block.repository, penalties); + LOGGER.info("Account levels updated for {} penalty addresses", updatedCount); + } + + public static void orphanAccountPenalties(Block block) throws DataException { + LOGGER.info("Running algo for block orphaning - this will take a while..."); + logPenaltyStats(block.repository); + long startTime = System.currentTimeMillis(); + Set penalties = getAccountPenalties(block.repository, 5000000); + block.repository.getAccountRepository().updateBlocksMintedPenalties(penalties); + long totalTime = System.currentTimeMillis() - startTime; + String hash = getHash(penalties.stream().map(p -> p.getAddress()).collect(Collectors.toList())); + LOGGER.info("{} penalty addresses orphaned (hash: {}). Total time taken: {} seconds", penalties.size(), hash, (int)(totalTime / 1000.0f)); + logPenaltyStats(block.repository); + + int updatedCount = updateAccountLevels(block.repository, penalties); + LOGGER.info("Account levels updated for {} penalty addresses", updatedCount); + } + + public static Set getAccountPenalties(Repository repository, int penalty) throws DataException { + final long snapshotTimestamp = BlockChain.getInstance().getSelfSponsorshipAlgoV1SnapshotTimestamp(); + Set penalties = new LinkedHashSet<>(); + List addresses = repository.getTransactionRepository().getConfirmedRewardShareCreatorsExcludingSelfShares(); + for (String address : addresses) { + //System.out.println(String.format("address: %s", address)); + SelfSponsorshipAlgoV1 selfSponsorshipAlgoV1 = new SelfSponsorshipAlgoV1(repository, address, snapshotTimestamp, false); + selfSponsorshipAlgoV1.run(); + //System.out.println(String.format("Penalty addresses: %d", selfSponsorshipAlgoV1.getPenaltyAddresses().size())); + + for (String penaltyAddress : selfSponsorshipAlgoV1.getPenaltyAddresses()) { + penalties.add(new AccountPenaltyData(penaltyAddress, penalty)); + } + } + return penalties; + } + + private static int updateAccountLevels(Repository repository, Set accountPenalties) throws DataException { + final List cumulativeBlocksByLevel = BlockChain.getInstance().getCumulativeBlocksByLevel(); + final int maximumLevel = cumulativeBlocksByLevel.size() - 1; + + int updatedCount = 0; + + for (AccountPenaltyData penaltyData : accountPenalties) { + AccountData accountData = repository.getAccountRepository().getAccount(penaltyData.getAddress()); + final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty(); + + // Shortcut for penalties + if (effectiveBlocksMinted < 0) { + accountData.setLevel(0); + repository.getAccountRepository().setLevel(accountData); + updatedCount++; + LOGGER.trace(() -> String.format("Block minter %s dropped to level %d", accountData.getAddress(), accountData.getLevel())); + continue; + } + + for (int newLevel = maximumLevel; newLevel >= 0; --newLevel) { + if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) { + accountData.setLevel(newLevel); + repository.getAccountRepository().setLevel(accountData); + updatedCount++; + LOGGER.trace(() -> String.format("Block minter %s increased to level %d", accountData.getAddress(), accountData.getLevel())); + break; + } + } + } + + return updatedCount; + } + + private static void logPenaltyStats(Repository repository) { + try { + LOGGER.info(getPenaltyStats(repository)); + + } catch (DataException e) {} + } + + private static AccountPenaltyStats getPenaltyStats(Repository repository) throws DataException { + List accounts = repository.getAccountRepository().getPenaltyAccounts(); + return AccountPenaltyStats.fromAccounts(accounts); + } + + public static String getHash(List penaltyAddresses) { + if (penaltyAddresses == null || penaltyAddresses.isEmpty()) { + return null; + } + Collections.sort(penaltyAddresses); + return Base58.encode(Crypto.digest(StringUtils.join(penaltyAddresses).getBytes(StandardCharsets.UTF_8))); + } + +} From c108afa27c5d84dd197a5fa8cc343099a6e53671 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 20:57:36 +0000 Subject: [PATCH 075/496] Self sponsorship algo tests --- .../test/SelfSponsorshipAlgoV1Tests.java | 1627 +++++++++++++++++ .../test-chain-v2-self-sponsorship-algo.json | 114 ++ ...est-settings-v2-self-sponsorship-algo.json | 20 + 3 files changed, 1761 insertions(+) create mode 100644 src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java create mode 100644 src/test/resources/test-chain-v2-self-sponsorship-algo.json create mode 100644 src/test/resources/test-settings-v2-self-sponsorship-algo.json diff --git a/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java new file mode 100644 index 00000000..91628dd3 --- /dev/null +++ b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java @@ -0,0 +1,1627 @@ +package org.qortal.test; + +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.block.Block; +import org.qortal.controller.BlockMinter; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PaymentTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.data.transaction.TransferPrivsTransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.test.common.*; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.TransferPrivsTransaction; +import org.qortal.utils.NTP; + +import java.util.*; + +import static org.junit.Assert.*; +import static org.qortal.test.common.AccountUtils.fee; +import static org.qortal.transaction.Transaction.ValidationResult.*; + +public class SelfSponsorshipAlgoV1Tests extends Common { + + + @Before + public void beforeTest() throws DataException { + Common.useSettings("test-settings-v2-self-sponsorship-algo.json"); + NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); + } + + + @Test + public void testSingleSponsor() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob self sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(11, block.getBlockData().getOnlineAccountsCount()); + assertEquals(10, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testMultipleSponsors() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(26, block.getBlockData().getOnlineAccountsCount()); + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees have no penalties + List chloeAndSponsees = new ArrayList<>(chloeSponsees); + chloeAndSponsees.add(chloeAccount); + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(0, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees have no penalties + List dilbertAndSponsees = new ArrayList<>(dilbertSponsees); + dilbertAndSponsees.add(dilbertAccount); + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees still have no penalties + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(0, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees still have no penalties + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testMintBlockWithSignerPenalty() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + List onlineAccountsAliceSigner = new ArrayList<>(); + List onlineAccountsBobSigner = new ArrayList<>(); + + // Alice self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + onlineAccountsAliceSigner.add(aliceSelfShare); + + // Bob self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + onlineAccountsBobSigner.add(bobSelfShare); + + // Include Alice and Bob's online accounts in each other's arrays + onlineAccountsAliceSigner.add(bobSelfShare); + onlineAccountsBobSigner.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccountsAliceSigner.addAll(chloeSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccountsAliceSigner.addAll(dilbertSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks (Bob is the signer) + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + // Get reward share transaction count + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up (Bob is the signer) + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccountsAliceSigner.addAll(bobSponseeSelfShares); + onlineAccountsBobSigner.addAll(bobSponseeSelfShares); + + // Mint blocks (Bob is the signer) + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) (Bob is the signer) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs (Bob is the signer) + // Block should be valid, because new account levels don't take effect until next block's validation + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Mint a block, but Bob is now an invalid signer because he is level 0 + block = BlockMinter.mintTestingBlockUnvalidated(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + // Block should be null as it's unable to be minted + assertNull(block); + + // Mint the same block with Alice as the signer, and this time it should be valid + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + // Block should NOT be null + assertNotNull(block); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testMintBlockWithFounderSignerPenalty() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + List onlineAccountsAliceSigner = new ArrayList<>(); + List onlineAccountsBobSigner = new ArrayList<>(); + + // Alice self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + onlineAccountsAliceSigner.add(aliceSelfShare); + + // Bob self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + onlineAccountsBobSigner.add(bobSelfShare); + + // Include Alice and Bob's online accounts in each other's arrays + onlineAccountsAliceSigner.add(bobSelfShare); + onlineAccountsBobSigner.add(aliceSelfShare); + + PrivateKeyAccount aliceAccount = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Alice sponsors 10 accounts + List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + onlineAccountsAliceSigner.addAll(aliceSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(aliceSponseesOnlineAccounts); + + // Bob sponsors 9 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); + + // Mint blocks (Bob is the signer) + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + // Get reward share transaction count + assertEquals(19, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up (Alice is the signer) + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + onlineAccountsAliceSigner.addAll(aliceSponseeSelfShares); + onlineAccountsBobSigner.addAll(aliceSponseeSelfShares); + + // Mint blocks (Bob is the signer) + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Alice then consolidates funds + consolidateFunds(repository, aliceSponsees, aliceAccount); + + // Mint until block 19 (the algo runs at block 20) (Bob is the signer) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that alice and her sponsees have no penalties + List aliceAndSponsees = new ArrayList<>(aliceSponsees); + aliceAndSponsees.add(aliceAccount); + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs (Alice is the signer) + // Block should be valid, because new account levels don't take effect until next block's validation + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + // Ensure that alice and her sponsees now have penalties + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(-5000000, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that alice and her sponsees are now level 0 + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getLevel()); + + // Mint a block, but Alice is now an invalid signer because she has lost founder minting abilities + block = BlockMinter.mintTestingBlockUnvalidated(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + // Block should be null as it's unable to be minted + assertNull(block); + + // Mint the same block with Bob as the signer, and this time it should be valid + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + // Block should NOT be null + assertNotNull(block); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testOnlineAccountsWithPenalties() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + // Bob self share online + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + onlineAccounts.add(bobSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(27, block.getBlockData().getOnlineAccountsCount()); + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Ensure that chloe's sponsees are present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(chloeSponsees, block)); + + // Ensure that dilbert's sponsees are present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(dilbertSponsees, block)); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Mint another few blocks + while (block.getBlockData().getHeight() < 24) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(24, (int)block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees are NOT present in block's online accounts (due to penalties) + assertFalse(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Ensure that chloe's sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(chloeSponsees, block)); + + // Ensure that dilbert's sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(dilbertSponsees, block)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testFounderOnlineAccountsWithPenalties() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Bob self share online, and will be used to mint the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(bobSelfShare); + + // Alice self share online + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount aliceAccount = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Alice sponsors 10 accounts + List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + onlineAccounts.addAll(aliceSponseesOnlineAccounts); + onlineAccounts.addAll(aliceSponseesOnlineAccounts); + + // Bob sponsors 9 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks (Bob is the signer) + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Get reward share transaction count + assertEquals(19, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up (Alice is the signer) + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + onlineAccounts.addAll(aliceSponseeSelfShares); + + // Mint blocks (Bob is the signer) + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Alice then consolidates funds + consolidateFunds(repository, aliceSponsees, aliceAccount); + + // Mint until block 19 (the algo runs at block 20) (Bob is the signer) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that alice and her sponsees have no penalties + List aliceAndSponsees = new ArrayList<>(aliceSponsees); + aliceAndSponsees.add(aliceAccount); + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs (Alice is the signer) + // Block should be valid, because new account levels don't take effect until next block's validation + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that alice and her sponsees now have penalties + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(-5000000, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that alice and her sponsees are now level 0 + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getLevel()); + + // Ensure that alice and her sponsees don't have penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Ensure that alice and her sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(aliceAndSponsees, block)); + + // Mint another few blocks + while (block.getBlockData().getHeight() < 24) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(24, (int)block.getBlockData().getHeight()); + + // Ensure that alice and her sponsees are NOT present in block's online accounts (due to penalties) + assertFalse(areAllAccountsPresentInBlock(aliceAndSponsees, block)); + + // Ensure that bob and his sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testPenaltyAccountCreateRewardShare() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(21, block.getBlockData().getOnlineAccountsCount()); + assertEquals(20, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Bob creates a valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Bob can no longer create a reward share transaction + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, createRandomRewardShare(repository, bobAccount)); + + // ... but Chloe still can + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, chloeAccount)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Bob creates another valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testPenaltyFounderCreateRewardShare() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Bob self share online, and will be used to mint the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(bobSelfShare); + + PrivateKeyAccount aliceAccount = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Alice sponsors 10 accounts + List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + onlineAccounts.addAll(aliceSponseesOnlineAccounts); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(21, block.getBlockData().getOnlineAccountsCount()); + assertEquals(20, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Generate self shares so the sponsees can start minting + List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + onlineAccounts.addAll(aliceSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Alice then consolidates funds + consolidateFunds(repository, aliceSponsees, aliceAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Alice creates a valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, aliceAccount)); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that alice now has a penalty + assertEquals(-5000000, (int) new Account(repository, aliceAccount.getAddress()).getBlocksMintedPenalty()); + + // Ensure that alice and her sponsees are now level 0 + assertEquals(0, (int) new Account(repository, aliceAccount.getAddress()).getLevel()); + + // Alice can no longer create a reward share transaction + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, createRandomRewardShare(repository, aliceAccount)); + + // ... but Bob still can + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Alice creates another valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, aliceAccount)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + /** + * This is a test to prove that Dilbert levels up from 6 to 7 in the same block that the self + * sponsorship algo runs. It is here to give some confidence in the following testPenaltyAccountLevelUp() + * test, in which we will test what happens if a penalty is applied or removed in the same block + * that an account would otherwise have leveled up. It also gives some confidence that the algo + * doesn't affect the levels of unflagged accounts. + * + * @throws DataException + */ + @Test + public void testNonPenaltyAccountLevelUp() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Dilbert sponsors 10 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Make sure Dilbert hasn't leveled up yet + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure Dilbert has leveled up + assertEquals(7, (int)dilbertAccount.getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Make sure Dilbert has returned to level 6 + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testPenaltyAccountLevelUp() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Dilbert sponsors 10 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Generate self shares so the sponsees can start minting + List dilbertSponseeSelfShares = generateSelfShares(repository, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Dilbert then consolidates funds + consolidateFunds(repository, dilbertSponsees, dilbertAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Make sure Dilbert hasn't leveled up yet + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure Dilbert is now level 0 instead of 7 (due to penalty) + assertEquals(0, (int)dilbertAccount.getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Make sure Dilbert has returned to level 6 + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testDuplicateSponsors() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors THE SAME 10 accounts + for (PrivateKeyAccount bobSponsee : bobSponsees) { + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, chloeAccount, bobSponsee, 0, fee); + TransactionUtils.signAndImportValid(repository, transactionData, chloeAccount); + } + List chloeSponsees = new ArrayList<>(bobSponsees); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(26, block.getBlockData().getOnlineAccountsCount()); + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees also have penalties, as they relate to the same network of accounts + List chloeAndSponsees = new ArrayList<>(chloeSponsees); + chloeAndSponsees.add(chloeAccount); + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(-5000000, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees have no penalties + List dilbertAndSponsees = new ArrayList<>(dilbertSponsees); + dilbertAndSponsees.add(dilbertAccount); + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees still have no penalties again + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(0, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees still have no penalties + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testTransferPrivsBeforeAlgoBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 18 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 18) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(18, (int) block.getBlockData().getHeight()); + + // Bob then issues a TRANSFER_PRIVS + PrivateKeyAccount recipientAccount = randomTransferPrivs(repository, bobAccount); + + // Ensure recipient has no level (actually, no account record) at this point (pre-confirmation) + assertNull(recipientAccount.getLevel()); + + // Mint another block, so that the TRANSFER_PRIVS confirms + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Now ensure that the TRANSFER_PRIVS recipient has inherited Bob's level, and Bob is at level 0 + assertTrue(recipientAccount.getLevel() > 0); + assertEquals(0, (int)bobAccount.getLevel()); + assertEquals(0, (int) new Account(repository, recipientAccount.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob's sponsees are greater than level 0 + // Bob's account won't be, as he has transferred privs + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Ensure recipient account has penalty too + assertEquals(-5000000, (int) new Account(repository, recipientAccount.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount.getAddress()).getLevel()); + + // TODO: check both recipients' sponsees + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that Bob's sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure recipient account has no penalty again and has a level greater than 0 + assertEquals(0, (int) new Account(repository, recipientAccount.getAddress()).getBlocksMintedPenalty()); + assertTrue(new Account(repository, recipientAccount.getAddress()).getLevel() > 0); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testTransferPrivsInAlgoBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Bob then issues a TRANSFER_PRIVS + PrivateKeyAccount recipientAccount = randomTransferPrivs(repository, bobAccount); + + // Ensure recipient has no level (actually, no account record) at this point (pre-confirmation) + assertNull(recipientAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Now ensure that the TRANSFER_PRIVS recipient has inherited Bob's level, and Bob is at level 0 + assertTrue(recipientAccount.getLevel() > 0); + assertEquals(0, (int)bobAccount.getLevel()); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure recipient has no level again + assertNull(recipientAccount.getLevel()); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testTransferPrivsAfterAlgoBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Bob then issues a TRANSFER_PRIVS, which should be invalid + Transaction transferPrivsTransaction = randomTransferPrivsTransaction(repository, bobAccount); + assertEquals(ACCOUNT_NOT_TRANSFERABLE, transferPrivsTransaction.isValid()); + + // Orphan last 2 blocks + BlockUtils.orphanLastBlock(repository); + BlockUtils.orphanLastBlock(repository); + + // TRANSFER_PRIVS should now be valid + transferPrivsTransaction = randomTransferPrivsTransaction(repository, bobAccount); + assertEquals(OK, transferPrivsTransaction.isValid()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testDoubleTransferPrivs() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 17 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 17) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(17, (int) block.getBlockData().getHeight()); + + // Bob then issues a TRANSFER_PRIVS + PrivateKeyAccount recipientAccount1 = randomTransferPrivs(repository, bobAccount); + + // Ensure recipient has no level (actually, no account record) at this point (pre-confirmation) + assertNull(recipientAccount1.getLevel()); + + // Bob and also sends some QORT to cover future transaction fees + // This mints another block, and the TRANSFER_PRIVS confirms + AccountUtils.pay(repository, bobAccount, recipientAccount1.getAddress(), 123456789L); + + // Now ensure that the TRANSFER_PRIVS recipient has inherited Bob's level, and Bob is at level 0 + assertTrue(recipientAccount1.getLevel() > 0); + assertEquals(0, (int)bobAccount.getLevel()); + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getBlocksMintedPenalty()); + + // The recipient account then issues a TRANSFER_PRIVS of their own + PrivateKeyAccount recipientAccount2 = randomTransferPrivs(repository, recipientAccount1); + + // Ensure recipientAccount2 has no level at this point (pre-confirmation) + assertNull(recipientAccount2.getLevel()); + + // Mint another block, so that the TRANSFER_PRIVS confirms + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Now ensure that the TRANSFER_PRIVS recipient2 has inherited Bob's level, and recipient1 is at level 0 + assertTrue(recipientAccount2.getLevel() > 0); + assertEquals(0, (int)recipientAccount1.getLevel()); + assertEquals(0, (int) new Account(repository, recipientAccount2.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob's sponsees are greater than level 0 + // Bob's account won't be, as he has transferred privs + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Ensure recipientAccount2 has penalty too + assertEquals(-5000000, (int) new Account(repository, recipientAccount2.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount2.getAddress()).getLevel()); + + // Ensure recipientAccount1 has penalty too + assertEquals(-5000000, (int) new Account(repository, recipientAccount1.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getLevel()); + + // TODO: check recipient's sponsees + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that Bob's sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure recipientAccount1 has no penalty again and is level 0 + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getLevel()); + + // Ensure recipientAccount2 has no penalty again and has a level greater than 0 + assertEquals(0, (int) new Account(repository, recipientAccount2.getAddress()).getBlocksMintedPenalty()); + assertTrue(new Account(repository, recipientAccount2.getAddress()).getLevel() > 0); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + + + private static PrivateKeyAccount randomTransferPrivs(Repository repository, PrivateKeyAccount senderAccount) throws DataException { + // Generate random recipient account + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount recipientAccount = new PrivateKeyAccount(repository, randomPrivateKey); + + BaseTransactionData baseTransactionData = new BaseTransactionData(NTP.getTime(), 0, senderAccount.getLastReference(), senderAccount.getPublicKey(), fee, null); + TransactionData transactionData = new TransferPrivsTransactionData(baseTransactionData, recipientAccount.getAddress()); + + TransactionUtils.signAndImportValid(repository, transactionData, senderAccount); + + return recipientAccount; + } + + private static TransferPrivsTransaction randomTransferPrivsTransaction(Repository repository, PrivateKeyAccount senderAccount) throws DataException { + // Generate random recipient account + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount recipientAccount = new PrivateKeyAccount(repository, randomPrivateKey); + + BaseTransactionData baseTransactionData = new BaseTransactionData(NTP.getTime(), 0, senderAccount.getLastReference(), senderAccount.getPublicKey(), fee, null); + TransactionData transactionData = new TransferPrivsTransactionData(baseTransactionData, recipientAccount.getAddress()); + + return new TransferPrivsTransaction(repository, transactionData); + } + + private static List generateSponsorshipRewardShares(Repository repository, PrivateKeyAccount sponsorAccount, int accountsCount) throws DataException { + final int sharePercent = 0; + Random random = new Random(); + + List sponsees = new ArrayList<>(); + for (int i = 0; i < accountsCount; i++) { + + // Generate random sponsee account + byte[] randomPrivateKey = new byte[32]; + random.nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + sponsees.add(sponseeAccount); + + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, sponsorAccount, sponseeAccount, sharePercent, fee); + TransactionUtils.signAndImportValid(repository, transactionData, sponsorAccount); + } + + return sponsees; + } + + private static Transaction.ValidationResult createRandomRewardShare(Repository repository, PrivateKeyAccount account) throws DataException { + // Bob attempts to create a reward share transaction + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + TransactionData transactionData = AccountUtils.createRewardShare(repository, account, sponseeAccount, 0, fee); + return TransactionUtils.signAndImport(repository, transactionData, account); + } + + private static List generateSelfShares(Repository repository, List accounts) throws DataException { + final int sharePercent = 0; + + for (PrivateKeyAccount account : accounts) { + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, account, account, sharePercent, 0L); + TransactionUtils.signAndImportValid(repository, transactionData, account); + } + + return toRewardShares(repository, null, accounts); + } + + private static List toRewardShares(Repository repository, PrivateKeyAccount parentAccount, List accounts) { + List rewardShares = new ArrayList<>(); + + for (PrivateKeyAccount account : accounts) { + PrivateKeyAccount sponsor = (parentAccount != null) ? parentAccount : account; + byte[] rewardSharePrivateKey = sponsor.getRewardSharePrivateKey(account.getPublicKey()); + PrivateKeyAccount rewardShareAccount = new PrivateKeyAccount(repository, rewardSharePrivateKey); + rewardShares.add(rewardShareAccount); + } + + return rewardShares; + } + + private boolean areAllAccountsPresentInBlock(List accounts, Block block) throws DataException { + for (PrivateKeyAccount bobSponsee : accounts) { + boolean foundOnlineAccountInBlock = false; + for (Block.ExpandedAccount expandedAccount : block.getExpandedAccounts()) { + if (expandedAccount.getRecipientAccount().getAddress().equals(bobSponsee.getAddress())) { + foundOnlineAccountInBlock = true; + break; + } + } + if (!foundOnlineAccountInBlock) { + return false; + } + } + return true; + } + + private static void consolidateFunds(Repository repository, List sponsees, PrivateKeyAccount sponsor) throws DataException { + for (PrivateKeyAccount sponsee : sponsees) { + for (int i = 0; i < 5; i++) { + // Generate new payments from sponsee to sponsor + TransactionData paymentData = new PaymentTransactionData(TestTransaction.generateBase(sponsee), sponsor.getAddress(), 1); + TransactionUtils.signAndImportValid(repository, paymentData, sponsee); // updates paymentData's signature + } + } + } + +} \ No newline at end of file diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json new file mode 100644 index 00000000..7712ceb1 --- /dev/null +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -0,0 +1,114 @@ +{ + "isTestChain": true, + "blockTimestampMargin": 500, + "transactionExpiryPeriod": 86400000, + "maxBlockSize": 2097152, + "maxBytesPerUnitFee": 0, + "unitFee": "0.00000001", + "nameRegistrationUnitFees": [ + { "timestamp": 1645372800000, "fee": "5" } + ], + "requireGroupForApproval": false, + "minAccountLevelToRewardShare": 5, + "maxRewardSharesPerFounderMintingAccount": 20, + "maxRewardSharesByTimestamp": [ + { "timestamp": 0, "maxShares": 20 }, + { "timestamp": 9999999999999, "maxShares": 3 } + ], + "founderEffectiveMintingLevel": 10, + "onlineAccountSignaturesMinLifetime": 3600000, + "onlineAccountSignaturesMaxLifetime": 86400000, + "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "rewardsByHeight": [ + { "height": 1, "reward": 100 }, + { "height": 11, "reward": 10 }, + { "height": 21, "reward": 1 } + ], + "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": 1000000, "share": 0.01 } + ], + "qoraPerQortReward": 250, + "minAccountsToActivateShareBin": 30, + "shareBinActivationMinLevel": 7, + "blocksNeededByLevel": [ 5, 20, 30, 40, 50, 60, 18, 80, 90, 100 ], + "blockTimingsByHeight": [ + { "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 } + ], + "ciyamAtSettings": { + "feePerStep": "0.0001", + "maxStepsPerRound": 500, + "stepsPerFunctionCall": 10, + "minutesPerBlock": 1 + }, + "featureTriggers": { + "messageHeight": 0, + "atHeight": 0, + "assetsTimestamp": 0, + "votingTimestamp": 0, + "arbitraryTimestamp": 0, + "powfixTimestamp": 0, + "qortalTimestamp": 0, + "newAssetPricingTimestamp": 0, + "groupApprovalTimestamp": 0, + "atFindNextTransactionFix": 0, + "newBlockSigHeight": 999999, + "shareBinFix": 999999, + "sharesByLevelV2Height": 999999, + "rewardShareLimitTimestamp": 9999999999999, + "calcChainWeightTimestamp": 0, + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0, + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 20 + }, + "genesisInfo": { + "version": 4, + "timestamp": 0, + "transactions": [ + { "type": "ISSUE_ASSET", "assetName": "QORT", "description": "QORT native coin", "data": "", "quantity": 0, "isDivisible": true, "fee": 0 }, + { "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": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "1000000000" }, + { "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "amount": "1000000" }, + + { "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 }, + + { "type": "UPDATE_GROUP", "ownerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupId": 1, "newOwner": "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG", "newDescription": "developer group", "newIsOpen": false, "newApprovalThreshold": "PCT40", "minimumBlockDelay": 10, "maximumBlockDelay": 1440 }, + + { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "TEST", "description": "test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "issuerPublicKey": "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry", "assetName": "OTHER", "description": "other test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "GOLD", "description": "gold test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, + + { "type": "ACCOUNT_FLAGS", "target": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "rewardSharePublicKey": "7PpfnvLSG7y4HPh8hE7KoqAjLCkv7Ui6xw4mKAkbZtox", "sharePercent": "100" }, + + { "type": "ACCOUNT_LEVEL", "target": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "level": 5 }, + { "type": "REWARD_SHARE", "minterPublicKey": "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "rewardSharePublicKey": "CcABzvk26TFEHG7Yok84jxyd4oBtLkx8RJdGFVz2csvp", "sharePercent": 100 }, + + { "type": "ACCOUNT_LEVEL", "target": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "level": 6 } + ] + } +} diff --git a/src/test/resources/test-settings-v2-self-sponsorship-algo.json b/src/test/resources/test-settings-v2-self-sponsorship-algo.json new file mode 100644 index 00000000..5ea42e66 --- /dev/null +++ b/src/test/resources/test-settings-v2-self-sponsorship-algo.json @@ -0,0 +1,20 @@ +{ + "repositoryPath": "testdb", + "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", + "restrictedApi": false, + "blockchainConfig": "src/test/resources/test-chain-v2-self-sponsorship-algo.json", + "exportPath": "qortal-backup-test", + "bootstrap": false, + "wipeUnconfirmedOnStart": false, + "testNtpOffset": 0, + "minPeers": 0, + "pruneBlockLimit": 100, + "bootstrapFilenamePrefix": "test-", + "dataPath": "data-test", + "tempDataPath": "data-test/_temp", + "listsPath": "lists-test", + "storagePolicy": "FOLLOWED_OR_VIEWED", + "maxStorageCapacity": 104857600, + "arrrDefaultBirthday": 1900000 +} From d435e4047bd3fe0e1d9d03745ff0fe38a2901f8a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 08:21:45 +0000 Subject: [PATCH 076/496] SelfSponsorshipAlgoV1 --- .../qortal/account/SelfSponsorshipAlgoV1.java | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java diff --git a/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java new file mode 100644 index 00000000..474bbdf2 --- /dev/null +++ b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java @@ -0,0 +1,362 @@ +package org.qortal.account; + +import org.qortal.api.resource.TransactionsResource; +import org.qortal.asset.Asset; +import org.qortal.data.account.AccountData; +import org.qortal.data.naming.NameData; +import org.qortal.data.transaction.*; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transaction.Transaction.TransactionType; + +import java.util.*; +import java.util.stream.Collectors; + +public class SelfSponsorshipAlgoV1 { + + private final Repository repository; + private final String address; + private final AccountData accountData; + private final long snapshotTimestamp; + private final boolean override; + + private int registeredNameCount = 0; + private int suspiciousCount = 0; + private int suspiciousPercent = 0; + private int consolidationCount = 0; + private int bulkIssuanceCount = 0; + private int recentSponsorshipCount = 0; + + private List sponsorshipRewardShares = new ArrayList<>(); + private final Map> paymentsByAddress = new HashMap<>(); + private final Set sponsees = new LinkedHashSet<>(); + private Set consolidatedAddresses = new LinkedHashSet<>(); + private final Set zeroTransactionAddreses = new LinkedHashSet<>(); + private final Set penaltyAddresses = new LinkedHashSet<>(); + + public SelfSponsorshipAlgoV1(Repository repository, String address, long snapshotTimestamp, boolean override) throws DataException { + this.repository = repository; + this.address = address; + this.accountData = this.repository.getAccountRepository().getAccount(this.address); + this.snapshotTimestamp = snapshotTimestamp; + this.override = override; + } + + public String getAddress() { + return this.address; + } + + public Set getPenaltyAddresses() { + return this.penaltyAddresses; + } + + + public void run() throws DataException { + this.fetchSponsorshipRewardShares(); + if (this.sponsorshipRewardShares.isEmpty()) { + // Nothing to do + return; + } + + this.findConsolidatedRewards(); + this.findBulkIssuance(); + this.findRegisteredNameCount(); + this.findRecentSponsorshipCount(); + + int score = this.calculateScore(); + if (score <= 0 && !override) { + return; + } + + String newAddress = this.getDestinationAccount(this.address); + while (newAddress != null) { + // Found destination account + this.penaltyAddresses.add(newAddress); + + // Run algo for this address, but in "override" mode because it has already been flagged + SelfSponsorshipAlgoV1 algoV1 = new SelfSponsorshipAlgoV1(this.repository, newAddress, this.snapshotTimestamp, true); + algoV1.run(); + this.penaltyAddresses.addAll(algoV1.getPenaltyAddresses()); + + newAddress = this.getDestinationAccount(newAddress); + } + + this.penaltyAddresses.add(this.address); + + if (this.override || this.recentSponsorshipCount < 20) { + this.penaltyAddresses.addAll(this.consolidatedAddresses); + this.penaltyAddresses.addAll(this.zeroTransactionAddreses); + } + else { + this.penaltyAddresses.addAll(this.sponsees); + } + } + + private String getDestinationAccount(String address) throws DataException { + List transferPrivsTransactions = fetchTransferPrivsForAddress(address); + if (transferPrivsTransactions.isEmpty()) { + // No TRANSFER_PRIVS transactions for this address + return null; + } + + AccountData accountData = this.repository.getAccountRepository().getAccount(address); + if (accountData == null) { + return null; + } + + for (TransactionData transactionData : transferPrivsTransactions) { + TransferPrivsTransactionData transferPrivsTransactionData = (TransferPrivsTransactionData) transactionData; + if (Arrays.equals(transferPrivsTransactionData.getSenderPublicKey(), accountData.getPublicKey())) { + return transferPrivsTransactionData.getRecipient(); + } + } + + return null; + } + + private void findConsolidatedRewards() throws DataException { + List sponseesThatSentRewards = new ArrayList<>(); + Map paymentRecipients = new HashMap<>(); + + // Collect outgoing payments of each sponsee + for (String sponseeAddress : this.sponsees) { + + // Firstly fetch all payments for address, since the functions below depend on this data + this.fetchPaymentsForAddress(sponseeAddress); + + // Check if the address has zero relevant transactions + if (this.hasZeroTransactions(sponseeAddress)) { + this.zeroTransactionAddreses.add(sponseeAddress); + } + + // Get payment recipients + List allPaymentRecipients = this.fetchOutgoingPaymentRecipientsForAddress(sponseeAddress); + if (allPaymentRecipients.isEmpty()) { + continue; + } + sponseesThatSentRewards.add(sponseeAddress); + + List addressesPaidByThisSponsee = new ArrayList<>(); + for (String paymentRecipient : allPaymentRecipients) { + if (addressesPaidByThisSponsee.contains(paymentRecipient)) { + // We already tracked this association - don't allow multiple to stack up + continue; + } + addressesPaidByThisSponsee.add(paymentRecipient); + + // Increment count for this recipient, or initialize to 1 if not present + if (paymentRecipients.computeIfPresent(paymentRecipient, (k, v) -> v + 1) == null) { + paymentRecipients.put(paymentRecipient, 1); + } + } + + } + + // Exclude addresses with a low number of payments + Map filteredPaymentRecipients = paymentRecipients.entrySet().stream() + .filter(p -> p.getValue() != null && p.getValue() >= 10) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + // Now check how many sponsees have sent to this subset of addresses + Map sponseesThatConsolidatedRewards = new HashMap<>(); + for (String sponseeAddress : sponseesThatSentRewards) { + List allPaymentRecipients = this.fetchOutgoingPaymentRecipientsForAddress(sponseeAddress); + // Remove any that aren't to one of the flagged recipients (i.e. consolidation) + allPaymentRecipients.removeIf(r -> !filteredPaymentRecipients.containsKey(r)); + + int count = allPaymentRecipients.size(); + if (count == 0) { + continue; + } + if (sponseesThatConsolidatedRewards.computeIfPresent(sponseeAddress, (k, v) -> v + count) == null) { + sponseesThatConsolidatedRewards.put(sponseeAddress, count); + } + } + + // Remove sponsees that have only sent a low number of payments to the filtered addresses + Map filteredSponseesThatConsolidatedRewards = sponseesThatConsolidatedRewards.entrySet().stream() + .filter(p -> p.getValue() != null && p.getValue() >= 2) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + this.consolidationCount = sponseesThatConsolidatedRewards.size(); + this.consolidatedAddresses = new LinkedHashSet<>(filteredSponseesThatConsolidatedRewards.keySet()); + this.suspiciousCount = this.consolidationCount + this.zeroTransactionAddreses.size(); + this.suspiciousPercent = (int)(this.suspiciousCount / (float) this.sponsees.size() * 100); + } + + private void findBulkIssuance() { + Long lastTimestamp = null; + for (RewardShareTransactionData rewardShareTransactionData : sponsorshipRewardShares) { + long timestamp = rewardShareTransactionData.getTimestamp(); + if (timestamp >= this.snapshotTimestamp) { + continue; + } + + if (lastTimestamp != null) { + if (timestamp - lastTimestamp < 3*60*1000L) { + this.bulkIssuanceCount++; + } + } + lastTimestamp = timestamp; + } + } + + private void findRegisteredNameCount() throws DataException { + int registeredNameCount = 0; + for (String sponseeAddress : sponsees) { + List names = repository.getNameRepository().getNamesByOwner(sponseeAddress); + for (NameData name : names) { + if (name.getRegistered() < this.snapshotTimestamp) { + registeredNameCount++; + break; + } + } + } + this.registeredNameCount = registeredNameCount; + } + + private void findRecentSponsorshipCount() { + final long referenceTimestamp = this.snapshotTimestamp - (365 * 24 * 60 * 60 * 1000L); + int recentSponsorshipCount = 0; + for (RewardShareTransactionData rewardShare : sponsorshipRewardShares) { + if (rewardShare.getTimestamp() >= referenceTimestamp) { + recentSponsorshipCount++; + } + } + this.recentSponsorshipCount = recentSponsorshipCount; + } + + private int calculateScore() { + final int suspiciousMultiplier = (this.suspiciousCount >= 100) ? this.suspiciousPercent : 1; + final int nameMultiplier = (this.sponsees.size() >= 50 && this.registeredNameCount == 0) ? 2 : 1; + final int consolidationMultiplier = Math.max(this.consolidationCount, 1); + final int bulkIssuanceMultiplier = Math.max(this.bulkIssuanceCount / 2, 1); + final int offset = 9; + return suspiciousMultiplier * nameMultiplier * consolidationMultiplier * bulkIssuanceMultiplier - offset; + } + + private void fetchSponsorshipRewardShares() throws DataException { + List sponsorshipRewardShares = new ArrayList<>(); + + // Define relevant transactions + List txTypes = List.of(TransactionType.REWARD_SHARE); + List transactionDataList = fetchTransactions(repository, txTypes, this.address, false); + + for (TransactionData transactionData : transactionDataList) { + if (transactionData.getType() != TransactionType.REWARD_SHARE) { + continue; + } + + RewardShareTransactionData rewardShareTransactionData = (RewardShareTransactionData) transactionData; + + // Skip removals + if (rewardShareTransactionData.getSharePercent() < 0) { + continue; + } + + // Skip if not sponsored by this account + if (!Arrays.equals(rewardShareTransactionData.getCreatorPublicKey(), accountData.getPublicKey())) { + continue; + } + + // Skip self shares + if (Objects.equals(rewardShareTransactionData.getRecipient(), this.address)) { + continue; + } + + boolean duplicateFound = false; + for (RewardShareTransactionData existingRewardShare : sponsorshipRewardShares) { + if (Objects.equals(existingRewardShare.getRecipient(), rewardShareTransactionData.getRecipient())) { + // Duplicate + duplicateFound = true; + break; + } + } + if (!duplicateFound) { + sponsorshipRewardShares.add(rewardShareTransactionData); + this.sponsees.add(rewardShareTransactionData.getRecipient()); + } + } + + this.sponsorshipRewardShares = sponsorshipRewardShares; + } + + private List fetchTransferPrivsForAddress(String address) throws DataException { + return fetchTransactions(repository, + List.of(TransactionType.TRANSFER_PRIVS), + address, true); + } + + private void fetchPaymentsForAddress(String address) throws DataException { + List payments = fetchTransactions(repository, + Arrays.asList(TransactionType.PAYMENT, TransactionType.TRANSFER_ASSET), + address, false); + this.paymentsByAddress.put(address, payments); + } + + private List fetchOutgoingPaymentRecipientsForAddress(String address) { + List outgoingPaymentRecipients = new ArrayList<>(); + + List transactionDataList = this.paymentsByAddress.get(address); + if (transactionDataList == null) transactionDataList = new ArrayList<>(); + transactionDataList.removeIf(t -> t.getTimestamp() >= this.snapshotTimestamp); + for (TransactionData transactionData : transactionDataList) { + switch (transactionData.getType()) { + + case PAYMENT: + PaymentTransactionData paymentTransactionData = (PaymentTransactionData) transactionData; + if (!Objects.equals(paymentTransactionData.getRecipient(), address)) { + // Outgoing payment from this account + outgoingPaymentRecipients.add(paymentTransactionData.getRecipient()); + } + break; + + case TRANSFER_ASSET: + TransferAssetTransactionData transferAssetTransactionData = (TransferAssetTransactionData) transactionData; + if (transferAssetTransactionData.getAssetId() == Asset.QORT) { + if (!Objects.equals(transferAssetTransactionData.getRecipient(), address)) { + // Outgoing payment from this account + outgoingPaymentRecipients.add(transferAssetTransactionData.getRecipient()); + } + } + break; + + default: + break; + } + } + + return outgoingPaymentRecipients; + } + + private boolean hasZeroTransactions(String address) { + List transactionDataList = this.paymentsByAddress.get(address); + if (transactionDataList == null) { + return true; + } + transactionDataList.removeIf(t -> t.getTimestamp() >= this.snapshotTimestamp); + return transactionDataList.size() == 0; + } + + private static List fetchTransactions(Repository repository, List txTypes, String address, boolean reverse) throws DataException { + // Fetch all relevant transactions for this account + List signatures = repository.getTransactionRepository() + .getSignaturesMatchingCriteria(null, null, null, txTypes, + null, null, address, TransactionsResource.ConfirmationStatus.CONFIRMED, + null, null, reverse); + + List transactionDataList = new ArrayList<>(); + + for (byte[] signature : signatures) { + // Fetch transaction data + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + if (transactionData == null) { + continue; + } + transactionDataList.add(transactionData); + } + + return transactionDataList; + } + +} From 9afc31a20ddcc4d028a4aeea7064f6995502c9d1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 08:52:09 +0000 Subject: [PATCH 077/496] selfSponsorshipAlgoV1SnapshotTimestamp set to 1670230000000 --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 126ec7ae..270456fc 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -24,7 +24,7 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, - "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 1670230000000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From 4d9964c080fd90923eda9326ed507e03957f2844 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 18:52:33 +0000 Subject: [PATCH 078/496] Block connections with peers older than 3.7.0, as this has been released for long enough now. --- src/main/java/org/qortal/network/Handshake.java | 2 +- src/main/java/org/qortal/settings/Settings.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/network/Handshake.java b/src/main/java/org/qortal/network/Handshake.java index 22354cc4..b2e5f829 100644 --- a/src/main/java/org/qortal/network/Handshake.java +++ b/src/main/java/org/qortal/network/Handshake.java @@ -265,7 +265,7 @@ public enum Handshake { private static final long PEER_VERSION_131 = 0x0100030001L; /** Minimum peer version that we are allowed to communicate with */ - private static final String MIN_PEER_VERSION = "3.1.0"; + private static final String MIN_PEER_VERSION = "3.7.0"; private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes private static final int POW_DIFFICULTY_PRE_131 = 8; // leading zero bits diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index acfd0e78..89d18057 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -209,7 +209,7 @@ public class Settings { public long recoveryModeTimeout = 10 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.6.3"; + private String minPeerVersion = "3.7.0"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From 45a6f495d20c4656342019aa9481c56202b891d5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 19:38:26 +0000 Subject: [PATCH 079/496] selfSponsorshipAlgoV1Height set to 1092400 (approx 4pm UTC on Sat 10th December) --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 270456fc..c13455d6 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -83,7 +83,7 @@ "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 999999999, - "selfSponsorshipAlgoV1Height": 999999999 + "selfSponsorshipAlgoV1Height": 1092400 }, "genesisInfo": { "version": 4, From 51ad0a5b48fe09c9922c03e93daa41585189399d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 19:38:44 +0000 Subject: [PATCH 080/496] onlineAccountMinterLevelValidationHeight set to 1093400 (approx 20 hours later) --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index c13455d6..d28c1ea0 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -82,7 +82,7 @@ "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, - "onlineAccountMinterLevelValidationHeight": 999999999, + "onlineAccountMinterLevelValidationHeight": 1093400, "selfSponsorshipAlgoV1Height": 1092400 }, "genesisInfo": { From a69618133e9361bf1b44dd0591fe0d1d9f24fb1b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 21:34:26 +0000 Subject: [PATCH 081/496] Level 0 online account removals moved inside feature trigger, so it is coordinated with the new validation. --- src/main/java/org/qortal/block/Block.java | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index a31c522b..df0ca7cd 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -378,15 +378,17 @@ public class Block { List onlineAccounts = OnlineAccountsManager.getInstance().getOnlineAccounts(onlineAccountsTimestamp); onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); - // Remove any online accounts that are level 0 - onlineAccounts.removeIf(a -> { - try { - return Account.getRewardShareEffectiveMintingLevel(repository, a.getPublicKey()) == 0; - } catch (DataException e) { - // Something went wrong, so remove the account - return true; - } - }); + // After feature trigger, remove any online accounts that are level 0 + if (height >= BlockChain.getInstance().getOnlineAccountMinterLevelValidationHeight()) { + onlineAccounts.removeIf(a -> { + try { + return Account.getRewardShareEffectiveMintingLevel(repository, a.getPublicKey()) == 0; + } catch (DataException e) { + // Something went wrong, so remove the account + return true; + } + }); + } if (onlineAccounts.isEmpty()) { LOGGER.debug("No online accounts - not even our own?"); From 6f95e7c1c8b1f65dc04a949a15da347c8671c11a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 21:57:32 +0000 Subject: [PATCH 082/496] Bump version to 3.8.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 52c574b0..b52dd2fc 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.7.0 + 3.8.0 jar true From 12fb6cd0adcf0cf942eb144d7371a2829d29d498 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 8 Dec 2022 18:24:34 +0000 Subject: [PATCH 083/496] onlineAccountMinterLevelValidationHeight moved forward to block 1092000 --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index d28c1ea0..7e4497fe 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -82,7 +82,7 @@ "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, - "onlineAccountMinterLevelValidationHeight": 1093400, + "onlineAccountMinterLevelValidationHeight": 1092000, "selfSponsorshipAlgoV1Height": 1092400 }, "genesisInfo": { From ccc1976d0053b6c052356f3c9440262146104ca3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 8 Dec 2022 18:25:03 +0000 Subject: [PATCH 084/496] Added defensiveness --- src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java index 474bbdf2..725e53f5 100644 --- a/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java +++ b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java @@ -52,6 +52,11 @@ public class SelfSponsorshipAlgoV1 { public void run() throws DataException { + if (this.accountData == null) { + // Nothing to do + return; + } + this.fetchSponsorshipRewardShares(); if (this.sponsorshipRewardShares.isEmpty()) { // Nothing to do From 5c9109aca9197db5e662f1b74b849a469e3b8316 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 8 Dec 2022 18:25:19 +0000 Subject: [PATCH 085/496] minPeerVersion set to 3.8.0 --- 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 89d18057..9045d0ad 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -209,7 +209,7 @@ public class Settings { public long recoveryModeTimeout = 10 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.7.0"; + private String minPeerVersion = "3.8.0"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From cdeb2052b07dabef6df92bfddf1f9c55f156c1e8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 8 Dec 2022 18:26:34 +0000 Subject: [PATCH 086/496] Bump version to 3.8.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b52dd2fc..da6d8e27 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.0 + 3.8.1 jar true From 1dc7f056f9cb9b08fb44ae50896844cfcd144ead Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 14 Dec 2022 16:39:43 +0000 Subject: [PATCH 087/496] Filter out peers of divergent or significantly inferior chains when syncing. --- src/main/java/org/qortal/controller/Controller.java | 13 +++++++++++++ .../java/org/qortal/controller/Synchronizer.java | 3 +++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 0a323cb2..182889f5 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -752,6 +752,19 @@ public class Controller extends Thread { return peerChainTipData == null || peerChainTipData.getSignature() == null || inferiorChainTips.contains(ByteArray.wrap(peerChainTipData.getSignature())); }; + /** + * If a peer has a recent block timestamp, but its height is more than 25 blocks behind ours, + * we can assume it has a significantly inferior chain, and is most likely too divergent. + * Early filtering of these peers prevents a lot of very expensive chain weight comparisons. + */ + public static final Predicate hasInferiorChain = peer -> { + final Long minLatestBlockTimestamp = getMinimumLatestBlockTimestamp(); + final int ourHeight = Controller.getInstance().getChainHeight(); + final BlockSummaryData peerChainTipData = peer.getChainTipData(); + boolean peerUpToDate = peerChainTipData != null && peerChainTipData.getTimestamp() != null && peerChainTipData.getTimestamp() >= minLatestBlockTimestamp; + return peerUpToDate && ourHeight - peerChainTipData.getHeight() > 25; + }; + public static final Predicate hasOldVersion = peer -> { final String minPeerVersion = Settings.getInstance().getMinPeerVersion(); return peer.isAtLeastVersion(minPeerVersion) == false; diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index e3ace9ed..54b13580 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -247,6 +247,9 @@ public class Synchronizer extends Thread { // Disregard peers that are on the same block as last sync attempt and we didn't like their chain peers.removeIf(Controller.hasInferiorChainTip); + // Disregard peers that are on a very inferior chain, based on their heights and timestamps + peers.removeIf(Controller.hasInferiorChain); + // Disregard peers that have a block with an invalid signer peers.removeIf(Controller.hasInvalidSigner); From 99d5bf91031b0919f7e67024237bbe97a654f56d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 14 Dec 2022 16:40:11 +0000 Subject: [PATCH 088/496] Disallow transactions with timestamps more than 30 mins in the future (reduced from 24 hours) --- 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 9045d0ad..7372a7c9 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -110,7 +110,7 @@ public class Settings { /** Maximum number of unconfirmed transactions allowed per account */ private int maxUnconfirmedPerAccount = 25; /** Max milliseconds into future for accepting new, unconfirmed transactions */ - private int maxTransactionTimestampFuture = 24 * 60 * 60 * 1000; // milliseconds + private int maxTransactionTimestampFuture = 30 * 60 * 1000; // milliseconds /** Whether we check, fetch and install auto-updates */ private boolean autoUpdateEnabled = true; /** How long between repository backups (ms), or 0 if disabled. */ From 08de1fb4ec0604de2a01a30a96059d283c777c4a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 14 Dec 2022 16:40:57 +0000 Subject: [PATCH 089/496] Disallow CHAT transactions with timestamps more than 5 minutes in the future. --- src/main/java/org/qortal/transaction/ChatTransaction.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index 9cccd42a..b4ae9f37 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -19,6 +19,7 @@ import org.qortal.repository.Repository; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.ChatTransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.NTP; public class ChatTransaction extends Transaction { @@ -145,6 +146,11 @@ public class ChatTransaction extends Transaction { public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import + // Disregard messages with timestamp too far in the future (we have stricter limits for CHAT transactions) + if (this.chatTransactionData.getTimestamp() > NTP.getTime() + (5 * 60 * 1000L)) { + return ValidationResult.TIMESTAMP_TOO_NEW; + } + // Check for blocked author by address ResourceListManager listManager = ResourceListManager.getInstance(); if (listManager.listContains("blockedAddresses", this.chatTransactionData.getSender(), true)) { From 80048208d1b12452167972652c5a15f6dc726673 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 15 Dec 2022 12:14:42 +0000 Subject: [PATCH 090/496] Moved some test sponsorship utility methods to AccountUtils, so they can be used in other test classes too. --- .../test/SelfSponsorshipAlgoV1Tests.java | 205 +++++++----------- .../org/qortal/test/common/AccountUtils.java | 57 ++++- 2 files changed, 131 insertions(+), 131 deletions(-) diff --git a/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java index 91628dd3..397a1bbe 100644 --- a/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java +++ b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java @@ -50,8 +50,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob self sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -73,7 +73,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -139,18 +139,18 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Chloe sponsors 10 accounts - List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccounts.addAll(chloeSponseesOnlineAccounts); // Dilbert sponsors 5 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -172,7 +172,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -270,20 +270,20 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); // Chloe sponsors 10 accounts - List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccountsAliceSigner.addAll(chloeSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(chloeSponseesOnlineAccounts); // Dilbert sponsors 5 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccountsAliceSigner.addAll(dilbertSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(dilbertSponseesOnlineAccounts); @@ -306,7 +306,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccountsAliceSigner.addAll(bobSponseeSelfShares); onlineAccountsBobSigner.addAll(bobSponseeSelfShares); @@ -382,14 +382,14 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Alice sponsors 10 accounts - List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); - List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + List aliceSponsees = AccountUtils.generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, aliceAccount, aliceSponsees); onlineAccountsAliceSigner.addAll(aliceSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(aliceSponseesOnlineAccounts); // Bob sponsors 9 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 9); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); @@ -412,7 +412,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + List aliceSponseeSelfShares = AccountUtils.generateSelfShares(repository, aliceSponsees); onlineAccountsAliceSigner.addAll(aliceSponseeSelfShares); onlineAccountsBobSigner.addAll(aliceSponseeSelfShares); @@ -483,18 +483,18 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Chloe sponsors 10 accounts - List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccounts.addAll(chloeSponseesOnlineAccounts); // Dilbert sponsors 5 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -516,7 +516,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -597,14 +597,14 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Alice sponsors 10 accounts - List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); - List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + List aliceSponsees = AccountUtils.generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, aliceAccount, aliceSponsees); onlineAccounts.addAll(aliceSponseesOnlineAccounts); onlineAccounts.addAll(aliceSponseesOnlineAccounts); // Bob sponsors 9 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 9); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); onlineAccounts.addAll(bobSponseesOnlineAccounts); @@ -627,7 +627,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + List aliceSponseeSelfShares = AccountUtils.generateSelfShares(repository, aliceSponsees); onlineAccounts.addAll(aliceSponseeSelfShares); // Mint blocks (Bob is the signer) @@ -706,13 +706,13 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Chloe sponsors 10 accounts - List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccounts.addAll(chloeSponseesOnlineAccounts); // Mint blocks @@ -728,7 +728,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -744,22 +744,22 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertEquals(19, (int) block.getBlockData().getHeight()); // Bob creates a valid reward share transaction - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, bobAccount)); // Mint a block, so the algo runs block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); // Bob can no longer create a reward share transaction - assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, createRandomRewardShare(repository, bobAccount)); + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, AccountUtils.createRandomRewardShare(repository, bobAccount)); // ... but Chloe still can - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, chloeAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, chloeAccount)); // Orphan last block BlockUtils.orphanLastBlock(repository); // Bob creates another valid reward share transaction - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, bobAccount)); // Run orphan check - this can't be in afterTest() because some tests access the live db Common.orphanCheck(); @@ -780,13 +780,13 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Alice sponsors 10 accounts - List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); - List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + List aliceSponsees = AccountUtils.generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, aliceAccount, aliceSponsees); onlineAccounts.addAll(aliceSponseesOnlineAccounts); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -802,7 +802,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); // Generate self shares so the sponsees can start minting - List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + List aliceSponseeSelfShares = AccountUtils.generateSelfShares(repository, aliceSponsees); onlineAccounts.addAll(aliceSponseeSelfShares); // Mint blocks @@ -818,7 +818,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertEquals(19, (int) block.getBlockData().getHeight()); // Alice creates a valid reward share transaction - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, aliceAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, aliceAccount)); // Mint a block, so the algo runs block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); @@ -830,16 +830,16 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertEquals(0, (int) new Account(repository, aliceAccount.getAddress()).getLevel()); // Alice can no longer create a reward share transaction - assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, createRandomRewardShare(repository, aliceAccount)); + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, AccountUtils.createRandomRewardShare(repository, aliceAccount)); // ... but Bob still can - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, bobAccount)); // Orphan last block BlockUtils.orphanLastBlock(repository); // Alice creates another valid reward share transaction - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, aliceAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, aliceAccount)); // Run orphan check - this can't be in afterTest() because some tests access the live db Common.orphanCheck(); @@ -867,8 +867,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Dilbert sponsors 10 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 10); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -921,8 +921,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Dilbert sponsors 10 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 10); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -935,7 +935,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); // Generate self shares so the sponsees can start minting - List dilbertSponseeSelfShares = generateSelfShares(repository, dilbertSponsees); + List dilbertSponseeSelfShares = AccountUtils.generateSelfShares(repository, dilbertSponsees); onlineAccounts.addAll(dilbertSponseeSelfShares); // Mint blocks @@ -985,8 +985,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Chloe sponsors THE SAME 10 accounts @@ -996,12 +996,12 @@ public class SelfSponsorshipAlgoV1Tests extends Common { TransactionUtils.signAndImportValid(repository, transactionData, chloeAccount); } List chloeSponsees = new ArrayList<>(bobSponsees); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccounts.addAll(chloeSponseesOnlineAccounts); // Dilbert sponsors 5 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -1023,7 +1023,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1108,8 +1108,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -1128,7 +1128,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1220,8 +1220,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -1240,7 +1240,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1316,8 +1316,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -1336,7 +1336,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1392,8 +1392,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -1412,7 +1412,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1543,61 +1543,6 @@ public class SelfSponsorshipAlgoV1Tests extends Common { return new TransferPrivsTransaction(repository, transactionData); } - private static List generateSponsorshipRewardShares(Repository repository, PrivateKeyAccount sponsorAccount, int accountsCount) throws DataException { - final int sharePercent = 0; - Random random = new Random(); - - List sponsees = new ArrayList<>(); - for (int i = 0; i < accountsCount; i++) { - - // Generate random sponsee account - byte[] randomPrivateKey = new byte[32]; - random.nextBytes(randomPrivateKey); - PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); - sponsees.add(sponseeAccount); - - // Create reward-share - TransactionData transactionData = AccountUtils.createRewardShare(repository, sponsorAccount, sponseeAccount, sharePercent, fee); - TransactionUtils.signAndImportValid(repository, transactionData, sponsorAccount); - } - - return sponsees; - } - - private static Transaction.ValidationResult createRandomRewardShare(Repository repository, PrivateKeyAccount account) throws DataException { - // Bob attempts to create a reward share transaction - byte[] randomPrivateKey = new byte[32]; - new Random().nextBytes(randomPrivateKey); - PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); - TransactionData transactionData = AccountUtils.createRewardShare(repository, account, sponseeAccount, 0, fee); - return TransactionUtils.signAndImport(repository, transactionData, account); - } - - private static List generateSelfShares(Repository repository, List accounts) throws DataException { - final int sharePercent = 0; - - for (PrivateKeyAccount account : accounts) { - // Create reward-share - TransactionData transactionData = AccountUtils.createRewardShare(repository, account, account, sharePercent, 0L); - TransactionUtils.signAndImportValid(repository, transactionData, account); - } - - return toRewardShares(repository, null, accounts); - } - - private static List toRewardShares(Repository repository, PrivateKeyAccount parentAccount, List accounts) { - List rewardShares = new ArrayList<>(); - - for (PrivateKeyAccount account : accounts) { - PrivateKeyAccount sponsor = (parentAccount != null) ? parentAccount : account; - byte[] rewardSharePrivateKey = sponsor.getRewardSharePrivateKey(account.getPublicKey()); - PrivateKeyAccount rewardShareAccount = new PrivateKeyAccount(repository, rewardSharePrivateKey); - rewardShares.add(rewardShareAccount); - } - - return rewardShares; - } - private boolean areAllAccountsPresentInBlock(List accounts, Block block) throws DataException { for (PrivateKeyAccount bobSponsee : accounts) { boolean foundOnlineAccountInBlock = false; diff --git a/src/test/java/org/qortal/test/common/AccountUtils.java b/src/test/java/org/qortal/test/common/AccountUtils.java index c31cd85e..bdfd124b 100644 --- a/src/test/java/org/qortal/test/common/AccountUtils.java +++ b/src/test/java/org/qortal/test/common/AccountUtils.java @@ -8,7 +8,6 @@ import java.util.*; import com.google.common.primitives.Longs; import org.qortal.account.PrivateKeyAccount; -import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.crypto.Qortal25519Extras; import org.qortal.data.network.OnlineAccountData; @@ -19,6 +18,7 @@ import org.qortal.data.transaction.TransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.transaction.Transaction; import org.qortal.transform.Transformer; import org.qortal.utils.Amounts; @@ -86,6 +86,61 @@ public class AccountUtils { return rewardSharePrivateKey; } + public static List generateSponsorshipRewardShares(Repository repository, PrivateKeyAccount sponsorAccount, int accountsCount) throws DataException { + final int sharePercent = 0; + Random random = new Random(); + + List sponsees = new ArrayList<>(); + for (int i = 0; i < accountsCount; i++) { + + // Generate random sponsee account + byte[] randomPrivateKey = new byte[32]; + random.nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + sponsees.add(sponseeAccount); + + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, sponsorAccount, sponseeAccount, sharePercent, fee); + TransactionUtils.signAndImportValid(repository, transactionData, sponsorAccount); + } + + return sponsees; + } + + public static Transaction.ValidationResult createRandomRewardShare(Repository repository, PrivateKeyAccount account) throws DataException { + // Bob attempts to create a reward share transaction + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + TransactionData transactionData = createRewardShare(repository, account, sponseeAccount, 0, fee); + return TransactionUtils.signAndImport(repository, transactionData, account); + } + + public static List generateSelfShares(Repository repository, List accounts) throws DataException { + final int sharePercent = 0; + + for (PrivateKeyAccount account : accounts) { + // Create reward-share + TransactionData transactionData = createRewardShare(repository, account, account, sharePercent, 0L); + TransactionUtils.signAndImportValid(repository, transactionData, account); + } + + return toRewardShares(repository, null, accounts); + } + + public static List toRewardShares(Repository repository, PrivateKeyAccount parentAccount, List accounts) { + List rewardShares = new ArrayList<>(); + + for (PrivateKeyAccount account : accounts) { + PrivateKeyAccount sponsor = (parentAccount != null) ? parentAccount : account; + byte[] rewardSharePrivateKey = sponsor.getRewardSharePrivateKey(account.getPublicKey()); + PrivateKeyAccount rewardShareAccount = new PrivateKeyAccount(repository, rewardSharePrivateKey); + rewardShares.add(rewardShareAccount); + } + + return rewardShares; + } + public static Map> getBalances(Repository repository, long... assetIds) throws DataException { Map> balances = new HashMap<>(); From cf3195cb833772dccedc16f1dda2451d8823f848 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 18 Dec 2022 18:32:06 +0000 Subject: [PATCH 091/496] Set "minAccountsToActivateShareBin" to 0 for certain tests. --- src/test/resources/test-chain-v2-self-sponsorship-algo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index 7712ceb1..36df9a62 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -44,7 +44,7 @@ { "height": 1000000, "share": 0.01 } ], "qoraPerQortReward": 250, - "minAccountsToActivateShareBin": 30, + "minAccountsToActivateShareBin": 0, "shareBinActivationMinLevel": 7, "blocksNeededByLevel": [ 5, 20, 30, 40, 50, 60, 18, 80, 90, 100 ], "blockTimingsByHeight": [ From e678ea22e0e9ce8933f39a39a107b149193a06ed Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 18 Dec 2022 18:33:51 +0000 Subject: [PATCH 092/496] Fixed NPE in unit tests. Still need to work out how/when this was introduced. --- src/test/java/org/qortal/test/common/Common.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/qortal/test/common/Common.java b/src/test/java/org/qortal/test/common/Common.java index 3270a795..bb6cc1cb 100644 --- a/src/test/java/org/qortal/test/common/Common.java +++ b/src/test/java/org/qortal/test/common/Common.java @@ -120,7 +120,9 @@ public class Common { } public static void useSettingsAndDb(String settingsFilename, boolean dbInMemory) throws DataException { - closeRepository(); + if (RepositoryManager.getRepositoryFactory() != null) { + closeRepository(); + } // Load/check settings, which potentially sets up blockchain config, etc. LOGGER.debug(String.format("Using setting file: %s", settingsFilename)); From e40dc4af59c5f301a935332a75a992e9e0b1b7b0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:16:57 +0000 Subject: [PATCH 093/496] Fixed group ban expiry. --- .../qortal/repository/GroupRepository.java | 9 +- .../hsqldb/HSQLDBGroupRepository.java | 4 +- .../CancelGroupBanTransaction.java | 2 +- .../transaction/GroupInviteTransaction.java | 2 +- .../transaction/JoinGroupTransaction.java | 2 +- .../transaction/UpdateGroupTransaction.java | 2 +- .../org/qortal/test/group/AdminTests.java | 154 +++++++++++++++++- 7 files changed, 161 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/repository/GroupRepository.java b/src/main/java/org/qortal/repository/GroupRepository.java index bcee7d25..94c97992 100644 --- a/src/main/java/org/qortal/repository/GroupRepository.java +++ b/src/main/java/org/qortal/repository/GroupRepository.java @@ -131,7 +131,14 @@ public interface GroupRepository { public GroupBanData getBan(int groupId, String member) throws DataException; - public boolean banExists(int groupId, String offender) throws DataException; + /** + * IMPORTANT: when using banExists() as part of validation, the timestamp must be that of the transaction that + * is calling banExists() as part of its validation. It must NOT be the current time, unless this is being + * called outside of validation, as part of an on demand check for a ban existing (such as via an API call). + * This is because we need to evaluate a ban's status based on the time of the subsequent transaction, as + * validation will not occur at a fixed time for every node. For some, it could be months into the future. + */ + public boolean banExists(int groupId, String offender, long timestamp) throws DataException; public List getGroupBans(int groupId, Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java index 91db22f1..b1cd40a0 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java @@ -777,9 +777,9 @@ public class HSQLDBGroupRepository implements GroupRepository { } @Override - public boolean banExists(int groupId, String offender) throws DataException { + public boolean banExists(int groupId, String offender, long timestamp) throws DataException { try { - return this.repository.exists("GroupBans", "group_id = ? AND offender = ?", groupId, offender); + return this.repository.exists("GroupBans", "group_id = ? AND offender = ? AND (expires_when IS NULL OR expires_when > ?)", groupId, offender, timestamp); } catch (SQLException e) { throw new DataException("Unable to check for group ban in repository", e); } diff --git a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java index 483dfc6f..08d9cb3e 100644 --- a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java @@ -73,7 +73,7 @@ public class CancelGroupBanTransaction extends Transaction { Account member = getMember(); // Check ban actually exists - if (!this.repository.getGroupRepository().banExists(groupId, member.getAddress())) + if (!this.repository.getGroupRepository().banExists(groupId, member.getAddress(), this.groupUnbanTransactionData.getTimestamp())) return ValidationResult.BAN_UNKNOWN; // Check admin has enough funds diff --git a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java index f3b08f59..fa5e7b85 100644 --- a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java @@ -78,7 +78,7 @@ public class GroupInviteTransaction extends Transaction { return ValidationResult.ALREADY_GROUP_MEMBER; // Check invitee is not banned - if (this.repository.getGroupRepository().banExists(groupId, invitee.getAddress())) + if (this.repository.getGroupRepository().banExists(groupId, invitee.getAddress(), this.groupInviteTransactionData.getTimestamp())) return ValidationResult.BANNED_FROM_GROUP; // Check creator has enough funds diff --git a/src/main/java/org/qortal/transaction/JoinGroupTransaction.java b/src/main/java/org/qortal/transaction/JoinGroupTransaction.java index bc62c629..3061a3fb 100644 --- a/src/main/java/org/qortal/transaction/JoinGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/JoinGroupTransaction.java @@ -53,7 +53,7 @@ public class JoinGroupTransaction extends Transaction { return ValidationResult.ALREADY_GROUP_MEMBER; // Check member is not banned - if (this.repository.getGroupRepository().banExists(groupId, joiner.getAddress())) + if (this.repository.getGroupRepository().banExists(groupId, joiner.getAddress(), this.joinGroupTransactionData.getTimestamp())) return ValidationResult.BANNED_FROM_GROUP; // Check join request doesn't already exist diff --git a/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java b/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java index 9664ccbf..27580430 100644 --- a/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java @@ -103,7 +103,7 @@ public class UpdateGroupTransaction extends Transaction { Account newOwner = getNewOwner(); // Check new owner is not banned - if (this.repository.getGroupRepository().banExists(this.updateGroupTransactionData.getGroupId(), newOwner.getAddress())) + if (this.repository.getGroupRepository().banExists(this.updateGroupTransactionData.getGroupId(), newOwner.getAddress(), this.updateGroupTransactionData.getTimestamp())) return ValidationResult.BANNED_FROM_GROUP; return ValidationResult.OK; diff --git a/src/test/java/org/qortal/test/group/AdminTests.java b/src/test/java/org/qortal/test/group/AdminTests.java index a39b23d7..8cf83c29 100644 --- a/src/test/java/org/qortal/test/group/AdminTests.java +++ b/src/test/java/org/qortal/test/group/AdminTests.java @@ -135,7 +135,8 @@ public class AdminTests extends Common { assertNotSame(ValidationResult.OK, result); // Attempt to ban Bob - result = groupBan(repository, alice, groupId, bob.getAddress()); + int timeToLive = 0; + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); // Should be OK assertEquals(ValidationResult.OK, result); @@ -158,7 +159,7 @@ public class AdminTests extends Common { assertTrue(isMember(repository, bob.getAddress(), groupId)); // Attempt to ban Bob - result = groupBan(repository, alice, groupId, bob.getAddress()); + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); // Should be OK assertEquals(ValidationResult.OK, result); @@ -205,6 +206,144 @@ public class AdminTests extends Common { } } + @Test + public void testGroupBanMemberWithExpiry() throws DataException, InterruptedException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Create group + int groupId = createGroup(repository, alice, "open-group", true); + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to cancel non-existent Bob ban + ValidationResult result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Attempt to ban Bob for 2 seconds + int timeToLive = 2; + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Wait for 2 seconds to pass + Thread.sleep(2000L); + + // Bob attempts to rejoin again + result = joinGroup(repository, bob, groupId); + // Should be OK, as the ban has expired + assertSame(ValidationResult.OK, result); + + // Confirm Bob is now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Orphan last block (Bob join) + BlockUtils.orphanLastBlock(repository); + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Bob to join + result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + + // Attempt to ban Bob for 2 seconds + result = groupBan(repository, alice, groupId, bob.getAddress(), 2); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Wait for 2 seconds to pass + Thread.sleep(2000L); + + // Cancel Bob's ban + result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK, as ban has already expired + assertNotSame(ValidationResult.OK, result); + + // Confirm Bob still not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should be OK, as no longer banned + assertSame(ValidationResult.OK, result); + + // Confirm Bob is now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + + // Attempt to ban Bob for 10 seconds + result = groupBan(repository, alice, groupId, bob.getAddress(), 10); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK, as ban still exists + assertNotSame(ValidationResult.OK, result); + + // Cancel Bob's ban + result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK, as ban still exists + assertEquals(ValidationResult.OK, result); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should be OK, as no longer banned + assertEquals(ValidationResult.OK, result); + + // Orphan last block (Bob join) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed join-group transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Orphan last block (Cancel Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed cancel-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + } + } + @Test public void testGroupBanAdmin() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { @@ -226,7 +365,8 @@ public class AdminTests extends Common { assertTrue(isAdmin(repository, bob.getAddress(), groupId)); // Attempt to ban Bob - result = groupBan(repository, alice, groupId, bob.getAddress()); + int timeToLive = 0; + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); // Should be OK assertEquals(ValidationResult.OK, result); @@ -272,12 +412,12 @@ public class AdminTests extends Common { assertTrue(isAdmin(repository, bob.getAddress(), groupId)); // Have Alice (owner) try to ban herself! - result = groupBan(repository, alice, groupId, alice.getAddress()); + result = groupBan(repository, alice, groupId, alice.getAddress(), timeToLive); // Should NOT be OK assertNotSame(ValidationResult.OK, result); // Have Bob try to ban Alice (owner) - result = groupBan(repository, bob, groupId, alice.getAddress()); + result = groupBan(repository, bob, groupId, alice.getAddress(), timeToLive); // Should NOT be OK assertNotSame(ValidationResult.OK, result); } @@ -316,8 +456,8 @@ public class AdminTests extends Common { return result; } - private ValidationResult groupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { - GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing", 0); + private ValidationResult groupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member, int timeToLive) throws DataException { + GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing", timeToLive); ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); if (result == ValidationResult.OK) From a75ed0e63480b198e965fb89c3db9cccfc718cd4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:18:39 +0000 Subject: [PATCH 094/496] Bump additional expandedAccount level references held in memory. --- src/main/java/org/qortal/block/Block.java | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index df0ca7cd..3f306b93 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1522,6 +1522,9 @@ public class Block { // Batch update in repository repository.getAccountRepository().modifyMintedBlockCounts(allUniqueExpandedAccounts.stream().map(AccountData::getAddress).collect(Collectors.toList()), +1); + // Keep track of level bumps in case we need to apply to other entries + Map bumpedAccounts = new HashMap<>(); + // Local changes and also checks for level bump for (AccountData accountData : allUniqueExpandedAccounts) { // Adjust count locally (in Java) @@ -1535,6 +1538,7 @@ public class Block { if (newLevel > accountData.getLevel()) { // Account has increased in level! accountData.setLevel(newLevel); + bumpedAccounts.put(accountData.getAddress(), newLevel); repository.getAccountRepository().setLevel(accountData); LOGGER.trace(() -> String.format("Block minter %s bumped to level %d", accountData.getAddress(), accountData.getLevel())); } @@ -1542,6 +1546,25 @@ public class Block { break; } } + + // Also bump other entries if need be + if (!bumpedAccounts.isEmpty()) { + for (ExpandedAccount expandedAccount : expandedAccounts) { + Integer newLevel = bumpedAccounts.get(expandedAccount.mintingAccountData.getAddress()); + if (newLevel != null && expandedAccount.mintingAccountData.getLevel() != newLevel) { + expandedAccount.mintingAccountData.setLevel(newLevel); + LOGGER.trace("Also bumped {} to level {}", expandedAccount.mintingAccountData.getAddress(), newLevel); + } + + if (!expandedAccount.isRecipientAlsoMinter) { + newLevel = bumpedAccounts.get(expandedAccount.recipientAccountData.getAddress()); + if (newLevel != null && expandedAccount.recipientAccountData.getLevel() != newLevel) { + expandedAccount.recipientAccountData.setLevel(newLevel); + LOGGER.trace("Also bumped {} to level {}", expandedAccount.recipientAccountData.getAddress(), newLevel); + } + } + } + } } protected void processBlockRewards() throws DataException { From 7ae142fa641ec1209d2b8c046c05dfca6396723b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:20:42 +0000 Subject: [PATCH 095/496] Improved transaction validation. --- .../org/qortal/transaction/RewardShareTransaction.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/transaction/RewardShareTransaction.java b/src/main/java/org/qortal/transaction/RewardShareTransaction.java index ed5029b2..3b9a251e 100644 --- a/src/main/java/org/qortal/transaction/RewardShareTransaction.java +++ b/src/main/java/org/qortal/transaction/RewardShareTransaction.java @@ -163,11 +163,9 @@ public class RewardShareTransaction extends Transaction { return ValidationResult.SELF_SHARE_EXISTS; } - // Fee checking needed if not setting up new self-share - if (!(isRecipientAlsoMinter && existingRewardShareData == null)) - // Check creator has enough funds - if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) - return ValidationResult.NO_BALANCE; + // Check creator has enough funds + if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) + return ValidationResult.NO_BALANCE; return ValidationResult.OK; } From 758a02d71af15364a1b13acc3ac7d61528122e0a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:23:30 +0000 Subject: [PATCH 096/496] Log Pirate light client server address if the wallet unable to be initialized. --- src/main/java/org/qortal/crosschain/PirateWallet.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/crosschain/PirateWallet.java b/src/main/java/org/qortal/crosschain/PirateWallet.java index 6c6ed2a9..4b95d3cc 100644 --- a/src/main/java/org/qortal/crosschain/PirateWallet.java +++ b/src/main/java/org/qortal/crosschain/PirateWallet.java @@ -117,7 +117,7 @@ public class PirateWallet { // Restore existing wallet String response = LiteWalletJni.initfromb64(serverUri, params, wallet, saplingOutput64, saplingSpend64); if (response != null && !response.contains("\"initalized\":true")) { - LOGGER.info("Unable to initialize Pirate Chain wallet: {}", response); + LOGGER.info("Unable to initialize Pirate Chain wallet at {}: {}", serverUri, response); return false; } this.seedPhrase = inputSeedPhrase; From bb74b2d4f607b6439594ef88bc6756d4f92a5d50 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:25:10 +0000 Subject: [PATCH 097/496] MAX_AVG_RESPONSE_TIME for ElectrumX servers increased from 0.5s to 1s. --- src/main/java/org/qortal/crosschain/ElectrumX.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index a2a42089..e1eb1963 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -40,7 +40,7 @@ public class ElectrumX extends BitcoinyBlockchainProvider { private static final String VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE = "verbose transactions are currently unsupported"; private static final int RESPONSE_TIME_READINGS = 5; - private static final long MAX_AVG_RESPONSE_TIME = 500L; // ms + private static final long MAX_AVG_RESPONSE_TIME = 1000L; // ms public static class Server { String hostname; From 2a4ac1ed2432b2b3428b0b122c1d38d62b1ccdce Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 15:09:04 +0000 Subject: [PATCH 098/496] Limit to 250 CHAT messages per hour per account. --- .../java/org/qortal/settings/Settings.java | 14 +++++++++ .../qortal/transaction/ChatTransaction.java | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 7372a7c9..0423f855 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -111,6 +111,12 @@ public class Settings { private int maxUnconfirmedPerAccount = 25; /** Max milliseconds into future for accepting new, unconfirmed transactions */ private int maxTransactionTimestampFuture = 30 * 60 * 1000; // milliseconds + + /** Maximum number of CHAT transactions allowed per account in recent timeframe */ + private int maxRecentChatMessagesPerAccount = 250; + /** Maximum age of a CHAT transaction to be considered 'recent' */ + private long recentChatMessagesMaxAge = 60 * 60 * 1000L; // milliseconds + /** Whether we check, fetch and install auto-updates */ private boolean autoUpdateEnabled = true; /** How long between repository backups (ms), or 0 if disabled. */ @@ -640,6 +646,14 @@ public class Settings { return this.maxTransactionTimestampFuture; } + public int getMaxRecentChatMessagesPerAccount() { + return this.maxRecentChatMessagesPerAccount; + } + + public long getRecentChatMessagesMaxAge() { + return recentChatMessagesMaxAge; + } + public int getBlockCacheSize() { return this.blockCacheSize; } diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index b4ae9f37..72fea7a1 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -1,7 +1,9 @@ package org.qortal.transaction; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.Predicate; import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; @@ -16,6 +18,7 @@ import org.qortal.list.ResourceListManager; import org.qortal.repository.DataException; import org.qortal.repository.GroupRepository; import org.qortal.repository.Repository; +import org.qortal.settings.Settings; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.ChatTransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer; @@ -169,6 +172,14 @@ public class ChatTransaction extends Transaction { } } + PublicKeyAccount creator = this.getCreator(); + if (creator == null) + return ValidationResult.MISSING_CREATOR; + + // Reject if unconfirmed pile already has X recent CHAT transactions from same creator + if (countRecentChatTransactionsByCreator(creator) >= Settings.getInstance().getMaxRecentChatMessagesPerAccount()) + return ValidationResult.TOO_MANY_UNCONFIRMED; + // If we exist in the repository then we've been imported as unconfirmed, // but we don't want to make it into a block, so return fake non-OK result. if (this.repository.getTransactionRepository().exists(this.chatTransactionData.getSignature())) @@ -219,6 +230,26 @@ public class ChatTransaction extends Transaction { return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); } + private int countRecentChatTransactionsByCreator(PublicKeyAccount creator) throws DataException { + List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + final Long now = NTP.getTime(); + long recentThreshold = Settings.getInstance().getRecentChatMessagesMaxAge(); + + // We only care about chat transactions, and only those that are considered 'recent' + Predicate hasSameCreatorAndIsRecentChat = transactionData -> { + if (transactionData.getType() != TransactionType.CHAT) + return false; + + if (transactionData.getTimestamp() < now - recentThreshold) + return false; + + return Arrays.equals(creator.getPublicKey(), transactionData.getCreatorPublicKey()); + }; + + return (int) unconfirmedTransactions.stream().filter(hasSameCreatorAndIsRecentChat).count(); + } + + /** * Ensure there's at least a skeleton account so people * can retrieve sender's public key using address, even if all their messages From 0e81665a36b192af5957b3df2bb106f16ca8e38d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 15:10:19 +0000 Subject: [PATCH 099/496] Revert "Filter out peers of divergent or significantly inferior chains when syncing." This reverts commit 1dc7f056f9cb9b08fb44ae50896844cfcd144ead. To be un-reverted in future when there is more time available for testing. --- src/main/java/org/qortal/controller/Controller.java | 13 ------------- .../java/org/qortal/controller/Synchronizer.java | 3 --- 2 files changed, 16 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 182889f5..0a323cb2 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -752,19 +752,6 @@ public class Controller extends Thread { return peerChainTipData == null || peerChainTipData.getSignature() == null || inferiorChainTips.contains(ByteArray.wrap(peerChainTipData.getSignature())); }; - /** - * If a peer has a recent block timestamp, but its height is more than 25 blocks behind ours, - * we can assume it has a significantly inferior chain, and is most likely too divergent. - * Early filtering of these peers prevents a lot of very expensive chain weight comparisons. - */ - public static final Predicate hasInferiorChain = peer -> { - final Long minLatestBlockTimestamp = getMinimumLatestBlockTimestamp(); - final int ourHeight = Controller.getInstance().getChainHeight(); - final BlockSummaryData peerChainTipData = peer.getChainTipData(); - boolean peerUpToDate = peerChainTipData != null && peerChainTipData.getTimestamp() != null && peerChainTipData.getTimestamp() >= minLatestBlockTimestamp; - return peerUpToDate && ourHeight - peerChainTipData.getHeight() > 25; - }; - public static final Predicate hasOldVersion = peer -> { final String minPeerVersion = Settings.getInstance().getMinPeerVersion(); return peer.isAtLeastVersion(minPeerVersion) == false; diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 54b13580..e3ace9ed 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -247,9 +247,6 @@ public class Synchronizer extends Thread { // Disregard peers that are on the same block as last sync attempt and we didn't like their chain peers.removeIf(Controller.hasInferiorChainTip); - // Disregard peers that are on a very inferior chain, based on their heights and timestamps - peers.removeIf(Controller.hasInferiorChain); - // Disregard peers that have a block with an invalid signer peers.removeIf(Controller.hasInvalidSigner); From 4aea29a91b9120eaf51adf031b5c59a1a1041c3e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 18:03:29 +0000 Subject: [PATCH 100/496] Improved PublicizeTransaction validation. --- .../java/org/qortal/transaction/PublicizeTransaction.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/org/qortal/transaction/PublicizeTransaction.java b/src/main/java/org/qortal/transaction/PublicizeTransaction.java index c03c8283..7179576b 100644 --- a/src/main/java/org/qortal/transaction/PublicizeTransaction.java +++ b/src/main/java/org/qortal/transaction/PublicizeTransaction.java @@ -4,7 +4,9 @@ import java.util.Collections; import java.util.List; import org.qortal.account.Account; +import org.qortal.account.PublicKeyAccount; import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; +import org.qortal.asset.Asset; import org.qortal.crypto.MemoryPoW; import org.qortal.data.transaction.PublicizeTransactionData; import org.qortal.data.transaction.TransactionData; @@ -102,6 +104,12 @@ public class PublicizeTransaction extends Transaction { if (!verifyNonce()) return ValidationResult.INCORRECT_NONCE; + // Validate fee if one has been included + PublicKeyAccount creator = this.getCreator(); + if (this.transactionData.getFee() > 0) + if (creator.getConfirmedBalance(Asset.QORT) < this.transactionData.getFee()) + return ValidationResult.NO_BALANCE; + return ValidationResult.OK; } From c6d65a88dcb9e1fef3db4ce8b76f60de2b071463 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 18:19:27 +0000 Subject: [PATCH 101/496] Increase mempow difficulty and threshold in ChatTransaction, to match the values in the UI. --- .../java/org/qortal/transaction/ChatTransaction.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index 72fea7a1..a248268c 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -32,8 +32,9 @@ public class ChatTransaction extends Transaction { // Other useful constants public static final int MAX_DATA_SIZE = 1024; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY_WITH_QORT = 8; // leading zero bits - public static final int POW_DIFFICULTY_NO_QORT = 12; // leading zero bits + public static final int POW_DIFFICULTY_ABOVE_QORT_THRESHOLD = 8; // leading zero bits + public static final int POW_DIFFICULTY_BELOW_QORT_THRESHOLD = 18; // leading zero bits + public static final long POW_QORT_THRESHOLD = 400000000L; // Constructors @@ -82,7 +83,7 @@ public class ChatTransaction extends Transaction { // Clear nonce from transactionBytes ChatTransactionTransformer.clearNonce(transactionBytes); - int difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; + int difficulty = this.getSender().getConfirmedBalance(Asset.QORT) >= POW_QORT_THRESHOLD ? POW_DIFFICULTY_ABOVE_QORT_THRESHOLD : POW_DIFFICULTY_BELOW_QORT_THRESHOLD; // Calculate nonce this.chatTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, difficulty)); @@ -221,7 +222,7 @@ public class ChatTransaction extends Transaction { int difficulty; try { - difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; + difficulty = this.getSender().getConfirmedBalance(Asset.QORT) >= POW_QORT_THRESHOLD ? POW_DIFFICULTY_ABOVE_QORT_THRESHOLD : POW_DIFFICULTY_BELOW_QORT_THRESHOLD; } catch (DataException e) { return false; } From 9a77aff0a611deb9fc5034db0af3cead34d351a7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Dec 2022 14:10:49 +0000 Subject: [PATCH 102/496] Reduced difficulty of PUBLICIZE transactions from 15 to 14 (it is now the same as ARBITRARY transactions) --- src/main/java/org/qortal/transaction/PublicizeTransaction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/transaction/PublicizeTransaction.java b/src/main/java/org/qortal/transaction/PublicizeTransaction.java index 7179576b..76fef00b 100644 --- a/src/main/java/org/qortal/transaction/PublicizeTransaction.java +++ b/src/main/java/org/qortal/transaction/PublicizeTransaction.java @@ -28,7 +28,7 @@ public class PublicizeTransaction extends Transaction { /** If time difference between transaction and now is greater than this then we don't verify proof-of-work. */ public static final long HISTORIC_THRESHOLD = 2 * 7 * 24 * 60 * 60 * 1000L; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY = 15; // leading zero bits + public static final int POW_DIFFICULTY = 14; // leading zero bits // Constructors From 166f9bd079fd95abcfbc8b52325332d3ea218ae1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Dec 2022 21:28:02 +0000 Subject: [PATCH 103/496] Bump version to 3.8.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index da6d8e27..b66f016f 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.1 + 3.8.2 jar true From 6b45901c4769e8046cac76f5ac1c0f4c8cbe9840 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 31 Dec 2022 14:43:37 +0000 Subject: [PATCH 104/496] Fixed validation of existing reward share transactions. --- src/main/java/org/qortal/block/BlockChain.java | 7 ++++++- .../org/qortal/transaction/RewardShareTransaction.java | 9 +++++++-- src/main/resources/blockchain.json | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 6182bd1d..a2fa8804 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -76,7 +76,8 @@ public class BlockChain { disableReferenceTimestamp, increaseOnlineAccountsDifficultyTimestamp, onlineAccountMinterLevelValidationHeight, - selfSponsorshipAlgoV1Height; + selfSponsorshipAlgoV1Height, + feeValidationFixTimestamp; } // Custom transaction fees @@ -501,6 +502,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.onlineAccountMinterLevelValidationHeight.name()).intValue(); } + public long getFeeValidationFixTimestamp() { + return this.featureTriggers.get(FeatureTrigger.feeValidationFixTimestamp.name()).longValue(); + } + // More complex getters for aspects that change by height or timestamp diff --git a/src/main/java/org/qortal/transaction/RewardShareTransaction.java b/src/main/java/org/qortal/transaction/RewardShareTransaction.java index 3b9a251e..d4d2434c 100644 --- a/src/main/java/org/qortal/transaction/RewardShareTransaction.java +++ b/src/main/java/org/qortal/transaction/RewardShareTransaction.java @@ -164,8 +164,13 @@ public class RewardShareTransaction extends Transaction { } // Check creator has enough funds - if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) - return ValidationResult.NO_BALANCE; + if (this.rewardShareTransactionData.getTimestamp() >= BlockChain.getInstance().getFeeValidationFixTimestamp()) + if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) + return ValidationResult.NO_BALANCE; + + else if (!(isRecipientAlsoMinter && existingRewardShareData == null)) + if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) + return ValidationResult.NO_BALANCE; return ValidationResult.OK; } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 7e4497fe..3969e944 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -83,7 +83,8 @@ "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 1092000, - "selfSponsorshipAlgoV1Height": 1092400 + "selfSponsorshipAlgoV1Height": 1092400, + "feeValidationFixTimestamp": 1671918000000 }, "genesisInfo": { "version": 4, From 98b92a5bf10a0d9fbf4a647888f7bde7702466b0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 2 Jan 2023 16:58:50 +0000 Subject: [PATCH 105/496] Introduced "historic threshold" to ARBITRARY transactions in order to save on verification times of older transactions. This is based on the approach used for PUBLICIZE transactions. --- .../qortal/transaction/ArbitraryTransaction.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index ca5ce517..50d8ccad 100644 --- a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java +++ b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java @@ -24,6 +24,7 @@ import org.qortal.transform.Transformer; import org.qortal.transform.transaction.ArbitraryTransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer; import org.qortal.utils.ArbitraryTransactionUtils; +import org.qortal.utils.NTP; public class ArbitraryTransaction extends Transaction { @@ -34,9 +35,13 @@ public class ArbitraryTransaction extends Transaction { public static final int MAX_DATA_SIZE = 4000; public static final int MAX_METADATA_LENGTH = 32; public static final int HASH_LENGTH = TransactionTransformer.SHA256_LENGTH; - public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes public static final int MAX_IDENTIFIER_LENGTH = 64; + /** If time difference between transaction and now is greater than this then we don't verify proof-of-work. */ + public static final long HISTORIC_THRESHOLD = 2 * 7 * 24 * 60 * 60 * 1000L; + public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes + + // Constructors public ArbitraryTransaction(Repository repository, TransactionData transactionData) { @@ -202,9 +207,11 @@ public class ArbitraryTransaction extends Transaction { // Clear nonce from transactionBytes ArbitraryTransactionTransformer.clearNonce(transactionBytes); - // Check nonce - int difficulty = ArbitraryDataManager.getInstance().getPowDifficulty(); - return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); + // We only need to check nonce for recent transactions due to PoW verification overhead + if (NTP.getTime() - this.arbitraryTransactionData.getTimestamp() < HISTORIC_THRESHOLD) { + int difficulty = ArbitraryDataManager.getInstance().getPowDifficulty(); + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); + } } return true; From b0486f44bbda54eb8e6e215ccabf231990d27d7f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 2 Jan 2023 17:47:36 +0000 Subject: [PATCH 106/496] Added chat_reference index to speed up searches. --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index c44c3d49..e72e5fab 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -984,6 +984,8 @@ public class HSQLDBDatabaseUpdates { // Add a chat reference, to allow one message to reference another, and for this to be easily // searchable. Null values are allowed as most transactions won't have a reference. stmt.execute("ALTER TABLE ChatTransactions ADD chat_reference Signature"); + // For finding chat messages by reference + stmt.execute("CREATE INDEX ChatTransactionsChatReferenceIndex ON ChatTransactions (chat_reference)"); break; default: From eb569304ba603f10ba752ee877a53df900b78caa Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 6 Jan 2023 10:38:25 +0000 Subject: [PATCH 107/496] Improved refund/refundAll HTLC code, to handle cases where there have been multiple purchase attempts for the same AT. --- .../api/resource/CrossChainHtlcResource.java | 163 +++++++++--------- 1 file changed, 83 insertions(+), 80 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 664b013a..45b92c7c 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -8,11 +8,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import java.io.IOException; import java.math.BigDecimal; import java.util.List; import java.util.Objects; -import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.*; @@ -25,7 +24,6 @@ import org.bitcoinj.core.*; import org.bitcoinj.script.Script; import org.qortal.api.*; import org.qortal.api.model.CrossChainBitcoinyHTLCStatus; -import org.qortal.controller.Controller; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -586,98 +584,103 @@ public class CrossChainHtlcResource { } List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); - TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); - if (tradeBotData == null) + List tradeBotDataList = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).collect(Collectors.toList()); + if (tradeBotDataList == null || tradeBotDataList.isEmpty()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); - int lockTime = tradeBotData.getLockTimeA(); + // Loop through all matching entries for this AT address, as there might be more than one + for (TradeBotData tradeBotData : tradeBotDataList) { - // We can't refund P2SH-A until lockTime-A has passed - if (NTP.getTime() <= lockTime * 1000L) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + if (tradeBotData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) - int medianBlockTime = bitcoiny.getMedianBlockTime(); - if (medianBlockTime <= lockTime) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); + int lockTime = tradeBotData.getLockTimeA(); - // Fee for redeem/refund is subtracted from P2SH-A balance. - long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); - long p2shFee = bitcoiny.getP2shFee(feeTimestamp); - long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + // We can't refund P2SH-A until lockTime-A has passed + if (NTP.getTime() <= lockTime * 1000L) + continue; - // Create redeem script based on destination chain - byte[] redeemScriptA; - String p2shAddressA; - BitcoinyHTLC.Status htlcStatusA; - if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) { - redeemScriptA = PirateChainHTLC.buildScript(tradeBotData.getTradeForeignPublicKey(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); - p2shAddressA = PirateChain.getInstance().deriveP2shAddressBPrefix(redeemScriptA); - htlcStatusA = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); - } - else { - redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); - p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA); - htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); - } - LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA)); + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = bitcoiny.getMedianBlockTime(); + if (medianBlockTime <= lockTime) + continue; - switch (htlcStatusA) { - case UNFUNDED: - case FUNDING_IN_PROGRESS: - // Still waiting for P2SH-A to be funded... - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); + long p2shFee = bitcoiny.getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; - case REDEEM_IN_PROGRESS: - case REDEEMED: - case REFUND_IN_PROGRESS: - case REFUNDED: - // Too late! - return false; + // Create redeem script based on destination chain + byte[] redeemScriptA; + String p2shAddressA; + BitcoinyHTLC.Status htlcStatusA; + if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) { + redeemScriptA = PirateChainHTLC.buildScript(tradeBotData.getTradeForeignPublicKey(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + p2shAddressA = PirateChain.getInstance().deriveP2shAddressBPrefix(redeemScriptA); + htlcStatusA = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); + } else { + redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA); + htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); + } + LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA)); - case FUNDED:{ - Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-A to be funded... + continue; - if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) { - // Pirate Chain custom integration + case REDEEM_IN_PROGRESS: + case REDEEMED: + case REFUND_IN_PROGRESS: + case REFUNDED: + // Too late! + continue; - PirateChain pirateChain = PirateChain.getInstance(); - String p2shAddressT3 = pirateChain.deriveP2shAddress(redeemScriptA); + case FUNDED: { + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); - // Get funding txid - String fundingTxidHex = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddressA, minimumAmountA); - if (fundingTxidHex == null) { - throw new ForeignBlockchainException("Missing funding txid when refunding P2SH"); + if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) { + // Pirate Chain custom integration + + PirateChain pirateChain = PirateChain.getInstance(); + String p2shAddressT3 = pirateChain.deriveP2shAddress(redeemScriptA); + + // Get funding txid + String fundingTxidHex = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddressA, minimumAmountA); + if (fundingTxidHex == null) { + throw new ForeignBlockchainException("Missing funding txid when refunding P2SH"); + } + String fundingTxid58 = Base58.encode(HashCode.fromString(fundingTxidHex).asBytes()); + + byte[] privateKey = tradeBotData.getTradePrivateKey(); + String privateKey58 = Base58.encode(privateKey); + String redeemScript58 = Base58.encode(redeemScriptA); + + String txid = PirateChain.getInstance().refundP2sh(p2shAddressT3, + receiveAddress, refundAmount.value, redeemScript58, fundingTxid58, lockTime, privateKey58); + LOGGER.info("Refund txid: {}", txid); + } else { + // ElectrumX coins + + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); + + // Validate the destination foreign blockchain address + Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress); + if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTime, receiving.getHash()); + + bitcoiny.broadcastTransaction(p2shRefundTransaction); } - String fundingTxid58 = Base58.encode(HashCode.fromString(fundingTxidHex).asBytes()); - byte[] privateKey = tradeBotData.getTradePrivateKey(); - String privateKey58 = Base58.encode(privateKey); - String redeemScript58 = Base58.encode(redeemScriptA); - - String txid = PirateChain.getInstance().refundP2sh(p2shAddressT3, - receiveAddress, refundAmount.value, redeemScript58, fundingTxid58, lockTime, privateKey58); - LOGGER.info("Refund txid: {}", txid); + return true; } - else { - // ElectrumX coins - - ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); - - // Validate the destination foreign blockchain address - Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress); - if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey, - fundingOutputs, redeemScriptA, lockTime, receiving.getHash()); - - bitcoiny.broadcastTransaction(p2shRefundTransaction); - } - - return true; } } From 8ddcae249c09452120e70a6b286ad4398740218d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 12:05:57 +0000 Subject: [PATCH 108/496] 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 109/496] 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 110/496] 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 113/496] 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 114/496] 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 8e97c05b56d215a4f217c74a275881a763f92d31 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:25:06 +0000 Subject: [PATCH 115/496] Added missing feature trigger from unit tests. --- src/test/resources/test-chain-v2-block-timestamps.json | 1 + src/test/resources/test-chain-v2-disable-reference.json | 1 + src/test/resources/test-chain-v2-founder-rewards.json | 1 + src/test/resources/test-chain-v2-leftover-reward.json | 1 + src/test/resources/test-chain-v2-minting.json | 1 + src/test/resources/test-chain-v2-qora-holder-extremes.json | 1 + src/test/resources/test-chain-v2-qora-holder-reduction.json | 1 + src/test/resources/test-chain-v2-qora-holder.json | 1 + src/test/resources/test-chain-v2-reward-levels.json | 1 + src/test/resources/test-chain-v2-reward-scaling.json | 1 + src/test/resources/test-chain-v2-reward-shares.json | 1 + src/test/resources/test-chain-v2-self-sponsorship-algo.json | 1 + src/test/resources/test-chain-v2.json | 1 + 13 files changed, 13 insertions(+) diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 0a479a75..8c2e0503 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -74,6 +74,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index 15c4bedd..f7f8e7d8 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -77,6 +77,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index e17b6687..20d10233 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index abb78528..e71ebab6 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index 31f89916..2a388e1f 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 8d4351eb..cface0e7 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 20bd27c5..f233680b 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -79,6 +79,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index b638e759..4ea82290 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index 7ba5c8b6..5de8d9ff 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 5aa9084f..c008ed42 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 70b746a8..2fc0151f 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index 36df9a62..c13d55da 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -77,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "feeValidationFixTimestamp": 0, "selfSponsorshipAlgoV1Height": 20 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index cd28d214..63abc695 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { From ba95f8376f19cc76791aba451920bdabff0a8fb3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:27:02 +0000 Subject: [PATCH 116/496] Increase CHAT transaction data limits to the maximum (4000 bytes) to allow for upcoming UI features. --- src/main/java/org/qortal/transaction/ChatTransaction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index a248268c..5ed96494 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -30,7 +30,7 @@ public class ChatTransaction extends Transaction { private ChatTransactionData chatTransactionData; // Other useful constants - public static final int MAX_DATA_SIZE = 1024; + public static final int MAX_DATA_SIZE = 4000; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes public static final int POW_DIFFICULTY_ABOVE_QORT_THRESHOLD = 8; // leading zero bits public static final int POW_DIFFICULTY_BELOW_QORT_THRESHOLD = 18; // leading zero bits From 41f88be55eedae8b575f0be2ea220601f9d44819 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:27:38 +0000 Subject: [PATCH 117/496] Test serialization of CHAT transactions --- .../org/qortal/test/SerializationTests.java | 1 - .../transaction/ChatTestTransaction.java | 40 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java diff --git a/src/test/java/org/qortal/test/SerializationTests.java b/src/test/java/org/qortal/test/SerializationTests.java index d9fe978c..8422bd9c 100644 --- a/src/test/java/org/qortal/test/SerializationTests.java +++ b/src/test/java/org/qortal/test/SerializationTests.java @@ -47,7 +47,6 @@ public class SerializationTests extends Common { switch (txType) { case GENESIS: case ACCOUNT_FLAGS: - case CHAT: case PUBLICIZE: case AIRDROP: case ENABLE_FORGING: diff --git a/src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java b/src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java new file mode 100644 index 00000000..bab1f1a0 --- /dev/null +++ b/src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java @@ -0,0 +1,40 @@ +package org.qortal.test.common.transaction; + +import org.qortal.account.PrivateKeyAccount; +import org.qortal.crypto.Crypto; +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; + +import java.util.Random; + +public class ChatTestTransaction extends TestTransaction { + + public static TransactionData randomTransaction(Repository repository, PrivateKeyAccount account, boolean wantValid) throws DataException { + Random random = new Random(); + byte[] orderId = new byte[64]; + random.nextBytes(orderId); + + String sender = Crypto.toAddress(account.getPublicKey()); + int nonce = 1234567; + + // Generate random recipient + byte[] randomPrivateKey = new byte[32]; + random.nextBytes(randomPrivateKey); + PrivateKeyAccount recipientAccount = new PrivateKeyAccount(repository, randomPrivateKey); + String recipient = Crypto.toAddress(recipientAccount.getPublicKey()); + + byte[] chatReference = new byte[64]; + random.nextBytes(chatReference); + + byte[] data = new byte[4000]; + random.nextBytes(data); + + boolean isText = true; + boolean isEncrypted = true; + + return new ChatTransactionData(generateBase(account), sender, nonce, recipient, chatReference, data, isText, isEncrypted); + } + +} From 6284a4691caa2aa21b47b0431cbdf168b5bb888b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:28:44 +0000 Subject: [PATCH 118/496] Import test transactions as part of the serialization tests, to catch any issues with db schema data lengths. --- src/test/java/org/qortal/test/SerializationTests.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/org/qortal/test/SerializationTests.java b/src/test/java/org/qortal/test/SerializationTests.java index 8422bd9c..d5c12c00 100644 --- a/src/test/java/org/qortal/test/SerializationTests.java +++ b/src/test/java/org/qortal/test/SerializationTests.java @@ -59,6 +59,7 @@ public class SerializationTests extends Common { TransactionData transactionData = TransactionUtils.randomTransaction(repository, signingAccount, txType, true); Transaction transaction = Transaction.fromData(repository, transactionData); transaction.sign(signingAccount); + transaction.importAsUnconfirmed(); final int claimedLength = TransactionTransformer.getDataLength(transactionData); byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); From 745cfe8ea15f31bde71a4f591065907f74d1aa00 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:45:38 +0000 Subject: [PATCH 119/496] chatReferenceTimestamp set to 1674316800000 (Sat, 21 Jan 2023 16:00:00 GMT) --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 4ac40f62..aa6cd73b 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -85,7 +85,7 @@ "onlineAccountMinterLevelValidationHeight": 1092000, "selfSponsorshipAlgoV1Height": 1092400, "feeValidationFixTimestamp": 1671918000000, - "chatReferenceTimestamp": 9999999999999 + "chatReferenceTimestamp": 1674316800000 }, "genesisInfo": { "version": 4, From 4dc0033a5a76d6ba54cf96df12ce4c302eba8740 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:45:52 +0000 Subject: [PATCH 120/496] Added missing chatReferenceTimestamp in unit tests. --- src/test/resources/test-chain-v2-self-sponsorship-algo.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index c13d55da..68b33cc3 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -77,8 +77,9 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 20, "feeValidationFixTimestamp": 0, - "selfSponsorshipAlgoV1Height": 20 + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, From 0ad9e2f65bc4aedb26f63ea8144058f395dce800 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 20:08:47 +0000 Subject: [PATCH 121/496] Added QCHAT_ATTACHMENT service, with custom validation function. --- .../org/qortal/arbitrary/misc/Service.java | 32 ++++++- .../test/arbitrary/ArbitraryServiceTests.java | 91 ++++++++++++++++++- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 5dd8d94e..dc2deaeb 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -10,9 +10,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; @@ -20,6 +18,31 @@ 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) { + @Override + public ValidationResult validate(Path path) { + // Custom validation function to require a single file, with a whitelisted extension + int fileCount = 0; + File[] files = path.toFile().listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + return ValidationResult.DIRECTORIES_NOT_ALLOWED; + } + final String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(); + 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++; + } + } + if (fileCount != 1) { + return ValidationResult.INVALID_FILE_COUNT; + } + return ValidationResult.OK; + } + }, WEBSITE(200, true, null, null) { @Override public ValidationResult validate(Path path) { @@ -143,7 +166,8 @@ public enum Service { MISSING_INDEX_FILE(4), DIRECTORIES_NOT_ALLOWED(5), INVALID_FILE_EXTENSION(6), - MISSING_DATA(7); + MISSING_DATA(7), + INVALID_FILE_COUNT(8); 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 e6a51776..f7738c45 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -175,4 +175,93 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); } -} + @Test + public void testValidateQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateQChatAttachment"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "document.pdf"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.OK, service.validate(path)); + } + + @Test + public void testValidateInvalidQChatAttachmentFileExtension() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateInvalidQChatAttachmentFileExtension"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "application.exe"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); + } + + @Test + public void testValidateEmptyQChatAttachment() throws IOException { + Path path = Files.createTempDirectory("testValidateEmptyQChatAttachment"); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); + } + + @Test + public void testValidateMultiLayerQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // 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); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); + } + + @Test + public void testValidateMultiFileQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateMultiFileQChatAttachment"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "file1.txt"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "file2.txt"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); + } + +} \ No newline at end of file From 02d5043ef7900166af851d07bae76d00ed0d43db Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 20:17:27 +0000 Subject: [PATCH 122/496] Added missing calls to electrumX.setBlockchain(instance); for DGB and RVN. Thanks to @QuickMythril for noticing this. --- src/main/java/org/qortal/crosschain/Digibyte.java | 2 ++ src/main/java/org/qortal/crosschain/Ravencoin.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/crosschain/Digibyte.java b/src/main/java/org/qortal/crosschain/Digibyte.java index 3ab5e78e..4358b3b3 100644 --- a/src/main/java/org/qortal/crosschain/Digibyte.java +++ b/src/main/java/org/qortal/crosschain/Digibyte.java @@ -134,6 +134,8 @@ public class Digibyte extends Bitcoiny { Context bitcoinjContext = new Context(digibyteNet.getParams()); instance = new Digibyte(digibyteNet, electrumX, bitcoinjContext, CURRENCY_CODE); + + electrumX.setBlockchain(instance); } return instance; diff --git a/src/main/java/org/qortal/crosschain/Ravencoin.java b/src/main/java/org/qortal/crosschain/Ravencoin.java index d65c0a13..7bf5b20f 100644 --- a/src/main/java/org/qortal/crosschain/Ravencoin.java +++ b/src/main/java/org/qortal/crosschain/Ravencoin.java @@ -138,6 +138,8 @@ public class Ravencoin extends Bitcoiny { Context bitcoinjContext = new Context(ravencoinNet.getParams()); instance = new Ravencoin(ravencoinNet, electrumX, bitcoinjContext, CURRENCY_CODE); + + electrumX.setBlockchain(instance); } return instance; From 476fdcb31d442e49c5093911f71f0c44fff69edf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 10:38:50 +0000 Subject: [PATCH 123/496] Added serialization tests for chatReference, and grouped with other serialization tests into a single package. --- .../data/transaction/ChatTransactionData.java | 4 + .../AtSerializationTests.java | 2 +- .../serialization/ChatSerializationTests.java | 102 ++++++++++++++++++ .../SerializationTests.java | 2 +- 4 files changed, 108 insertions(+), 2 deletions(-) rename src/test/java/org/qortal/test/{at => serialization}/AtSerializationTests.java (99%) create mode 100644 src/test/java/org/qortal/test/serialization/ChatSerializationTests.java rename src/test/java/org/qortal/test/{ => serialization}/SerializationTests.java (99%) diff --git a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java index 81bdb2b7..5a6adf7f 100644 --- a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java @@ -85,6 +85,10 @@ public class ChatTransactionData extends TransactionData { return this.chatReference; } + public void setChatReference(byte[] chatReference) { + this.chatReference = chatReference; + } + public byte[] getData() { return this.data; } diff --git a/src/test/java/org/qortal/test/at/AtSerializationTests.java b/src/test/java/org/qortal/test/serialization/AtSerializationTests.java similarity index 99% rename from src/test/java/org/qortal/test/at/AtSerializationTests.java rename to src/test/java/org/qortal/test/serialization/AtSerializationTests.java index 3953bcdf..ea8d6bcd 100644 --- a/src/test/java/org/qortal/test/at/AtSerializationTests.java +++ b/src/test/java/org/qortal/test/serialization/AtSerializationTests.java @@ -1,4 +1,4 @@ -package org.qortal.test.at; +package org.qortal.test.serialization; import com.google.common.hash.HashCode; import org.junit.After; diff --git a/src/test/java/org/qortal/test/serialization/ChatSerializationTests.java b/src/test/java/org/qortal/test/serialization/ChatSerializationTests.java new file mode 100644 index 00000000..983896db --- /dev/null +++ b/src/test/java/org/qortal/test/serialization/ChatSerializationTests.java @@ -0,0 +1,102 @@ +package org.qortal.test.serialization; + +import com.google.common.hash.HashCode; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.Common; +import org.qortal.test.common.transaction.ChatTestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Base58; + +import static org.junit.Assert.*; + +public class ChatSerializationTests { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + + @Test + public void testChatSerializationWithChatReference() throws DataException, TransformationException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Build MESSAGE-type AT transaction with chatReference + PrivateKeyAccount signingAccount = Common.getTestAccount(repository, "alice"); + ChatTransactionData transactionData = (ChatTransactionData) ChatTestTransaction.randomTransaction(repository, signingAccount, true); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + assertNotNull(transactionData.getChatReference()); + + final int claimedLength = TransactionTransformer.getDataLength(transactionData); + byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); + assertEquals("Serialized CHAT transaction length differs from declared length", claimedLength, serializedTransaction.length); + + TransactionData deserializedTransactionData = TransactionTransformer.fromBytes(serializedTransaction); + // Re-sign + Transaction deserializedTransaction = Transaction.fromData(repository, deserializedTransactionData); + deserializedTransaction.sign(signingAccount); + assertEquals("Deserialized CHAT transaction signature differs", Base58.encode(transactionData.getSignature()), Base58.encode(deserializedTransactionData.getSignature())); + + // Re-serialize to check new length and bytes + final int reclaimedLength = TransactionTransformer.getDataLength(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction declared length differs", claimedLength, reclaimedLength); + + byte[] reserializedTransaction = TransactionTransformer.toBytes(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction bytes differ", HashCode.fromBytes(serializedTransaction).toString(), HashCode.fromBytes(reserializedTransaction).toString()); + + // Deserialized chat reference must match initial chat reference + ChatTransactionData deserializedChatTransactionData = (ChatTransactionData) deserializedTransactionData; + assertNotNull(deserializedChatTransactionData.getChatReference()); + assertArrayEquals(deserializedChatTransactionData.getChatReference(), transactionData.getChatReference()); + } + } + + @Test + public void testChatSerializationWithoutChatReference() throws DataException, TransformationException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Build MESSAGE-type AT transaction without chatReference + PrivateKeyAccount signingAccount = Common.getTestAccount(repository, "alice"); + ChatTransactionData transactionData = (ChatTransactionData) ChatTestTransaction.randomTransaction(repository, signingAccount, true); + transactionData.setChatReference(null); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + assertNull(transactionData.getChatReference()); + + final int claimedLength = TransactionTransformer.getDataLength(transactionData); + byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); + assertEquals("Serialized CHAT transaction length differs from declared length", claimedLength, serializedTransaction.length); + + TransactionData deserializedTransactionData = TransactionTransformer.fromBytes(serializedTransaction); + // Re-sign + Transaction deserializedTransaction = Transaction.fromData(repository, deserializedTransactionData); + deserializedTransaction.sign(signingAccount); + assertEquals("Deserialized CHAT transaction signature differs", Base58.encode(transactionData.getSignature()), Base58.encode(deserializedTransactionData.getSignature())); + + // Re-serialize to check new length and bytes + final int reclaimedLength = TransactionTransformer.getDataLength(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction declared length differs", claimedLength, reclaimedLength); + + byte[] reserializedTransaction = TransactionTransformer.toBytes(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction bytes differ", HashCode.fromBytes(serializedTransaction).toString(), HashCode.fromBytes(reserializedTransaction).toString()); + + // Deserialized chat reference must match initial chat reference + ChatTransactionData deserializedChatTransactionData = (ChatTransactionData) deserializedTransactionData; + assertNull(deserializedChatTransactionData.getChatReference()); + assertArrayEquals(deserializedChatTransactionData.getChatReference(), transactionData.getChatReference()); + } + } + +} diff --git a/src/test/java/org/qortal/test/SerializationTests.java b/src/test/java/org/qortal/test/serialization/SerializationTests.java similarity index 99% rename from src/test/java/org/qortal/test/SerializationTests.java rename to src/test/java/org/qortal/test/serialization/SerializationTests.java index d5c12c00..e9767909 100644 --- a/src/test/java/org/qortal/test/SerializationTests.java +++ b/src/test/java/org/qortal/test/serialization/SerializationTests.java @@ -1,4 +1,4 @@ -package org.qortal.test; +package org.qortal.test.serialization; import org.junit.Ignore; import org.junit.Test; From f78101e9cc68ab8ec199f1269f282ee5dc484d37 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 11:07:54 +0000 Subject: [PATCH 124/496] Updated a default bootstrap host to use a domain instead of its IP. --- 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 0423f855..546bd936 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -273,7 +273,7 @@ public class Settings { private String[] bootstrapHosts = new String[] { "http://bootstrap.qortal.org", "http://bootstrap2.qortal.org", - "http://62.171.190.193" + "http://bootstrap.qortal.online" }; // Auto-update sources From c62c59b44571d54410109fa756dfa50a9972e3ce Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 12:57:44 +0000 Subject: [PATCH 125/496] Use correct timeout (12s) when sending arbitrary data to a peer, and improved logging. --- .../arbitrary/ArbitraryDataFileManager.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java index 30b0fcca..807704dd 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java @@ -288,7 +288,7 @@ public class ArbitraryDataFileManager extends Thread { // The ID needs to match that of the original request message.setId(originalMessage.getId()); - if (!requestingPeer.sendMessage(message)) { + if (!requestingPeer.sendMessageWithTimeout(message, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT)) { LOGGER.debug("Failed to forward arbitrary data file to peer {}", requestingPeer); requestingPeer.disconnect("failed to forward arbitrary data file"); } @@ -564,13 +564,16 @@ public class ArbitraryDataFileManager extends Thread { LOGGER.trace("Hash {} exists", hash58); // We can serve the file directly as we already have it + LOGGER.debug("Sending file {}...", arbitraryDataFile); ArbitraryDataFileMessage arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, arbitraryDataFile); arbitraryDataFileMessage.setId(message.getId()); - if (!peer.sendMessage(arbitraryDataFileMessage)) { - LOGGER.debug("Couldn't sent file"); + if (!peer.sendMessageWithTimeout(arbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT)) { + LOGGER.debug("Couldn't send file {}", arbitraryDataFile); peer.disconnect("failed to send file"); } - LOGGER.debug("Sent file {}", arbitraryDataFile); + else { + LOGGER.debug("Sent file {}", arbitraryDataFile); + } } else if (relayInfo != null) { LOGGER.debug("We have relay info for hash {}", Base58.encode(hash)); From 0596a07c7de7cfe36e9b1aade9f07b149c3fec28 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 12:58:35 +0000 Subject: [PATCH 126/496] Reduced ArbitraryDataFileRequestThread count from 10 to 5, to reduce network flooding. --- .../qortal/controller/arbitrary/ArbitraryDataFileManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java index 807704dd..e2de1ae0 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java @@ -82,7 +82,7 @@ public class ArbitraryDataFileManager extends Thread { try { // Use a fixed thread pool to execute the arbitrary data file requests - int threadCount = 10; + int threadCount = 5; ExecutorService arbitraryDataFileRequestExecutor = Executors.newFixedThreadPool(threadCount); for (int i = 0; i < threadCount; i++) { arbitraryDataFileRequestExecutor.execute(new ArbitraryDataFileRequestThread()); From 016191bdb0887c20df91f859e6821b0342503772 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 15:15:48 +0000 Subject: [PATCH 127/496] Reduce log spam when a QDN resource can't be found due to it not being published. --- .../arbitrary/ArbitraryDataBuilder.java | 3 ++- .../qortal/arbitrary/ArbitraryDataReader.java | 11 +++++++++- .../arbitrary/ArbitraryDataResource.java | 3 ++- .../exception/DataNotPublishedException.java | 22 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java index 4f0e3835..b6b17ea5 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java @@ -2,6 +2,7 @@ package org.qortal.arbitrary; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.arbitrary.exception.DataNotPublishedException; import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.metadata.ArbitraryDataMetadataCache; import org.qortal.arbitrary.misc.Service; @@ -88,7 +89,7 @@ public class ArbitraryDataBuilder { if (latestPut == null) { String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s", this.name, this.service, this.identifierString()); - throw new DataException(message); + throw new DataNotPublishedException(message); } this.latestPutTransaction = latestPut; diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index 5d4b015c..d1a8b4f5 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -4,6 +4,7 @@ import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.arbitrary.exception.DataNotPublishedException; import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataBuildManager; @@ -169,10 +170,18 @@ public class ArbitraryDataReader { this.uncompress(); this.validate(); + } catch (DataNotPublishedException e) { + if (e.getMessage() != null) { + // Log the message only, to avoid spamming the logs with a full stack trace + LOGGER.debug("DataNotPublishedException when trying to load QDN resource: {}", e.getMessage()); + } + this.deleteWorkingDirectory(); + throw e; + } catch (DataException e) { LOGGER.info("DataException when trying to load QDN resource", e); this.deleteWorkingDirectory(); - throw new DataException(e.getMessage()); + throw e; } finally { this.postExecute(); diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java index 616c9b03..2720e4b2 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java @@ -3,6 +3,7 @@ package org.qortal.arbitrary; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; +import org.qortal.arbitrary.exception.DataNotPublishedException; import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataBuildManager; @@ -325,7 +326,7 @@ public class ArbitraryDataResource { if (latestPut == null) { String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s", this.resourceId, this.service, this.identifierString()); - throw new DataException(message); + throw new DataNotPublishedException(message); } this.latestPutTransaction = latestPut; diff --git a/src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java b/src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java new file mode 100644 index 00000000..4782826b --- /dev/null +++ b/src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java @@ -0,0 +1,22 @@ +package org.qortal.arbitrary.exception; + +import org.qortal.repository.DataException; + +public class DataNotPublishedException extends DataException { + + public DataNotPublishedException() { + } + + public DataNotPublishedException(String message) { + super(message); + } + + public DataNotPublishedException(String message, Throwable cause) { + super(message, cause); + } + + public DataNotPublishedException(Throwable cause) { + super(cause); + } + +} From 39e59cbcf812ded42cc4d144997cfec153194bb1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 18:47:46 +0000 Subject: [PATCH 128/496] Bump version to 3.8.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b66f016f..7a82ad37 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.2 + 3.8.3 jar true From 2a55eba1f7b695f34c82bf52fd4407d7c387325f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 11:28:37 +0000 Subject: [PATCH 129/496] Updated AdvancedInstaller project for v3.8.3 --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 1f579a9c..7af02485 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - + From e91e612b55e5cbbbf781bad317e2f2244952d13c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 11:33:16 +0000 Subject: [PATCH 130/496] Added checkpoint lookup on startup. Currently enabled for topOnly nodes only. This will detect if the node is on a divergent chain, and will force a bootstrap or resync (depending on settings) in order to rejoin the main chain. --- .../java/org/qortal/block/BlockChain.java | 60 +++++++++++++++---- src/main/resources/blockchain.json | 3 + 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index bacd7825..437a48ab 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -100,6 +100,13 @@ public class BlockChain { /** Whether only one registered name is allowed per account. */ private boolean oneNamePerAccount = false; + /** Checkpoints */ + public static class Checkpoint { + public int height; + public String signature; + } + private List checkpoints; + /** Block rewards by block height */ public static class RewardByHeight { public int height; @@ -381,6 +388,10 @@ public class BlockChain { return this.oneNamePerAccount; } + public List getCheckpoints() { + return this.checkpoints; + } + public List getBlockRewardsByHeight() { return this.rewardsByHeight; } @@ -679,6 +690,7 @@ public class BlockChain { boolean isTopOnly = Settings.getInstance().isTopOnly(); boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); + boolean isLite = Settings.getInstance().isLite(); boolean canBootstrap = Settings.getInstance().getBootstrap(); boolean needsArchiveRebuild = false; BlockData chainTip; @@ -699,22 +711,44 @@ public class BlockChain { } } } + + // Validate checkpoints + // Limited to topOnly nodes for now, in order to reduce risk, and to solve a real-world problem with divergent topOnly nodes + // TODO: remove the isTopOnly conditional below once this feature has had more testing time + if (isTopOnly && !isLite) { + List checkpoints = BlockChain.getInstance().getCheckpoints(); + for (Checkpoint checkpoint : checkpoints) { + BlockData blockData = repository.getBlockRepository().fromHeight(checkpoint.height); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(checkpoint.height); + } + if (blockData == null) { + LOGGER.trace("Couldn't find block for height {}", checkpoint.height); + // This is likely due to the block being pruned, so is safe to ignore. + // Continue, as there might be other blocks we can check more definitively. + continue; + } + + byte[] signature = Base58.decode(checkpoint.signature); + if (!Arrays.equals(signature, blockData.getSignature())) { + LOGGER.info("Error: block at height {} with signature {} doesn't match checkpoint sig: {}. Bootstrapping...", checkpoint.height, Base58.encode(blockData.getSignature()), checkpoint.signature); + needsArchiveRebuild = true; + break; + } + LOGGER.info("Block at height {} matches checkpoint signature", blockData.getHeight()); + } + } + } - boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); + // Check first block is Genesis Block + if (!isGenesisBlockValid() || needsArchiveRebuild) { + try { + rebuildBlockchain(); - if (isTopOnly && hasBlocks) { - // Top-only mode is enabled and we have blocks, so it's possible that the genesis block has been pruned - // It's best not to validate it, and there's no real need to - } else { - // Check first block is Genesis Block - if (!isGenesisBlockValid() || needsArchiveRebuild) { - try { - rebuildBlockchain(); - - } catch (InterruptedException e) { - throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage())); - } + } catch (InterruptedException e) { + throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage())); } } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index aa6cd73b..f48958eb 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -87,6 +87,9 @@ "feeValidationFixTimestamp": 1671918000000, "chatReferenceTimestamp": 1674316800000 }, + "checkpoints": [ + { "height": 1131800, "signature": "EpRam4PLdKzULMp7xNU7XG964AKfioG3g1k7cxwxWXnXspPwnjfF6UncEz4feuSA9mr1vW5d3YQPGruXYjj4vciSh4SPj5iWRxkHRWFeRpQnmVUyaVumuBTwM8nnLKJTdtkZnd6d8Mc5mVFdHs6EwLBTY4HECoRcbo4e4FwkfqVon4M" } + ], "genesisInfo": { "version": 4, "timestamp": "1593450000000", From 30105199a2de58b949b3bdac97bde8d5a83ee5a2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 12:00:32 +0000 Subject: [PATCH 131/496] Default pruneBlockLimit increased from 1450 to 6000 (approx 5 days), to be more similar to the AT states retention time of full nodes. --- src/main/java/org/qortal/block/BlockChain.java | 4 +--- src/main/java/org/qortal/settings/Settings.java | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 437a48ab..b96350e6 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -757,9 +757,7 @@ public class BlockChain { try (final Repository repository = RepositoryManager.getRepository()) { repository.checkConsistency(); - // Set the number of blocks to validate based on the pruned state of the chain - // If pruned, subtract an extra 10 to allow room for error - int blocksToValidate = (isTopOnly || archiveEnabled) ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; + int blocksToValidate = Math.min(Settings.getInstance().getPruneBlockLimit() - 10, 1440); int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1); BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight); diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 546bd936..d51737a3 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -159,7 +159,7 @@ public class Settings { * This prevents the node from being able to serve older blocks */ private boolean topOnly = false; /** The amount of recent blocks we should keep when pruning */ - private int pruneBlockLimit = 1450; + private int pruneBlockLimit = 6000; /** How often to attempt AT state pruning (ms). */ private long atStatesPruneInterval = 3219L; // milliseconds From dfe3754afc3d3ede3e7f4722a1baae9f4432c324 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 12:07:27 +0000 Subject: [PATCH 132/496] Block connections with peers older than 3.8.2, as those versions are nonfunctional due to recent feature triggers. --- src/main/java/org/qortal/network/Handshake.java | 2 +- src/main/java/org/qortal/settings/Settings.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/network/Handshake.java b/src/main/java/org/qortal/network/Handshake.java index b2e5f829..47752767 100644 --- a/src/main/java/org/qortal/network/Handshake.java +++ b/src/main/java/org/qortal/network/Handshake.java @@ -265,7 +265,7 @@ public enum Handshake { private static final long PEER_VERSION_131 = 0x0100030001L; /** Minimum peer version that we are allowed to communicate with */ - private static final String MIN_PEER_VERSION = "3.7.0"; + private static final String MIN_PEER_VERSION = "3.8.2"; private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes private static final int POW_DIFFICULTY_PRE_131 = 8; // leading zero bits diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index d51737a3..5799bd26 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -215,7 +215,7 @@ public class Settings { public long recoveryModeTimeout = 10 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.8.0"; + private String minPeerVersion = "3.8.2"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From c03f271825595cc1350b2a2274047a548cc52a73 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 12:44:19 +0000 Subject: [PATCH 133/496] Keep track of peers which are too divergent, and return an `isTooDivergent` boolean in /peers APIs. isTooDivergent will be true or false if a definitive decision has been made, or missing from the response if not yet known. Therefore it should be safe to treat `"isTooDivergent": false` as a peer that is on the same chain. --- .../java/org/qortal/api/model/ConnectedPeer.java | 7 +++++++ src/main/java/org/qortal/controller/Controller.java | 10 ++++++++++ .../java/org/qortal/controller/Synchronizer.java | 4 ++++ src/main/java/org/qortal/network/Peer.java | 13 +++++++++++++ 4 files changed, 34 insertions(+) diff --git a/src/main/java/org/qortal/api/model/ConnectedPeer.java b/src/main/java/org/qortal/api/model/ConnectedPeer.java index 3d383321..c4198654 100644 --- a/src/main/java/org/qortal/api/model/ConnectedPeer.java +++ b/src/main/java/org/qortal/api/model/ConnectedPeer.java @@ -1,6 +1,7 @@ package org.qortal.api.model; import io.swagger.v3.oas.annotations.media.Schema; +import org.qortal.controller.Controller; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.network.PeerData; import org.qortal.network.Handshake; @@ -36,6 +37,7 @@ public class ConnectedPeer { public Long lastBlockTimestamp; public UUID connectionId; public String age; + public Boolean isTooDivergent; protected ConnectedPeer() { } @@ -69,6 +71,11 @@ public class ConnectedPeer { this.lastBlockSignature = peerChainTipData.getSignature(); this.lastBlockTimestamp = peerChainTipData.getTimestamp(); } + + // Only include isTooDivergent decision if we've had the opportunity to request block summaries this peer + if (peer.getLastTooDivergentTime() != null) { + this.isTooDivergent = Controller.wasRecentlyTooDivergent.test(peer); + } } } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 0a323cb2..e9e1fcc2 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -769,6 +769,16 @@ public class Controller extends Thread { } }; + public static final Predicate wasRecentlyTooDivergent = peer -> { + Long now = NTP.getTime(); + Long peerLastTooDivergentTime = peer.getLastTooDivergentTime(); + if (now == null || peerLastTooDivergentTime == null) + return false; + + // Exclude any peers that were TOO_DIVERGENT in the last 5 mins + return (now - peerLastTooDivergentTime < 5 * 60 * 1000L); + }; + private long getRandomRepositoryMaintenanceInterval() { final long minInterval = Settings.getInstance().getRepositoryMaintenanceMinInterval(); final long maxInterval = Settings.getInstance().getRepositoryMaintenanceMaxInterval(); diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index e3ace9ed..2dad62e7 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -1121,6 +1121,7 @@ public class Synchronizer extends Thread { // If common block is too far behind us then we're on massively different forks so give up. if (!force && testHeight < ourHeight - MAXIMUM_COMMON_DELTA) { LOGGER.info(String.format("Blockchain too divergent with peer %s", peer)); + peer.setLastTooDivergentTime(NTP.getTime()); return SynchronizationResult.TOO_DIVERGENT; } @@ -1130,6 +1131,9 @@ public class Synchronizer extends Thread { testHeight = Math.max(testHeight - step, 1); } + // Peer not considered too divergent + peer.setLastTooDivergentTime(0L); + // Prepend test block's summary as first block summary, as summaries returned are *after* test block BlockSummaryData testBlockSummary = new BlockSummaryData(testBlockData); blockSummariesFromCommon.add(0, testBlockSummary); diff --git a/src/main/java/org/qortal/network/Peer.java b/src/main/java/org/qortal/network/Peer.java index a187d29b..4c05d5b9 100644 --- a/src/main/java/org/qortal/network/Peer.java +++ b/src/main/java/org/qortal/network/Peer.java @@ -155,6 +155,11 @@ public class Peer { */ private CommonBlockData commonBlockData; + /** + * Last time we detected this peer as TOO_DIVERGENT + */ + private Long lastTooDivergentTime; + // Message stats private static class MessageStats { @@ -383,6 +388,14 @@ public class Peer { this.commonBlockData = commonBlockData; } + public Long getLastTooDivergentTime() { + return this.lastTooDivergentTime; + } + + public void setLastTooDivergentTime(Long lastTooDivergentTime) { + this.lastTooDivergentTime = lastTooDivergentTime; + } + public boolean isSyncInProgress() { return this.syncInProgress; } From bd4c47dba602f5964ed3d97ffda64b5f04a22e05 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 14:32:33 +0000 Subject: [PATCH 134/496] Rework of AT state trimming and pruning, in order to more reliably track the "latest" AT states. This should fix an edge case where AT states data was pruned/trimmed but it was then later required in consensus. The older state was deleted because it was replaced by a new "latest" state in a brand new block. But once the new "latest" state was orphaned from the block, the old "latest" state was then required again. This works around the problem by excluding very recent blocks in the latest AT states data, so that it is unaffected by real-time sync activity. The trade off is that we could end up retaining more AT states than needed, so a secondary cleanup process may need to run at some time in the future to remove these. But it should only be a minimal amount of data, and can be cleaned up with a single query. This would have been happening to a certain degree already. --- .../controller/repository/AtStatesPruner.java | 6 +- .../repository/AtStatesTrimmer.java | 6 +- .../controller/repository/PruneManager.java | 14 ++ .../org/qortal/repository/ATRepository.java | 2 +- .../repository/hsqldb/HSQLDBATRepository.java | 5 +- .../hsqldb/HSQLDBDatabasePruning.java | 2 +- .../org/qortal/test/BlockArchiveTests.java | 26 ++-- .../java/org/qortal/test/BootstrapTests.java | 3 +- src/test/java/org/qortal/test/PruneTests.java | 143 +++++++++++++++++- .../org/qortal/test/at/AtRepositoryTests.java | 19 +-- .../org/qortal/test/common/BlockUtils.java | 9 ++ 11 files changed, 197 insertions(+), 38 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index bd12f784..1faeda98 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -39,9 +39,10 @@ public class AtStatesPruner implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); + int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); repository.discardChanges(); - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); while (!Controller.isStopping()) { repository.discardChanges(); @@ -91,7 +92,8 @@ public class AtStatesPruner implements Runnable { if (upperPrunableHeight > upperBatchHeight) { pruneStartHeight = upperBatchHeight; repository.getATRepository().setAtPruneHeight(pruneStartHeight); - repository.getATRepository().rebuildLatestAtStates(); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); final int finalPruneStartHeight = pruneStartHeight; diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index 69fa347c..ea56699c 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -26,9 +26,10 @@ public class AtStatesTrimmer implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int trimStartHeight = repository.getATRepository().getAtTrimHeight(); + int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); repository.discardChanges(); - repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); while (!Controller.isStopping()) { repository.discardChanges(); @@ -69,7 +70,8 @@ public class AtStatesTrimmer implements Runnable { if (upperTrimmableHeight > upperBatchHeight) { trimStartHeight = upperBatchHeight; repository.getATRepository().setAtTrimHeight(trimStartHeight); - repository.getATRepository().rebuildLatestAtStates(); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); final int finalTrimStartHeight = trimStartHeight; diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index ec27456f..dfb6290b 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -157,4 +157,18 @@ public class PruneManager { return (height < latestUnprunedHeight); } + /** + * When rebuilding the latest AT states, we need to specify a maxHeight, so that we aren't tracking + * very recent AT states that could potentially be orphaned. This method ensures that AT states + * are given a sufficient number of blocks to confirm before being tracked as a latest AT state. + */ + public static int getMaxHeightForLatestAtStates(Repository repository) throws DataException { + // Get current chain height, and subtract a certain number of "confirmation" blocks + // This is to ensure we are basing our latest AT states data on confirmed blocks - + // ones that won't be orphaned in any normal circumstances + final int confirmationBlocks = 250; + final int chainHeight = repository.getBlockRepository().getBlockchainHeight(); + return chainHeight - confirmationBlocks; + } + } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 0f537ae9..93da924c 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -119,7 +119,7 @@ public interface ATRepository { *