diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7b016a89..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "java.compile.nullAnalysis.mode": "automatic" -} \ No newline at end of file diff --git a/README.md b/README.md index 9dd9ad60..fa4d1213 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,19 @@ -# Qortal Project - Official Repo +# Qortal Project - Qortal Core - Primary Repository +The Qortal Core is the blockchain and node component of the overall project. It contains the primary API, and ability to make calls to create transactions, and interact with the Qortal Blockchain Network. + +In order to run the Qortal Core, a machine with java 11+ installed is required. Minimum RAM specs will vary depending on settings, but as low as 4GB of RAM should be acceptable in most scenarios. + +Qortal is a complete infrastructure platform with a blockchain backend, it is capable of indefinite web and application hosting with no continual fees, replacement of DNS and centralized name systems and communications systems, and is the foundation of the next generation digital infrastructure of the world. Qortal is unique in nearly every way, and was written from scratch to address as many concerns from both the existing 'blockchain space' and the 'typical internet' as possible, while maintaining a system that is easy to use and able to run on 'any' computer. + +Qortal contains extensive functionality geared toward complete decentralization of the digital world. Removal of 'middlemen' of any kind from all transactions, and ability to publish websites and applications that require no continual fees, on a name that is truly owned by the account that registered it, or purchased it from another. A single name on Qortal is capable of being both a namespace and a 'username'. That single name can have an application, website, public and private data, communications, authentication, the namespace itself and more, which can subsequently be sold to anyone else without the need to change any type of 'hosting' or DNS entries that do not exist, email that doesn't exist, etc. Maintaining the same functionality as those replaced features of web 2.0. + +Over time Qortal has progressed into a fully featured environment catering to any and all types of people and organizations, and will continue to advance as time goes on. Brining more features, capability, device support, and availale replacements for web2.0. Ultimately building a new, completely user-controlled digital world without limits. + +Qortal has no owner, no company on top of it, and is completely community built, run, and funded. A community-established and run group of developers known as the 'dev-group' or Qortal Development Group, make group_approval based decisions for the project's future. If you are a developer interested in assisting with the project, you meay reach out to the Qortal Development Group in any of the available Qortal community locations. Either on the Qortal network itself, or on one of the temporary centralized social media locations. + +Building the future one block at a time. Welcome to Qortal. + +# Building the Qortal Core from Source ## Build / run @@ -10,3 +25,21 @@ - Run JAR in same working directory as *settings.json*: `java -jar target/qortal-1.0.jar` - Wrap in shell script, add JVM flags, redirection, backgrounding, etc. as necessary. - Or use supplied example shell script: *start.sh* + +# Using a pre-built Qortal 'jar' binary + +If you would prefer to utilize a released version of Qortal, you may do so by downloading one of the available releases from the releases page, that are also linked on https://qortal.org and https://qortal.dev. + +# Learning Q-App Development + +https://qortal.dev contains dev documentation for building JS/React (and other client-side languages) applications or 'Q-Apps' on Qortal. Q-Apps are published on Registered Qortal Names, and aside from a single Name Registration fee, and a fraction of QORT for a publish transaction, require zero continual costs. These applications get more redundant with each new access from a new Qortal Node, making your application faster for the next user to download, and stronger as time goes on. Q-Apps live indefinitely in the history of the blockchain-secured Qortal Data Network (QDN). + +# How to learn more + +If the project interests you, you may learn more from the various web2 and QDN based websites focused on introductory information. + +https://qortal.org - primary internet presence +https://qortal.dev - secondary and development focused website with links to many new developments and documentation +https://wiki.qortal.org - community built and managed wiki with detailed information regarding the project + +links to telegram and discord communities are at the top of https://qortal.org as well. diff --git a/WindowsInstaller/Install Files/AppData/settings.json b/WindowsInstaller/Install Files/AppData/settings.json index 088afef4..0d66e4e8 100755 --- a/WindowsInstaller/Install Files/AppData/settings.json +++ b/WindowsInstaller/Install Files/AppData/settings.json @@ -1,3 +1,4 @@ { - "apiDocumentationEnabled": true + "apiDocumentationEnabled": true, + "apiWhitelistEnabled": false } diff --git a/WindowsInstaller/Nice-Qortal-Logo-crop.bmp b/WindowsInstaller/Nice-Qortal-Logo-crop.bmp new file mode 100644 index 00000000..0b9f457b Binary files /dev/null and b/WindowsInstaller/Nice-Qortal-Logo-crop.bmp differ diff --git a/WindowsInstaller/Nice-Qortal-Logo-crop.png b/WindowsInstaller/Nice-Qortal-Logo-crop.png new file mode 100644 index 00000000..0cea3c92 Binary files /dev/null and b/WindowsInstaller/Nice-Qortal-Logo-crop.png differ diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 1b0c944b..a3dbd88a 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -4,10 +4,12 @@ - - + + + + @@ -16,19 +18,22 @@ - + + + + + - + - + - - + @@ -40,6 +45,7 @@ + @@ -133,232 +139,239 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + @@ -368,497 +381,505 @@ - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -869,8 +890,11 @@ + + + - + @@ -899,19 +923,19 @@ - - - - - + + + + + + - + - - - + + @@ -941,175 +965,76 @@ + + + + + + + + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -1129,6 +1054,7 @@ + @@ -1145,6 +1071,8 @@ + + @@ -1162,6 +1090,7 @@ + @@ -1180,11 +1109,6 @@ - - - - - @@ -1195,15 +1119,20 @@ + + + + + @@ -1217,7 +1146,7 @@ - + @@ -1239,6 +1168,7 @@ + @@ -1279,9 +1209,15 @@ - + + + + + + + @@ -1297,13 +1233,6 @@ - - - - - - - @@ -1327,86 +1256,87 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1428,12 +1358,12 @@ - + - + @@ -1462,6 +1392,8 @@ + + @@ -1469,7 +1401,7 @@ - + @@ -1477,6 +1409,8 @@ + + @@ -1491,15 +1425,13 @@ - - - - - - + + + + @@ -1508,6 +1440,7 @@ + @@ -1559,8 +1492,11 @@ + + + - + diff --git a/WindowsInstaller/README.md b/WindowsInstaller/README.md index 29a7b64a..4431564e 100644 --- a/WindowsInstaller/README.md +++ b/WindowsInstaller/README.md @@ -2,7 +2,9 @@ ## Prerequisites -* AdvancedInstaller v19.4 or better, and enterprise licence if translations are required +* AdvancedInstaller v19.4 or better, and enterprise licence. +* Qortal has an open source license, however it currently (as of December 2024) only supports up to version 19. (We may need to reach out to Advanced Installer again to obtain a new license at some point, if needed. +* Reach out to @crowetic for links to the installer install files, and license. * Installed AdoptOpenJDK v17 64bit, full JDK *not* JRE ## General build instructions @@ -10,6 +12,12 @@ If this is your first time opening the `qortal.aip` file then you might need to adjust configured paths, or create a dummy `D:` drive with the expected layout. +Opening the aip file from within a clone of the qortal repo also works, if you have a separate windows machine setup to do the build. + +You May need to change the location of the 'jre64' files inside Advanced Installer, if it is set to a path that your build machine doesn't have. + +The Java Memory Arguments can be set manually, but as of December 2024 they have been reset back to system defaults. This should include G1GC Garbage Collector. + Typical build procedure: * Place the `qortal.jar` file in `Install-Files\` diff --git a/lib/io/reticulum/reticulum-network-stack/1.0-SNAPSHOT/maven-metadata-local.xml b/lib/io/reticulum/reticulum-network-stack/1.0-SNAPSHOT/maven-metadata-local.xml index 0c5034d3..0c856aac 100644 --- a/lib/io/reticulum/reticulum-network-stack/1.0-SNAPSHOT/maven-metadata-local.xml +++ b/lib/io/reticulum/reticulum-network-stack/1.0-SNAPSHOT/maven-metadata-local.xml @@ -7,17 +7,17 @@ true - 20240814140435 + 20250418180444 jar 1.0-SNAPSHOT - 20240814140435 + 20250418180444 pom 1.0-SNAPSHOT - 20240324170649 + 20241218212752 diff --git a/lib/io/reticulum/reticulum-network-stack/1.0-SNAPSHOT/reticulum-network-stack-1.0-SNAPSHOT.jar b/lib/io/reticulum/reticulum-network-stack/1.0-SNAPSHOT/reticulum-network-stack-1.0-SNAPSHOT.jar index 5695fd2a..e1d6ad48 100644 Binary files a/lib/io/reticulum/reticulum-network-stack/1.0-SNAPSHOT/reticulum-network-stack-1.0-SNAPSHOT.jar and b/lib/io/reticulum/reticulum-network-stack/1.0-SNAPSHOT/reticulum-network-stack-1.0-SNAPSHOT.jar differ diff --git a/lib/io/reticulum/reticulum-network-stack/maven-metadata-local.xml b/lib/io/reticulum/reticulum-network-stack/maven-metadata-local.xml index ef7609de..45dbe562 100644 --- a/lib/io/reticulum/reticulum-network-stack/maven-metadata-local.xml +++ b/lib/io/reticulum/reticulum-network-stack/maven-metadata-local.xml @@ -6,6 +6,6 @@ 1.0-SNAPSHOT - 20240814140435 + 20250418180444 diff --git a/log4j2.properties b/log4j2.properties index 54f295c1..584e74b6 100644 --- a/log4j2.properties +++ b/log4j2.properties @@ -6,6 +6,34 @@ rootLogger.level = info rootLogger.appenderRef.console.ref = stdout rootLogger.appenderRef.rolling.ref = FILE +### additional debug options (in case of problems eg. 202411 + +#to see more QDN details - add the stuff below +#logger.arbitrary.name = org.qortal.arbitrary +#logger.arbitrary.level = trace + +#to see more QDN networking details - add stuff below +#logger.arbitrarycontroller.name = org.qortal.controller.arbitrary +#logger.arbitrarycontroller.level = debug + +# Support optional, Network Task debugging +#logger.networkTask.name = org.qortal.network.task +#logger.networkTask.level = debug + +# Support optional, Network Task tracing +#logger.networkTask.name = org.qortal.network.task +#logger.networkTask.level = trace + +# Support optional, Block debugging +#logger.block.name = org.qortal.block +#logger.block.level = debug + +# Support optional, Block tracing +#logger.block.name = org.qortal.block +#logger.block.level = trace + +### end additional debug options + # Suppress extraneous bitcoinj library output logger.bitcoinj.name = org.bitcoinj logger.bitcoinj.level = error @@ -18,6 +46,10 @@ logger.hsqldb.level = warn logger.hsqldbRepository.name = org.qortal.repository.hsqldb logger.hsqldbRepository.level = debug +## Support optional, controller repository debugging +#logger.controllerRepository.name = org.qortal.controller.repository +#logger.controllerRepository.level = debug + # Suppress extraneous Jersey warning logger.jerseyInject.name = org.glassfish.jersey.internal.inject.Providers logger.jerseyInject.level = off diff --git a/pom.xml b/pom.xml index 55fa4b36..82e0f42f 100644 --- a/pom.xml +++ b/pom.xml @@ -3,11 +3,12 @@ 4.0.0 org.qortal qortal - 4.5.2 + 4.7.1 jar UTF-8 true + 7dc8c6f 0.15.10 1.70 @@ -15,26 +16,26 @@ 1.4.2 3.8.0 1.12.0 - 2.16.1 - 1.26.2 - 3.14.0 + 2.18.0 + 1.27.1 + 3.17.0 1.2.2 0.12.3 4.9.10 - 1.65.0 - 33.2.1-jre + 1.68.1 + 33.3.1-jre 2.2 1.2.1 - 2.5.1 - 75.1 - 4.12 + 2.7.4 + 76.1 + 4.15 4.0.1 2.3.9 2.42 - 9.4.54.v20240208 + 9.4.56.v20240826 1.1.1 20240303 - 1.17.2 + 1.18.1 5.11.0-M2 1.0.0 2.23.1 @@ -44,19 +45,18 @@ 3.6.1 3.4.2 1.1.0 - - 3.12.1 - 0.16 + 2.18.0 + 0.17 3.3.1 3.6.0 - 3.3.0 + 3.5.2 3.25.3 1.5.3 1.17 2.0.10 - 5.17.14 + 5.18.2 1.2 - 1.9 + 1.10 1.18.30 2.16.1 2.0.12 @@ -445,26 +445,26 @@ - + @@ -590,32 +590,33 @@ guava ${guava.version} + org.slf4j slf4j-api ${slf4j.version} + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j.version} + - - org.apache.logging.log4j - log4j-slf4j2-impl - ${log4j.version} - - - org.apache.logging.log4j - log4j-core - ${log4j.version} - - - org.apache.logging.log4j - log4j-api - ${log4j.version} - - - org.apache.logging.log4j - log4j-jul - ${log4j.version} - + + org.apache.logging.log4j + log4j-core + ${log4j.version} + + + org.apache.logging.log4j + log4j-api + ${log4j.version} + + + org.apache.logging.log4j + log4j-jul + ${log4j.version} + javax.servlet diff --git a/src/main/java/org/hsqldb/jdbc/HSQLDBPoolMonitored.java b/src/main/java/org/hsqldb/jdbc/HSQLDBPoolMonitored.java new file mode 100644 index 00000000..2037453c --- /dev/null +++ b/src/main/java/org/hsqldb/jdbc/HSQLDBPoolMonitored.java @@ -0,0 +1,173 @@ +package org.hsqldb.jdbc; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.hsqldb.jdbc.pool.JDBCPooledConnection; +import org.qortal.data.system.DbConnectionInfo; +import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; + +import javax.sql.ConnectionEvent; +import javax.sql.PooledConnection; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Class HSQLDBPoolMonitored + * + * This class uses the same logic as HSQLDBPool. The only difference is it monitors the state of every connection + * to the database. This is used for debugging purposes only. + */ +public class HSQLDBPoolMonitored extends HSQLDBPool { + + private static final Logger LOGGER = LogManager.getLogger(HSQLDBRepositoryFactory.class); + + private static final String EMPTY = "Empty"; + private static final String AVAILABLE = "Available"; + private static final String ALLOCATED = "Allocated"; + + private ConcurrentHashMap infoByIndex; + + public HSQLDBPoolMonitored(int poolSize) { + super(poolSize); + + this.infoByIndex = new ConcurrentHashMap<>(poolSize); + } + + /** + * Tries to retrieve a new connection using the properties that have already been + * set. + * + * @return a connection to the data source, or null if no spare connections in pool + * @exception SQLException if a database access error occurs + */ + public Connection tryConnection() throws SQLException { + for (int i = 0; i < states.length(); i++) { + if (states.compareAndSet(i, RefState.available, RefState.allocated)) { + JDBCPooledConnection pooledConnection = connections[i]; + + if (pooledConnection == null) + // Probably shutdown situation + return null; + + infoByIndex.put(i, new DbConnectionInfo(System.currentTimeMillis(), Thread.currentThread().getName(), ALLOCATED)); + + return pooledConnection.getConnection(); + } + + if (states.compareAndSet(i, RefState.empty, RefState.allocated)) { + try { + JDBCPooledConnection pooledConnection = (JDBCPooledConnection) source.getPooledConnection(); + + if (pooledConnection == null) + // Probably shutdown situation + return null; + + pooledConnection.addConnectionEventListener(this); + pooledConnection.addStatementEventListener(this); + connections[i] = pooledConnection; + + infoByIndex.put(i, new DbConnectionInfo(System.currentTimeMillis(), Thread.currentThread().getName(), ALLOCATED)); + + return pooledConnection.getConnection(); + } catch (SQLException e) { + states.set(i, RefState.empty); + infoByIndex.put(i, new DbConnectionInfo(System.currentTimeMillis(), Thread.currentThread().getName(), EMPTY)); + } + } + } + + return null; + } + + public Connection getConnection() throws SQLException { + int var1 = 300; + if (this.source.loginTimeout != 0) { + var1 = this.source.loginTimeout * 10; + } + + if (this.closed) { + throw new SQLException("connection pool is closed"); + } else { + for(int var2 = 0; var2 < var1; ++var2) { + for(int var3 = 0; var3 < this.states.length(); ++var3) { + if (this.states.compareAndSet(var3, 1, 2)) { + infoByIndex.put(var3, new DbConnectionInfo(System.currentTimeMillis(), Thread.currentThread().getName(), ALLOCATED)); + return this.connections[var3].getConnection(); + } + + if (this.states.compareAndSet(var3, 0, 2)) { + try { + JDBCPooledConnection var4 = (JDBCPooledConnection)this.source.getPooledConnection(); + var4.addConnectionEventListener(this); + var4.addStatementEventListener(this); + this.connections[var3] = var4; + + infoByIndex.put(var3, new DbConnectionInfo(System.currentTimeMillis(), Thread.currentThread().getName(), ALLOCATED)); + + return this.connections[var3].getConnection(); + } catch (SQLException var6) { + this.states.set(var3, 0); + infoByIndex.put(var3, new DbConnectionInfo(System.currentTimeMillis(), Thread.currentThread().getName(), EMPTY)); + } + } + } + + try { + Thread.sleep(100L); + } catch (InterruptedException var5) { + } + } + + throw JDBCUtil.invalidArgument(); + } + } + + public void connectionClosed(ConnectionEvent event) { + PooledConnection connection = (PooledConnection) event.getSource(); + + for (int i = 0; i < connections.length; i++) { + if (connections[i] == connection) { + states.set(i, RefState.available); + infoByIndex.put(i, new DbConnectionInfo(System.currentTimeMillis(), Thread.currentThread().getName(), AVAILABLE)); + break; + } + } + } + + public void connectionErrorOccurred(ConnectionEvent event) { + PooledConnection connection = (PooledConnection) event.getSource(); + + for (int i = 0; i < connections.length; i++) { + if (connections[i] == connection) { + states.set(i, RefState.allocated); + connections[i] = null; + states.set(i, RefState.empty); + infoByIndex.put(i, new DbConnectionInfo(System.currentTimeMillis(), Thread.currentThread().getName(), EMPTY)); + break; + } + } + } + + public List getDbConnectionsStates() { + + return infoByIndex.values().stream() + .sorted(Comparator.comparingLong(DbConnectionInfo::getUpdated)) + .collect(Collectors.toList()); + } + + private int findConnectionIndex(ConnectionEvent connectionEvent) { + PooledConnection pooledConnection = (PooledConnection) connectionEvent.getSource(); + + for(int i = 0; i < this.connections.length; ++i) { + if (this.connections[i] == pooledConnection) { + return i; + } + } + + return -1; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/ApplyRestart.java b/src/main/java/org/qortal/ApplyRestart.java index 70d07df5..9488488c 100644 --- a/src/main/java/org/qortal/ApplyRestart.java +++ b/src/main/java/org/qortal/ApplyRestart.java @@ -1,14 +1,17 @@ package org.qortal; +import org.apache.commons.io.FileUtils; 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.api.ApiKey; import org.qortal.api.ApiRequest; +import org.qortal.controller.Controller; import org.qortal.controller.RestartNode; import org.qortal.settings.Settings; +import java.io.File; import java.io.IOException; import java.lang.management.ManagementFactory; import java.nio.file.Files; @@ -16,6 +19,8 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.security.Security; import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; import static org.qortal.controller.RestartNode.AGENTLIB_JVM_HOLDER_ARG; @@ -38,7 +43,7 @@ public class ApplyRestart { private static final String JAVA_TOOL_OPTIONS_NAME = "JAVA_TOOL_OPTIONS"; private static final String JAVA_TOOL_OPTIONS_VALUE = ""; - private static final long CHECK_INTERVAL = 10 * 1000L; // ms + private static final long CHECK_INTERVAL = 30 * 1000L; // ms private static final int MAX_ATTEMPTS = 12; public static void main(String[] args) { @@ -51,21 +56,38 @@ public class ApplyRestart { else Settings.getInstance(); - LOGGER.info("Applying restart..."); + LOGGER.info("Applying restart this can take up to 5 minutes..."); // Shutdown node using API if (!shutdownNode()) return; - // Restart node - restartNode(args); + try { + // Give some time for shutdown + TimeUnit.SECONDS.sleep(60); - LOGGER.info("Restarting..."); + // Remove blockchain lock if exist + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + if (blockchainLock.isLocked()) + blockchainLock.unlock(); + + // Remove blockchain lock file if still exist + TimeUnit.SECONDS.sleep(60); + deleteLock(); + + // Restart node + TimeUnit.SECONDS.sleep(15); + restartNode(args); + + LOGGER.info("Restarting..."); + } catch (InterruptedException e) { + LOGGER.error("Unable to restart", e); + } } private static boolean shutdownNode() { String baseUri = "http://localhost:" + Settings.getInstance().getApiPort() + "/"; - LOGGER.info(() -> String.format("Shutting down node using API via %s", baseUri)); + LOGGER.debug(() -> String.format("Shutting down node using API via %s", baseUri)); // The /admin/stop endpoint requires an API key, which may or may not be already generated boolean apiKeyNewlyGenerated = false; @@ -95,10 +117,17 @@ public class ApplyRestart { String response = ApiRequest.perform(baseUri + "admin/stop", params); if (response == null) { // No response - consider node shut down + try { + TimeUnit.SECONDS.sleep(30); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + if (apiKeyNewlyGenerated) { // API key was newly generated for restarting node, so we need to remove it ApplyRestart.removeGeneratedApiKey(); } + return true; } @@ -134,7 +163,22 @@ public class ApplyRestart { apiKey.delete(); } catch (IOException e) { - LOGGER.info("Error loading or deleting API key: {}", e.getMessage()); + LOGGER.error("Error loading or deleting API key: {}", e.getMessage()); + } + } + + private static void deleteLock() { + // Get the repository path from settings + String repositoryPath = Settings.getInstance().getRepositoryPath(); + LOGGER.debug(String.format("Repository path is: %s", repositoryPath)); + + try { + Path root = Paths.get(repositoryPath); + File lockFile = new File(root.resolve("blockchain.lck").toUri()); + LOGGER.debug("Lockfile is: {}", lockFile); + FileUtils.forceDelete(FileUtils.getFile(lockFile)); + } catch (IOException e) { + LOGGER.debug("Error deleting blockchain lock file: {}", e.getMessage()); } } @@ -150,9 +194,10 @@ public class ApplyRestart { List javaCmd; if (Files.exists(exeLauncher)) { - javaCmd = Arrays.asList(exeLauncher.toString()); + javaCmd = List.of(exeLauncher.toString()); } else { javaCmd = new ArrayList<>(); + // Java runtime binary itself javaCmd.add(javaBinary.toString()); diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java index 756128f9..722e70da 100644 --- a/src/main/java/org/qortal/account/Account.java +++ b/src/main/java/org/qortal/account/Account.java @@ -7,14 +7,20 @@ import org.qortal.controller.LiteNode; import org.qortal.data.account.AccountBalanceData; import org.qortal.data.account.AccountData; import org.qortal.data.account.RewardShareData; +import org.qortal.data.naming.NameData; import org.qortal.repository.DataException; +import org.qortal.repository.GroupRepository; +import org.qortal.repository.NameRepository; import org.qortal.repository.Repository; import org.qortal.settings.Settings; import org.qortal.utils.Base58; +import org.qortal.utils.Groups; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import java.util.List; + import static org.qortal.utils.Amounts.prettyAmount; @XmlAccessorType(XmlAccessType.NONE) // Stops JAX-RS errors when unmarshalling blockchain config @@ -193,27 +199,85 @@ public class Account { /** Returns whether account can be considered a "minting account". *

- * To be considered a "minting account", the account needs to pass at least one of these tests:
+ * To be considered a "minting account", the account needs to pass some of these tests:
*

    *
  • account's level is at least minAccountLevelToMint from blockchain config
  • - *
  • account has 'founder' flag set
  • + *
  • account's address has registered a name
  • + *
  • account's address is a member of the minter group
  • *
- * + * + * @param isGroupValidated true if this account has already been validated for MINTER Group membership * @return true if account can be considered "minting account" * @throws DataException */ - public boolean canMint() throws DataException { + public boolean canMint(boolean isGroupValidated) throws DataException { AccountData accountData = this.repository.getAccountRepository().getAccount(this.address); - if (accountData == null) - return false; + NameRepository nameRepository = this.repository.getNameRepository(); + GroupRepository groupRepository = this.repository.getGroupRepository(); + String myAddress = accountData.getAddress(); - Integer level = accountData.getLevel(); - if (level != null && level >= BlockChain.getInstance().getMinAccountLevelToMint()) - return true; + int blockchainHeight = this.repository.getBlockRepository().getBlockchainHeight(); - // Founders can always mint, unless they have a penalty - if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0) - return true; + int levelToMint; + + if( blockchainHeight >= BlockChain.getInstance().getIgnoreLevelForRewardShareHeight() ) { + levelToMint = 0; + } + else { + levelToMint = BlockChain.getInstance().getMinAccountLevelToMint(); + } + + int level = accountData.getLevel(); + List groupIdsToMint = Groups.getGroupIdsToMint( BlockChain.getInstance(), blockchainHeight ); + int nameCheckHeight = BlockChain.getInstance().getOnlyMintWithNameHeight(); + int groupCheckHeight = BlockChain.getInstance().getGroupMemberCheckHeight(); + int removeNameCheckHeight = BlockChain.getInstance().getRemoveOnlyMintWithNameHeight(); + + // Can only mint if: + // Account's level is at least minAccountLevelToMint from blockchain config + if (blockchainHeight < nameCheckHeight) { + if (Account.isFounder(accountData.getFlags())) { + return accountData.getBlocksMintedPenalty() == 0; + } else { + return level >= levelToMint; + } + } + + // Can only mint on onlyMintWithNameHeight from blockchain config if: + // Account's level is at least minAccountLevelToMint from blockchain config + // Account's address has registered a name + if (blockchainHeight >= nameCheckHeight && blockchainHeight < groupCheckHeight) { + List myName = nameRepository.getNamesByOwner(myAddress); + if (Account.isFounder(accountData.getFlags())) { + return accountData.getBlocksMintedPenalty() == 0 && !myName.isEmpty(); + } else { + return level >= levelToMint && !myName.isEmpty(); + } + } + + // Can only mint on groupMemberCheckHeight from blockchain config if: + // Account's level is at least minAccountLevelToMint from blockchain config + // Account's address has registered a name + // Account's address is a member of the minter group + if (blockchainHeight >= groupCheckHeight && blockchainHeight < removeNameCheckHeight) { + List myName = nameRepository.getNamesByOwner(myAddress); + if (Account.isFounder(accountData.getFlags())) { + return accountData.getBlocksMintedPenalty() == 0 && !myName.isEmpty() && (isGroupValidated || Groups.memberExistsInAnyGroup(groupRepository, groupIdsToMint, myAddress)); + } else { + return level >= levelToMint && !myName.isEmpty() && (isGroupValidated || Groups.memberExistsInAnyGroup(groupRepository, groupIdsToMint, myAddress)); + } + } + + // Can only mint on removeOnlyMintWithNameHeight from blockchain config if: + // Account's level is at least minAccountLevelToMint from blockchain config + // Account's address is a member of the minter group + if (blockchainHeight >= removeNameCheckHeight) { + if (Account.isFounder(accountData.getFlags())) { + return accountData.getBlocksMintedPenalty() == 0 && (isGroupValidated || Groups.memberExistsInAnyGroup(groupRepository, groupIdsToMint, myAddress)); + } else { + return level >= levelToMint && (isGroupValidated || Groups.memberExistsInAnyGroup(groupRepository, groupIdsToMint, myAddress)); + } + } return false; } @@ -228,7 +292,6 @@ public class Account { return this.repository.getAccountRepository().getBlocksMintedPenaltyCount(this.address); } - /** Returns whether account can build reward-shares. *

* To be able to create reward-shares, the account needs to pass at least one of these tests:
@@ -242,6 +305,7 @@ public class Account { */ public boolean canRewardShare() throws DataException { AccountData accountData = this.repository.getAccountRepository().getAccount(this.address); + if (accountData == null) return false; @@ -252,6 +316,9 @@ public class Account { if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0) return true; + if( this.repository.getBlockRepository().getBlockchainHeight() >= BlockChain.getInstance().getIgnoreLevelForRewardShareHeight() ) + return true; + return false; } @@ -295,10 +362,28 @@ public class Account { } /** - * Returns 'effective' minting level, or zero if reward-share does not exist. + * Returns reward-share minting address, or unknown if reward-share does not exist. * * @param repository * @param rewardSharePublicKey + * @return address or unknown + * @throws DataException + */ + public static String getRewardShareMintingAddress(Repository repository, byte[] rewardSharePublicKey) throws DataException { + // Find actual minter address + RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(rewardSharePublicKey); + + if (rewardShareData == null) + return "Unknown"; + + return rewardShareData.getMinter(); + } + + /** + * Returns 'effective' minting level, or zero if reward-share does not exist. + * + * @param repository + * @param rewardSharePublicKey * @return 0+ * @throws DataException */ @@ -311,6 +396,7 @@ public class Account { Account rewardShareMinter = new Account(repository, rewardShareData.getMinter()); return rewardShareMinter.getEffectiveMintingLevel(); } + /** * Returns 'effective' minting level, with a fix for the zero level. *

diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index fbef50d3..2cebe8e5 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -194,6 +194,7 @@ public class ApiService { context.addServlet(AdminStatusWebSocket.class, "/websockets/admin/status"); context.addServlet(BlocksWebSocket.class, "/websockets/blocks"); + context.addServlet(DataMonitorSocket.class, "/websockets/datamonitor"); context.addServlet(ActiveChatsWebSocket.class, "/websockets/chat/active/*"); context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages"); context.addServlet(TradeOffersWebSocket.class, "/websockets/crosschain/tradeoffers"); diff --git a/src/main/java/org/qortal/api/model/ApiOnlineAccount.java b/src/main/java/org/qortal/api/model/ApiOnlineAccount.java index 08b697aa..e26eb816 100644 --- a/src/main/java/org/qortal/api/model/ApiOnlineAccount.java +++ b/src/main/java/org/qortal/api/model/ApiOnlineAccount.java @@ -1,7 +1,13 @@ package org.qortal.api.model; +import org.qortal.account.Account; +import org.qortal.repository.DataException; +import org.qortal.repository.RepositoryManager; +import org.qortal.repository.Repository; + import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) @@ -47,4 +53,31 @@ public class ApiOnlineAccount { return this.recipientAddress; } + public int getMinterLevelFromPublicKey() { + try (final Repository repository = RepositoryManager.getRepository()) { + return Account.getRewardShareEffectiveMintingLevel(repository, this.rewardSharePublicKey); + } catch (DataException e) { + return 0; + } + } + + public boolean getIsMember() { + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getGroupRepository().memberExists(694, getMinterAddress()); + } catch (DataException e) { + return false; + } + } + + // JAXB special + + @XmlElement(name = "minterLevel") + protected int getMinterLevel() { + return getMinterLevelFromPublicKey(); + } + + @XmlElement(name = "isMinterMember") + protected boolean getMinterMember() { + return getIsMember(); + } } diff --git a/src/main/java/org/qortal/api/model/BlockMintingInfo.java b/src/main/java/org/qortal/api/model/BlockMintingInfo.java index f84e179e..02765a89 100644 --- a/src/main/java/org/qortal/api/model/BlockMintingInfo.java +++ b/src/main/java/org/qortal/api/model/BlockMintingInfo.java @@ -9,6 +9,7 @@ import java.math.BigInteger; public class BlockMintingInfo { public byte[] minterPublicKey; + public String minterAddress; public int minterLevel; public int onlineAccountsCount; public BigDecimal maxDistance; @@ -19,5 +20,4 @@ public class BlockMintingInfo { public BlockMintingInfo() { } - } diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeLedgerEntry.java b/src/main/java/org/qortal/api/model/CrossChainTradeLedgerEntry.java new file mode 100644 index 00000000..34f8fc57 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainTradeLedgerEntry.java @@ -0,0 +1,72 @@ +package org.qortal.api.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.qortal.data.crosschain.CrossChainTradeData; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainTradeLedgerEntry { + + private String market; + + private String currency; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long quantity; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long feeAmount; + + private String feeCurrency; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long totalPrice; + + private long tradeTimestamp; + + protected CrossChainTradeLedgerEntry() { + /* For JAXB */ + } + + public CrossChainTradeLedgerEntry(String market, String currency, long quantity, long feeAmount, String feeCurrency, long totalPrice, long tradeTimestamp) { + this.market = market; + this.currency = currency; + this.quantity = quantity; + this.feeAmount = feeAmount; + this.feeCurrency = feeCurrency; + this.totalPrice = totalPrice; + this.tradeTimestamp = tradeTimestamp; + } + + public String getMarket() { + return market; + } + + public String getCurrency() { + return currency; + } + + public long getQuantity() { + return quantity; + } + + public long getFeeAmount() { + return feeAmount; + } + + public String getFeeCurrency() { + return feeCurrency; + } + + public long getTotalPrice() { + return totalPrice; + } + + public long getTradeTimestamp() { + return tradeTimestamp; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/model/DatasetStatus.java b/src/main/java/org/qortal/api/model/DatasetStatus.java new file mode 100644 index 00000000..b587be51 --- /dev/null +++ b/src/main/java/org/qortal/api/model/DatasetStatus.java @@ -0,0 +1,50 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.Objects; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class DatasetStatus { + + private String name; + + private long count; + + public DatasetStatus() {} + + public DatasetStatus(String name, long count) { + this.name = name; + this.count = count; + } + + public String getName() { + return name; + } + + public long getCount() { + return count; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DatasetStatus that = (DatasetStatus) o; + return count == that.count && Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, count); + } + + @Override + public String toString() { + return "DatasetStatus{" + + "name='" + name + '\'' + + ", count=" + count + + '}'; + } +} diff --git a/src/main/java/org/qortal/api/model/crosschain/BitcoinyTBDRequest.java b/src/main/java/org/qortal/api/model/crosschain/BitcoinyTBDRequest.java new file mode 100644 index 00000000..3a531413 --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/BitcoinyTBDRequest.java @@ -0,0 +1,692 @@ +package org.qortal.api.model.crosschain; + +import org.qortal.crosschain.ServerInfo; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.Arrays; + +@XmlAccessorType(XmlAccessType.FIELD) +public class BitcoinyTBDRequest { + + /** + * Target Timespan + * + * extracted from /src/chainparams.cpp class + * consensus.nPowTargetTimespan + */ + private int targetTimespan; + + /** + * Target Spacing + * + * extracted from /src/chainparams.cpp class + * consensus.nPowTargetSpacing + */ + private int targetSpacing; + + /** + * Packet Magic + * + * extracted from /src/chainparams.cpp class + * Concatenate the 4 values in pchMessageStart, then convert the hex to decimal. + * + * Ex. litecoin + * pchMessageStart[0] = 0xfb; + * pchMessageStart[1] = 0xc0; + * pchMessageStart[2] = 0xb6; + * pchMessageStart[3] = 0xdb; + * packetMagic = 0xfbc0b6db = 4223710939 + */ + private long packetMagic; + + /** + * Port + * + * extracted from /src/chainparams.cpp class + * nDefaultPort + */ + private int port; + + /** + * Address Header + * + * extracted from /src/chainparams.cpp class + * base58Prefixes[PUBKEY_ADDRESS] from Main Network + */ + private int addressHeader; + + /** + * P2sh Header + * + * extracted from /src/chainparams.cpp class + * base58Prefixes[SCRIPT_ADDRESS] from Main Network + */ + private int p2shHeader; + + /** + * Segwit Address Hrp + * + * HRP -> Human Readable Parts + * + * extracted from /src/chainparams.cpp class + * bech32_hrp + */ + private String segwitAddressHrp; + + /** + * Dumped Private Key Header + * + * extracted from /src/chainparams.cpp class + * base58Prefixes[SECRET_KEY] from Main Network + * This is usually, but not always ... addressHeader + 128 + */ + private int dumpedPrivateKeyHeader; + + /** + * Subsidy Decreased Block Count + * + * extracted from /src/chainparams.cpp class + * consensus.nSubsidyHalvingInterval + * + * Digibyte does not support this, because they do halving differently. + */ + private int subsidyDecreaseBlockCount; + + /** + * Expected Genesis Hash + * + * extracted from /src/chainparams.cpp class + * consensus.hashGenesisBlock + * Remove '0x' prefix + */ + private String expectedGenesisHash; + + /** + * Common Script Pub Key + * + * extracted from /src/chainparams.cpp class + * This is the key commonly used to sign alerts for altcoins. Bitcoin and Digibyte are know exceptions. + */ + public static final String SCRIPT_PUB_KEY = "040184710fa689ad5023690c80f3a49c8f13f8d45b8c857fbcbc8bc4a8e4d3eb4b10f4d4604fa08dce601aaf0f470216fe1b51850b4acf21b179c45070ac7b03a9"; + + /** + * The Script Pub Key + * + * extracted from /src/chainparams.cpp class + * The key to sign alerts. + * + * const CScript genesisOutputScript = CScript() << ParseHex("040184710fa689ad5023690c80f3a49c8f13f8d45b8c857fbcbc8bc4a8e4d3eb4b10f4d4604fa08dce601aaf0f470216fe1b51850b4acf21b179c45070ac7b03a9") << OP_CHECKSIG; + * + * ie LTC = 040184710fa689ad5023690c80f3a49c8f13f8d45b8c857fbcbc8bc4a8e4d3eb4b10f4d4604fa08dce601aaf0f470216fe1b51850b4acf21b179c45070ac7b03a9 + * + * this may be the same value as scripHex + */ + private String pubKey; + + /** + * DNS Seeds + * + * extracted from /src/chainparams.cpp class + * vSeeds + */ + private String[] dnsSeeds; + + /** + * BIP32 Header P2PKH Pub + * + * extracted from /src/chainparams.cpp class + * Concatenate the 4 values in base58Prefixes[EXT_PUBLIC_KEY] + * base58Prefixes[EXT_PUBLIC_KEY] = {0x04, 0x88, 0xB2, 0x1E} = 0x0488B21E + */ + private int bip32HeaderP2PKHpub; + + /** + * BIP32 Header P2PKH Priv + * + * extracted from /src/chainparams.cpp class + * Concatenate the 4 values in base58Prefixes[EXT_SECRET_KEY] + * base58Prefixes[EXT_SECRET_KEY] = {0x04, 0x88, 0xAD, 0xE4} = 0x0488ADE4 + */ + private int bip32HeaderP2PKHpriv; + + /** + * Address Header (Testnet) + * + * extracted from /src/chainparams.cpp class + * base58Prefixes[PUBKEY_ADDRESS] from Testnet + */ + private int addressHeaderTestnet; + + /** + * BIP32 Header P2PKH Pub (Testnet) + * + * extracted from /src/chainparams.cpp class + * Concatenate the 4 values in base58Prefixes[EXT_PUBLIC_KEY] + * base58Prefixes[EXT_PUBLIC_KEY] = {0x04, 0x88, 0xB2, 0x1E} = 0x0488B21E + */ + private int bip32HeaderP2PKHpubTestnet; + + /** + * BIP32 Header P2PKH Priv (Testnet) + * + * extracted from /src/chainparams.cpp class + * Concatenate the 4 values in base58Prefixes[EXT_SECRET_KEY] + * base58Prefixes[EXT_SECRET_KEY] = {0x04, 0x88, 0xAD, 0xE4} = 0x0488ADE4 + */ + private int bip32HeaderP2PKHprivTestnet; + + /** + * Id + * + * "org.litecoin.production" for LTC + * I'm guessing this just has to match others for trading purposes. + */ + private String id; + + /** + * Majority Enforce Block Upgrade + * + * All coins are setting this to 750, except DOGE is setting this to 1500. + */ + private int majorityEnforceBlockUpgrade; + + /** + * Majority Reject Block Outdated + * + * All coins are setting this to 950, except DOGE is setting this to 1900. + */ + private int majorityRejectBlockOutdated; + + /** + * Majority Window + * + * All coins are setting this to 1000, except DOGE is setting this to 2000. + */ + private int majorityWindow; + + /** + * Code + * + * "LITE" for LTC + * Currency code for full unit. + */ + private String code; + + /** + * mCode + * + * "mLITE" for LTC + * Currency code for milli unit. + */ + private String mCode; + + /** + * Base Code + * + * "Liteoshi" for LTC + * Currency code for base unit. + */ + private String baseCode; + + /** + * Min Non Dust Output + * + * 100000 for LTC, web search for minimum transaction fee per kB + */ + private int minNonDustOutput; + + /** + * URI Scheme + * + * uriScheme = "litecoin" for LTC + * Do a web search to find this value. + */ + private String uriScheme; + + /** + * Protocol Version Minimum + * + * 70002 for LTC + * extracted from /src/protocol.h class + */ + private int protocolVersionMinimum; + + /** + * Protocol Version Current + * + * 70003 for LTC + * extracted from /src/protocol.h class + */ + private int protocolVersionCurrent; + + /** + * Has Max Money + * + * false for DOGE, true for BTC and LTC + */ + private boolean hasMaxMoney; + + /** + * Max Money + * + * 84000000 for LTC, 21000000 for BTC + * extracted from src/amount.h class + */ + private long maxMoney; + + /** + * Currency Code + * + * The trading symbol, ie LTC, BTC, DOGE + */ + private String currencyCode; + + /** + * Minimum Order Amount + * + * web search, LTC minimumOrderAmount = 1000000, 0.01 LTC minimum order to avoid dust errors + */ + private long minimumOrderAmount; + + /** + * Fee Per Kb + * + * web search, LTC feePerKb = 10000, 0.0001 LTC per 1000 bytes + */ + private long feePerKb; + + /** + * Network Name + * + * ie Litecoin-MAIN + */ + private String networkName; + + /** + * Fee Ceiling + * + * web search, LTC fee ceiling = 1000L + */ + private long feeCeiling; + + /** + * Extended Public Key + * + * xpub for operations that require wallet watching + */ + private String extendedPublicKey; + + /** + * Send Amount + * + * The amount to send in base units. Also, requires sending fee per byte, receiving address and sender's extended private key. + */ + private long sendAmount; + + /** + * Sending Fee Per Byte + * + * The fee to include on a send request in base units. Also, requires receiving address, sender's extended private key and send amount. + */ + private long sendingFeePerByte; + + /** + * Receiving Address + * + * The receiving address for a send request. Also, requires send amount, sender's extended private key and sending fee per byte. + */ + private String receivingAddress; + + /** + * Extended Private Key + * + * xpriv address for a send request. Also, requires receiving address, send amount and sending fee per byte. + */ + private String extendedPrivateKey; + + /** + * Server Info + * + * For adding, removing, setting current server requests. + */ + private ServerInfo serverInfo; + + /** + * Script Sig + * + * extracted from /src/chainparams.cpp class + * pszTimestamp + * + * transform this value - https://bitcoin.stackexchange.com/questions/13122/scriptsig-coinbase-structure-of-the-genesis-block + * ie LTC = 04ffff001d0104404e592054696d65732030352f4f63742f32303131205374657665204a6f62732c204170706c65e280997320566973696f6e6172792c2044696573206174203536 + * ie DOGE = 04ffff001d0104084e696e746f6e646f + */ + private String scriptSig; + + /** + * Script Hex + * + * extracted from /src/chainparams.cpp class + * genesisOutputScript + * + * ie LTC = 040184710fa689ad5023690c80f3a49c8f13f8d45b8c857fbcbc8bc4a8e4d3eb4b10f4d4604fa08dce601aaf0f470216fe1b51850b4acf21b179c45070ac7b03a9 + * + * this may be the same value as pubKey + */ + private String scriptHex; + + /** + * Reward + * + * extracted from /src/chainparams.cpp class + * CreateGenesisBlock(..., [reward] * COIN) + * + * ie LTC = 50, BTC = 50, DOGE = 88 + */ + private int reward; + + /** + * Genesis Creation Version + */ + private int genesisCreationVersion; + + /** + * Genesis Block Version + */ + private long genesisBlockVersion; + + /** + * Genesis Time + * + * extracted from /src/chainparams.cpp class + * CreateGenesisBlock(nTime, ...) + * + * ie LTC = 1317972665 + */ + private long genesisTime; + + /** + * Difficulty Target + * + * extracted from /src/chainparams.cpp class + * CreateGenesisBlock(genesisTime, nonce, difficultyTarget, 1, reward * COIN); + * + * convert from hex to decimal + * + * ie LTC = 0x1e0ffff0 = 504365040 + */ + private long difficultyTarget; + + /** + * Merkle Hex + */ + private String merkleHex; + + /** + * Nonce + * + * extracted from /src/chainparams.cpp class + * CreateGenesisBlock(genesisTime, nonce, difficultyTarget, 1, reward * COIN); + * + * ie LTC = 2084524493 + */ + private long nonce; + + + public int getTargetTimespan() { + return targetTimespan; + } + + public int getTargetSpacing() { + return targetSpacing; + } + + public long getPacketMagic() { + return packetMagic; + } + + public int getPort() { + return port; + } + + public int getAddressHeader() { + return addressHeader; + } + + public int getP2shHeader() { + return p2shHeader; + } + + public String getSegwitAddressHrp() { + return segwitAddressHrp; + } + + public int getDumpedPrivateKeyHeader() { + return dumpedPrivateKeyHeader; + } + + public int getSubsidyDecreaseBlockCount() { + return subsidyDecreaseBlockCount; + } + + public String getExpectedGenesisHash() { + return expectedGenesisHash; + } + + public String getPubKey() { + return pubKey; + } + + public String[] getDnsSeeds() { + return dnsSeeds; + } + + public int getBip32HeaderP2PKHpub() { + return bip32HeaderP2PKHpub; + } + + public int getBip32HeaderP2PKHpriv() { + return bip32HeaderP2PKHpriv; + } + + public int getAddressHeaderTestnet() { + return addressHeaderTestnet; + } + + public int getBip32HeaderP2PKHpubTestnet() { + return bip32HeaderP2PKHpubTestnet; + } + + public int getBip32HeaderP2PKHprivTestnet() { + return bip32HeaderP2PKHprivTestnet; + } + + public String getId() { + return this.id; + } + + public int getMajorityEnforceBlockUpgrade() { + return this.majorityEnforceBlockUpgrade; + } + + public int getMajorityRejectBlockOutdated() { + return this.majorityRejectBlockOutdated; + } + + public int getMajorityWindow() { + return this.majorityWindow; + } + + public String getCode() { + return this.code; + } + + public String getmCode() { + return this.mCode; + } + + public String getBaseCode() { + return this.baseCode; + } + + public int getMinNonDustOutput() { + return this.minNonDustOutput; + } + + public String getUriScheme() { + return this.uriScheme; + } + + public int getProtocolVersionMinimum() { + return this.protocolVersionMinimum; + } + + public int getProtocolVersionCurrent() { + return this.protocolVersionCurrent; + } + + public boolean isHasMaxMoney() { + return this.hasMaxMoney; + } + + public long getMaxMoney() { + return this.maxMoney; + } + + public String getCurrencyCode() { + return this.currencyCode; + } + + public long getMinimumOrderAmount() { + return this.minimumOrderAmount; + } + + public long getFeePerKb() { + return this.feePerKb; + } + + public String getNetworkName() { + return this.networkName; + } + + public long getFeeCeiling() { + return this.feeCeiling; + } + + public String getExtendedPublicKey() { + return this.extendedPublicKey; + } + + public long getSendAmount() { + return this.sendAmount; + } + + public long getSendingFeePerByte() { + return this.sendingFeePerByte; + } + + public String getReceivingAddress() { + return this.receivingAddress; + } + + public String getExtendedPrivateKey() { + return this.extendedPrivateKey; + } + + public ServerInfo getServerInfo() { + return this.serverInfo; + } + + public String getScriptSig() { + return this.scriptSig; + } + + public String getScriptHex() { + return this.scriptHex; + } + + public int getReward() { + return this.reward; + } + + public int getGenesisCreationVersion() { + return this.genesisCreationVersion; + } + + public long getGenesisBlockVersion() { + return this.genesisBlockVersion; + } + + public long getGenesisTime() { + return this.genesisTime; + } + + public long getDifficultyTarget() { + return this.difficultyTarget; + } + + public String getMerkleHex() { + return this.merkleHex; + } + + public long getNonce() { + return this.nonce; + } + + @Override + public String toString() { + return "BitcoinyTBDRequest{" + + "targetTimespan=" + targetTimespan + + ", targetSpacing=" + targetSpacing + + ", packetMagic=" + packetMagic + + ", port=" + port + + ", addressHeader=" + addressHeader + + ", p2shHeader=" + p2shHeader + + ", segwitAddressHrp='" + segwitAddressHrp + '\'' + + ", dumpedPrivateKeyHeader=" + dumpedPrivateKeyHeader + + ", subsidyDecreaseBlockCount=" + subsidyDecreaseBlockCount + + ", expectedGenesisHash='" + expectedGenesisHash + '\'' + + ", pubKey='" + pubKey + '\'' + + ", dnsSeeds=" + Arrays.toString(dnsSeeds) + + ", bip32HeaderP2PKHpub=" + bip32HeaderP2PKHpub + + ", bip32HeaderP2PKHpriv=" + bip32HeaderP2PKHpriv + + ", addressHeaderTestnet=" + addressHeaderTestnet + + ", bip32HeaderP2PKHpubTestnet=" + bip32HeaderP2PKHpubTestnet + + ", bip32HeaderP2PKHprivTestnet=" + bip32HeaderP2PKHprivTestnet + + ", id='" + id + '\'' + + ", majorityEnforceBlockUpgrade=" + majorityEnforceBlockUpgrade + + ", majorityRejectBlockOutdated=" + majorityRejectBlockOutdated + + ", majorityWindow=" + majorityWindow + + ", code='" + code + '\'' + + ", mCode='" + mCode + '\'' + + ", baseCode='" + baseCode + '\'' + + ", minNonDustOutput=" + minNonDustOutput + + ", uriScheme='" + uriScheme + '\'' + + ", protocolVersionMinimum=" + protocolVersionMinimum + + ", protocolVersionCurrent=" + protocolVersionCurrent + + ", hasMaxMoney=" + hasMaxMoney + + ", maxMoney=" + maxMoney + + ", currencyCode='" + currencyCode + '\'' + + ", minimumOrderAmount=" + minimumOrderAmount + + ", feePerKb=" + feePerKb + + ", networkName='" + networkName + '\'' + + ", feeCeiling=" + feeCeiling + + ", extendedPublicKey='" + extendedPublicKey + '\'' + + ", sendAmount=" + sendAmount + + ", sendingFeePerByte=" + sendingFeePerByte + + ", receivingAddress='" + receivingAddress + '\'' + + ", extendedPrivateKey='" + extendedPrivateKey + '\'' + + ", serverInfo=" + serverInfo + + ", scriptSig='" + scriptSig + '\'' + + ", scriptHex='" + scriptHex + '\'' + + ", reward=" + reward + + ", genesisCreationVersion=" + genesisCreationVersion + + ", genesisBlockVersion=" + genesisBlockVersion + + ", genesisTime=" + genesisTime + + ", difficultyTarget=" + difficultyTarget + + ", merkleHex='" + merkleHex + '\'' + + ", nonce=" + nonce + + '}'; + } +} diff --git a/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequests.java b/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequests.java new file mode 100644 index 00000000..e78f951d --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequests.java @@ -0,0 +1,68 @@ +package org.qortal.api.model.crosschain; + +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.XmlElement; +import java.util.List; + +@XmlAccessorType(XmlAccessType.FIELD) +public class TradeBotRespondRequests { + + @Schema(description = "Foreign blockchain private key, e.g. BIP32 'm' key for Bitcoin/Litecoin starting with 'xprv'", + example = "xprv___________________________________________________________________________________________________________") + public String foreignKey; + + @Schema(description = "List of address matches") + @XmlElement(name = "addresses") + public List addresses; + + @Schema(description = "Qortal address for receiving QORT from AT", example = "Qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq") + public String receivingAddress; + + public TradeBotRespondRequests() { + } + + public TradeBotRespondRequests(String foreignKey, List addresses, String receivingAddress) { + this.foreignKey = foreignKey; + this.addresses = addresses; + this.receivingAddress = receivingAddress; + } + + @Schema(description = "Address Match") + // All properties to be converted to JSON via JAX-RS + @XmlAccessorType(XmlAccessType.FIELD) + public static class AddressMatch { + @Schema(description = "AT Address") + public String atAddress; + + @Schema(description = "Receiving Address") + public String receivingAddress; + + // For JAX-RS + protected AddressMatch() { + } + + public AddressMatch(String atAddress, String receivingAddress) { + this.atAddress = atAddress; + this.receivingAddress = receivingAddress; + } + + @Override + public String toString() { + return "AddressMatch{" + + "atAddress='" + atAddress + '\'' + + ", receivingAddress='" + receivingAddress + '\'' + + '}'; + } + } + + @Override + public String toString() { + return "TradeBotRespondRequests{" + + "foreignKey='" + foreignKey + '\'' + + ", addresses=" + addresses + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/AddressesResource.java b/src/main/java/org/qortal/api/resource/AddressesResource.java index 66d8412c..beb73734 100644 --- a/src/main/java/org/qortal/api/resource/AddressesResource.java +++ b/src/main/java/org/qortal/api/resource/AddressesResource.java @@ -20,9 +20,7 @@ import org.qortal.asset.Asset; 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.account.*; import org.qortal.data.network.OnlineAccountData; import org.qortal.data.network.OnlineAccountLevel; import org.qortal.data.transaction.PublicizeTransactionData; @@ -52,6 +50,7 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Path("/addresses") @@ -327,11 +326,8 @@ public class AddressesResource { ) } ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.NON_PRODUCTION, ApiError.REPOSITORY_ISSUE}) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.REPOSITORY_ISSUE}) public String fromPublicKey(@PathParam("publickey") String publicKey58) { - if (Settings.getInstance().isApiRestricted()) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION); - // Decode public key byte[] publicKey; try { @@ -630,4 +626,160 @@ public class AddressesResource { } } -} + @GET + @Path("/sponsorship/{address}") + @Operation( + summary = "Returns sponsorship statistics for an account", + description = "Returns sponsorship statistics for an account, excluding the recipients that get real reward shares", + responses = { + @ApiResponse( + description = "the statistics", + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = SponsorshipReport.class)) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public SponsorshipReport getSponsorshipReport( + @PathParam("address") String address, + @QueryParam(("realRewardShareRecipient")) String[] realRewardShareRecipients) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + SponsorshipReport report = repository.getAccountRepository().getSponsorshipReport(address, realRewardShareRecipients); + // Not found? + if (report == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + return report; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/sponsorship/{address}/sponsor") + @Operation( + summary = "Returns sponsorship statistics for an account's sponsor", + description = "Returns sponsorship statistics for an account's sponsor, excluding the recipients that get real reward shares", + responses = { + @ApiResponse( + description = "the statistics", + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = SponsorshipReport.class)) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public SponsorshipReport getSponsorshipReportForSponsor( + @PathParam("address") String address, + @QueryParam("realRewardShareRecipient") String[] realRewardShareRecipients) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + + // get sponsor + Optional sponsor = repository.getAccountRepository().getSponsor(address); + + // if there is not sponsor, throw error + if(sponsor.isEmpty()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // get report for sponsor + SponsorshipReport report = repository.getAccountRepository().getSponsorshipReport(sponsor.get(), realRewardShareRecipients); + + // Not found? + if (report == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + return report; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/mintership/{address}") + @Operation( + summary = "Returns mintership statistics for an account", + description = "Returns mintership statistics for an account", + responses = { + @ApiResponse( + description = "the statistics", + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = MintershipReport.class)) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public MintershipReport getMintershipReport(@PathParam("address") String address, + @QueryParam("realRewardShareRecipient") String[] realRewardShareRecipients ) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + + // get sponsorship report for minter, fetch a list of one minter + SponsorshipReport report = repository.getAccountRepository().getMintershipReport(address, account -> List.of(account)); + + // Not found? + if (report == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // since the report is for one minter, must get sponsee count separately + int sponseeCount = repository.getAccountRepository().getSponseeAddresses(address, realRewardShareRecipients).size(); + + // since the report is for one minter, must get the first name from a array of names that should be size 1 + String name = report.getNames().length > 0 ? report.getNames()[0] : null; + + // transform sponsorship report to mintership report + MintershipReport mintershipReport + = new MintershipReport( + report.getAddress(), + report.getLevel(), + report.getBlocksMinted(), + report.getAdjustments(), + report.getPenalties(), + report.isTransfer(), + name, + sponseeCount, + report.getAvgBalance(), + report.getArbitraryCount(), + report.getTransferAssetCount(), + report.getTransferPrivsCount(), + report.getSellCount(), + report.getSellAmount(), + report.getBuyCount(), + report.getBuyAmount() + ); + + return mintershipReport; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/levels/{minLevel}") + @Operation( + summary = "Return accounts with levels greater than or equal to input", + responses = { + @ApiResponse( + description = "online accounts", + content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = AddressLevelPairing.class))) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + + public List getAddressLevelPairings(@PathParam("minLevel") int minLevel) { + + try (final Repository repository = RepositoryManager.getRepository()) { + + // get the level address pairings + List pairings = repository.getAccountRepository().getAddressLevelPairings(minLevel); + + return pairings; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } +} \ 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 99fc0020..a6f44373 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -33,9 +33,13 @@ import org.qortal.controller.arbitrary.ArbitraryDataStorageManager; import org.qortal.controller.arbitrary.ArbitraryMetadataManager; import org.qortal.data.account.AccountData; import org.qortal.data.arbitrary.ArbitraryCategoryInfo; +import org.qortal.data.arbitrary.ArbitraryDataIndexDetail; +import org.qortal.data.arbitrary.ArbitraryDataIndexScoreKey; +import org.qortal.data.arbitrary.ArbitraryDataIndexScorecard; import org.qortal.data.arbitrary.ArbitraryResourceData; import org.qortal.data.arbitrary.ArbitraryResourceMetadata; import org.qortal.data.arbitrary.ArbitraryResourceStatus; +import org.qortal.data.arbitrary.IndexCache; import org.qortal.data.naming.NameData; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.TransactionData; @@ -69,8 +73,11 @@ import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; @Path("/arbitrary") @Tag(name = "Arbitrary") @@ -172,6 +179,7 @@ public class ArbitraryResource { @Parameter(description = "Name (searches name field only)") @QueryParam("name") List names, @Parameter(description = "Title (searches title metadata field only)") @QueryParam("title") String title, @Parameter(description = "Description (searches description metadata field only)") @QueryParam("description") String description, + @Parameter(description = "Keyword (searches description metadata field by keywords)") @QueryParam("keywords") List keywords, @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, @@ -212,7 +220,7 @@ public class ArbitraryResource { } List resources = repository.getArbitraryRepository() - .searchArbitraryResources(service, query, identifier, names, title, description, usePrefixOnly, + .searchArbitraryResources(service, query, identifier, names, title, description, keywords, usePrefixOnly, exactMatchNames, defaultRes, mode, minLevel, followedOnly, excludeBlocked, includeMetadata, includeStatus, before, after, limit, offset, reverse); @@ -227,6 +235,49 @@ public class ArbitraryResource { } } + @GET + @Path("/resources/searchsimple") + @Operation( + summary = "Search arbitrary resources available on chain, optionally filtered by service.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceData.class)) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public List searchResourcesSimple( + @QueryParam("service") Service service, + @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 = "Case insensitive (ignore leter case on search)") @QueryParam("caseInsensitive") Boolean caseInsensitive, + @Parameter(description = "Creation date before timestamp") @QueryParam("before") Long before, + @Parameter(description = "Creation date 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()) { + + boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly); + boolean ignoreCase = Boolean.TRUE.equals(caseInsensitive); + + List resources = repository.getArbitraryRepository() + .searchArbitraryResourcesSimple(service, identifier, names, usePrefixOnly, + before, after, limit, offset, reverse, ignoreCase); + + if (resources == null) { + return new ArrayList<>(); + } + + return resources; + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @GET @Path("/resource/status/{service}/{name}") @Operation( @@ -1142,6 +1193,90 @@ public class ArbitraryResource { } } + @GET + @Path("/indices") + @Operation( + summary = "Find matching arbitrary resource indices", + description = "", + responses = { + @ApiResponse( + description = "indices", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = ArbitraryDataIndexScorecard.class + ) + ) + ) + ) + } + ) + public List searchIndices(@QueryParam("terms") String[] terms) { + + List indices = new ArrayList<>(); + + // get index details for each term + for( String term : terms ) { + List details = IndexCache.getInstance().getIndicesByTerm().get(term); + + if( details != null ) { + indices.addAll(details); + } + } + + // sum up the scores for each index with identical attributes + Map scoreForKey + = indices.stream() + .collect( + Collectors.groupingBy( + index -> new ArbitraryDataIndexScoreKey(index.name, index.category, index.link), + Collectors.summingDouble(detail -> 1.0 / detail.rank) + ) + ); + + // create scorecards for each index group and put them in descending order by score + List scorecards + = scoreForKey.entrySet().stream().map( + entry + -> + new ArbitraryDataIndexScorecard( + entry.getValue(), + entry.getKey().name, + entry.getKey().category, + entry.getKey().link) + ) + .sorted(Comparator.comparingDouble(ArbitraryDataIndexScorecard::getScore).reversed()) + .collect(Collectors.toList()); + + return scorecards; + } + + @GET + @Path("/indices/{name}/{idPrefix}") + @Operation( + summary = "Find matching arbitrary resource indices for a registered name and identifier prefix", + description = "", + responses = { + @ApiResponse( + description = "indices", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = ArbitraryDataIndexDetail.class + ) + ) + ) + ) + } + ) + public List searchIndicesByName(@PathParam("name") String name, @PathParam("idPrefix") String idPrefix) { + + return + IndexCache.getInstance().getIndicesByIssuer() + .getOrDefault(name, new ArrayList<>(0)).stream() + .filter( indexDetail -> indexDetail.indexIdentifer.startsWith(idPrefix)) + .collect(Collectors.toList()); + } // Shared methods diff --git a/src/main/java/org/qortal/api/resource/AssetsResource.java b/src/main/java/org/qortal/api/resource/AssetsResource.java index 40e04256..49ed251a 100644 --- a/src/main/java/org/qortal/api/resource/AssetsResource.java +++ b/src/main/java/org/qortal/api/resource/AssetsResource.java @@ -16,9 +16,13 @@ import org.qortal.api.model.AggregatedOrder; import org.qortal.api.model.TradeWithOrderInfo; import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; import org.qortal.asset.Asset; +import org.qortal.controller.hsqldb.HSQLDBBalanceRecorder; import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountBalanceData; import org.qortal.data.account.AccountData; +import org.qortal.data.account.AddressAmountData; +import org.qortal.data.account.BlockHeightRange; +import org.qortal.data.account.BlockHeightRangeAddressAmounts; import org.qortal.data.asset.AssetData; import org.qortal.data.asset.OrderData; import org.qortal.data.asset.RecentTradeData; @@ -33,6 +37,7 @@ import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.*; +import org.qortal.utils.BalanceRecorderUtils; import org.qortal.utils.Base58; import javax.servlet.http.HttpServletRequest; @@ -42,6 +47,7 @@ import javax.ws.rs.core.MediaType; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Path("/assets") @@ -179,6 +185,122 @@ public class AssetsResource { } } + @GET + @Path("/balancedynamicranges") + @Operation( + summary = "Get balance dynamic ranges listed.", + description = ".", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = BlockHeightRange.class + ) + ) + ) + ) + } + ) + public List getBalanceDynamicRanges( + @Parameter(ref = "offset") @QueryParam("offset") Integer offset, + @Parameter(ref = "limit") @QueryParam("limit") Integer limit, + @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { + + Optional recorder = HSQLDBBalanceRecorder.getInstance(); + + if( recorder.isPresent()) { + return recorder.get().getRanges(offset, limit, reverse); + } + else { + return new ArrayList<>(0); + } + } + + @GET + @Path("/balancedynamicrange/{height}") + @Operation( + summary = "Get balance dynamic range for a given height.", + description = ".", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + implementation = BlockHeightRange.class + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.INVALID_CRITERIA, ApiError.INVALID_DATA + }) + public BlockHeightRange getBalanceDynamicRange(@PathParam("height") int height) { + + Optional recorder = HSQLDBBalanceRecorder.getInstance(); + + if( recorder.isPresent()) { + Optional range = recorder.get().getRange(height); + + if( range.isPresent() ) { + return range.get(); + } + else { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + } + else { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + } + } + + @GET + @Path("/balancedynamicamounts/{begin}/{end}") + @Operation( + summary = "Get balance dynamic ranges address amounts listed.", + description = ".", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = AddressAmountData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.INVALID_CRITERIA, ApiError.INVALID_DATA + }) + public List getBalanceDynamicAddressAmounts( + @PathParam("begin") int begin, + @PathParam("end") int end, + @Parameter(ref = "offset") @QueryParam("offset") Integer offset, + @Parameter(ref = "limit") @QueryParam("limit") Integer limit) { + + Optional recorder = HSQLDBBalanceRecorder.getInstance(); + + if( recorder.isPresent()) { + Optional addressAmounts = recorder.get().getAddressAmounts(new BlockHeightRange(begin, end, false)); + + if( addressAmounts.isPresent() ) { + return addressAmounts.get().getAmounts().stream() + .sorted(BalanceRecorderUtils.ADDRESS_AMOUNT_DATA_COMPARATOR.reversed()) + .skip(offset) + .limit(limit) + .collect(Collectors.toList()); + } + else { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + } + else { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + } + } + @GET @Path("/openorders/{assetid}/{otherassetid}") @Operation( diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index 01d8d2ab..0203bafc 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -19,6 +19,8 @@ import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountData; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; +import org.qortal.data.block.DecodedOnlineAccountData; +import org.qortal.data.network.OnlineAccountData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.BlockArchiveReader; import org.qortal.repository.DataException; @@ -27,6 +29,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.Blocks; import org.qortal.utils.Triple; import javax.servlet.http.HttpServletRequest; @@ -45,6 +48,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.List; +import java.util.Set; @Path("/blocks") @Tag(name = "Blocks") @@ -542,6 +546,7 @@ public class BlocksResource { } } + String minterAddress = Account.getRewardShareMintingAddress(repository, blockData.getMinterPublicKey()); int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, blockData.getMinterPublicKey()); if (minterLevel == 0) // This may be unavailable when requesting a trimmed block @@ -554,6 +559,7 @@ public class BlocksResource { BlockMintingInfo blockMintingInfo = new BlockMintingInfo(); blockMintingInfo.minterPublicKey = blockData.getMinterPublicKey(); + blockMintingInfo.minterAddress = minterAddress; blockMintingInfo.minterLevel = minterLevel; blockMintingInfo.onlineAccountsCount = blockData.getOnlineAccountsCount(); blockMintingInfo.maxDistance = new BigDecimal(block.MAX_DISTANCE); @@ -888,4 +894,49 @@ public class BlocksResource { } } -} + @GET + @Path("/onlineaccounts/{height}") + @Operation( + summary = "Get online accounts for block", + description = "Returns the online accounts who submitted signatures for this block", + responses = { + @ApiResponse( + description = "online accounts", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = DecodedOnlineAccountData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.BLOCK_UNKNOWN, ApiError.REPOSITORY_ISSUE + }) + public Set getOnlineAccounts(@PathParam("height") int height) { + + try (final Repository repository = RepositoryManager.getRepository()) { + + // get block from database + BlockData blockData = repository.getBlockRepository().fromHeight(height); + + // if block data is not in the database, then try the archive + if (blockData == null) { + blockData = repository.getBlockArchiveRepository().fromHeight(height); + + // if the block is not in the database or the archive, then the block is unknown + if( blockData == null ) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } + } + + Set onlineAccounts = Blocks.getDecodedOnlineAccountsForBlock(repository, blockData); + + return onlineAccounts; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index 66a2bd46..df2ca399 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -234,17 +234,21 @@ public class ChatResource { } ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public ActiveChats getActiveChats(@PathParam("address") String address, @QueryParam("encoding") Encoding encoding) { + public ActiveChats getActiveChats( + @PathParam("address") String address, + @QueryParam("encoding") Encoding encoding, + @QueryParam("haschatreference") Boolean hasChatReference + ) { 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, encoding); + return repository.getChatRepository().getActiveChats(address, encoding, hasChatReference); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } } - + @POST @Operation( summary = "Build raw, unsigned, CHAT transaction", diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 3f05643d..c8f9ea6b 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -157,7 +157,7 @@ public class CrossChainHtlcResource { htlcStatus.bitcoinP2shAddress = p2shAddress; htlcStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8); - List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddress.toString()); + List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddress.toString(), false); if (p2shBalance > 0L && !fundingOutputs.isEmpty()) { htlcStatus.canRedeem = now >= medianBlockTime * 1000L; @@ -401,7 +401,7 @@ public class CrossChainHtlcResource { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey redeemKey = ECKey.fromPrivate(decodedTradePrivateKey); - List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); + List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA, false); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoiny.getNetworkParameters(), redeemAmount, redeemKey, fundingOutputs, redeemScriptA, decodedSecret, foreignBlockchainReceivingAccountInfo); @@ -664,7 +664,7 @@ public class CrossChainHtlcResource { // ElectrumX coins ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); + List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA, false); // Validate the destination foreign blockchain address Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress); diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 9e411127..3f7acf68 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -10,11 +10,13 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; 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 org.glassfish.jersey.media.multipart.ContentDisposition; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.CrossChainCancelRequest; +import org.qortal.api.model.CrossChainTradeLedgerEntry; import org.qortal.api.model.CrossChainTradeSummary; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crosschain.ACCT; @@ -44,14 +46,20 @@ import org.qortal.utils.Base58; import org.qortal.utils.ByteArray; import org.qortal.utils.NTP; +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.HttpHeaders; import javax.ws.rs.core.MediaType; +import java.io.IOException; import java.util.*; import java.util.function.Supplier; import java.util.stream.Collectors; + + @Path("/crosschain") @Tag(name = "Cross-Chain") public class CrossChainResource { @@ -59,6 +67,13 @@ public class CrossChainResource { @Context HttpServletRequest request; + @Context + HttpServletResponse response; + + @Context + ServletContext context; + + @GET @Path("/tradeoffers") @Operation( @@ -255,6 +270,12 @@ public class CrossChainResource { description = "Only return trades that completed on/after this timestamp (milliseconds since epoch)", example = "1597310000000" ) @QueryParam("minimumTimestamp") Long minimumTimestamp, + @Parameter( + description = "Optionally filter by buyer Qortal public key" + ) @QueryParam("buyerPublicKey") String buyerPublicKey58, + @Parameter( + description = "Optionally filter by seller Qortal public key" + ) @QueryParam("sellerPublicKey") String sellerPublicKey58, @Parameter( ref = "limit") @QueryParam("limit") Integer limit, @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { @@ -266,6 +287,10 @@ public class CrossChainResource { if (minimumTimestamp != null && minimumTimestamp <= 0) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // Decode public keys + byte[] buyerPublicKey = decodePublicKey(buyerPublicKey58); + byte[] sellerPublicKey = decodePublicKey(sellerPublicKey58); + final Boolean isFinished = Boolean.TRUE; try (final Repository repository = RepositoryManager.getRepository()) { @@ -296,7 +321,7 @@ public class CrossChainResource { byte[] codeHash = acctInfo.getKey().value; ACCT acct = acctInfo.getValue().get(); - List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, buyerPublicKey, sellerPublicKey, isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumFinalHeight, limit, offset, reverse); @@ -335,6 +360,120 @@ public class CrossChainResource { } } + /** + * Decode Public Key + * + * @param publicKey58 the public key in a string + * + * @return the public key in bytes + */ + private byte[] decodePublicKey(String publicKey58) { + + if( publicKey58 == null ) return null; + if( publicKey58.isEmpty() ) return new byte[0]; + + byte[] publicKey; + try { + publicKey = Base58.decode(publicKey58); + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY, e); + } + + // Correct size for public key? + if (publicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + return publicKey; + } + + @GET + @Path("/ledger/{publicKey}") + @Operation( + summary = "Accounting entries for all trades.", + description = "Returns accounting entries for all completed cross-chain trades", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string", + format = "byte" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public HttpServletResponse getLedgerEntries( + @PathParam("publicKey") String publicKey58, + @Parameter( + description = "Only return trades that completed on/after this timestamp (milliseconds since epoch)", + example = "1597310000000" + ) @QueryParam("minimumTimestamp") Long minimumTimestamp) { + + byte[] publicKey = decodePublicKey(publicKey58); + + // minimumTimestamp (if given) needs to be positive + if (minimumTimestamp != null && minimumTimestamp <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + try (final Repository repository = RepositoryManager.getRepository()) { + Integer minimumFinalHeight = null; + + if (minimumTimestamp != null) { + minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(minimumTimestamp); + // If not found in the block repository it will return either 0 or 1 + if (minimumFinalHeight == 0 || minimumFinalHeight == 1) { + // Try the archive + minimumFinalHeight = repository.getBlockArchiveRepository().getHeightFromTimestamp(minimumTimestamp); + } + + if (minimumFinalHeight == 0) + // We don't have any blocks since minimumTimestamp, let alone trades, so nothing to return + return response; + + // height returned from repository is for block BEFORE timestamp + // but we want trades AFTER timestamp so bump height accordingly + minimumFinalHeight++; + } + + List crossChainTradeLedgerEntries = new ArrayList<>(); + + Map> acctsByCodeHash = SupportedBlockchain.getAcctMap(); + + // collect ledger entries for each ACCT + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + // collect buys and sells + CrossChainUtils.collectLedgerEntries(publicKey, repository, minimumFinalHeight, crossChainTradeLedgerEntries, codeHash, acct, true); + CrossChainUtils.collectLedgerEntries(publicKey, repository, minimumFinalHeight, crossChainTradeLedgerEntries, codeHash, acct, false); + } + + crossChainTradeLedgerEntries.sort((a, b) -> Longs.compare(a.getTradeTimestamp(), b.getTradeTimestamp())); + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("text/csv"); + response.setHeader( + HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition + .type("attachment") + .fileName(CrossChainUtils.createLedgerFileName(Crypto.toAddress(publicKey))) + .build() + .toString() + ); + + CrossChainUtils.writeToLedger( response.getWriter(), crossChainTradeLedgerEntries); + + return response; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (IOException e) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return response; + } + } + @GET @Path("/price/{blockchain}") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java index 5a50222a..de646a9f 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java @@ -17,13 +17,16 @@ import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.crosschain.TradeBotCreateRequest; import org.qortal.api.model.crosschain.TradeBotRespondRequest; +import org.qortal.api.model.crosschain.TradeBotRespondRequests; import org.qortal.asset.Asset; import org.qortal.controller.Controller; import org.qortal.controller.tradebot.AcctTradeBot; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crosschain.ACCT; import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.ForeignBlockchain; +import org.qortal.crosschain.PirateChain; import org.qortal.crosschain.SupportedBlockchain; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -42,8 +45,10 @@ import javax.servlet.http.HttpServletRequest; import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; +import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; @Path("/crosschain/tradebot") @@ -187,6 +192,39 @@ public class CrossChainTradeBotResource { public String tradeBotResponder(@HeaderParam(Security.API_KEY_HEADER) String apiKey, TradeBotRespondRequest tradeBotRespondRequest) { Security.checkApiCallAllowed(request); + return createTradeBotResponse(tradeBotRespondRequest); + } + + @POST + @Path("/respondmultiple") + @Operation( + summary = "Respond to multiple trade offers. NOTE: WILL SPEND FUNDS!)", + description = "Start a new trade-bot entry to respond to chosen trade offers. Pirate Chain is not supported and will throw an invalid criteria error.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = TradeBotRespondRequests.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + @SuppressWarnings("deprecation") + @SecurityRequirement(name = "apiKey") + public String tradeBotResponderMultiple(@HeaderParam(Security.API_KEY_HEADER) String apiKey, TradeBotRespondRequests tradeBotRespondRequest) { + Security.checkApiCallAllowed(request); + + return createTradeBotResponseMultiple(tradeBotRespondRequest); + } + + private String createTradeBotResponse(TradeBotRespondRequest tradeBotRespondRequest) { final String atAddress = tradeBotRespondRequest.atAddress; // We prefer foreignKey to deprecated xprv58 @@ -257,6 +295,99 @@ public class CrossChainTradeBotResource { } } + private String createTradeBotResponseMultiple(TradeBotRespondRequests respondRequests) { + try (final Repository repository = RepositoryManager.getRepository()) { + + if (respondRequests.foreignKey == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + List crossChainTradeDataList = new ArrayList<>(respondRequests.addresses.size()); + Optional acct = Optional.empty(); + + for(String atAddress : respondRequests.addresses ) { + + if (atAddress == null || !Crypto.isValidAtAddress(atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (respondRequests.receivingAddress == null || !Crypto.isValidAddress(respondRequests.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L); + if (!Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC); + + // Extract data from cross-chain trading AT + ATData atData = fetchAtDataWithChecking(repository, atAddress); + + // TradeBot uses AT's code hash to map to ACCT + ACCT acctUsingAtData = TradeBot.getInstance().getAcctUsingAtData(atData); + if (acctUsingAtData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + // if the optional is empty, + // then ensure the ACCT blockchain is a Bitcoiny blockchain, but not Pirate Chain and fill the optional + // Even though the Pirate Chain protocol does support multi send, + // the Pirate Chain API we are using does not support multi send + else if( acct.isEmpty() ) { + if( !(acctUsingAtData.getBlockchain() instanceof Bitcoiny) || + acctUsingAtData.getBlockchain() instanceof PirateChain ) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + acct = Optional.of(acctUsingAtData); + } + // if the optional is filled, then ensure it is equal to the AT in this iteration + else if( !acctUsingAtData.getCodeBytesHash().equals(acct.get().getCodeBytesHash()) ) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (!acctUsingAtData.getBlockchain().isValidWalletKey(respondRequests.foreignKey)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + CrossChainTradeData crossChainTradeData = acctUsingAtData.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + 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."); + } + } + + crossChainTradeDataList.add(crossChainTradeData); + } + + AcctTradeBot.ResponseResult result + = TradeBot.getInstance().startResponseMultiple( + repository, + acct.get(), + crossChainTradeDataList, + respondRequests.receivingAddress, + respondRequests.foreignKey, + (Bitcoiny) acct.get().getBlockchain()); + + switch (result) { + case OK: + return "true"; + + case BALANCE_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + case NETWORK_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + default: + return "false"; + } + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); + } + } + @DELETE @Operation( summary = "Delete completed trade", diff --git a/src/main/java/org/qortal/api/resource/CrossChainUtils.java b/src/main/java/org/qortal/api/resource/CrossChainUtils.java index d1453bda..ddd1d2d6 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainUtils.java +++ b/src/main/java/org/qortal/api/resource/CrossChainUtils.java @@ -1,5 +1,6 @@ package org.qortal.api.resource; +import com.google.common.primitives.Bytes; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bitcoinj.core.Address; @@ -7,19 +8,38 @@ import org.bitcoinj.core.Coin; import org.bitcoinj.script.Script; import org.bitcoinj.script.ScriptBuilder; +import org.bouncycastle.util.Strings; +import org.json.simple.JSONObject; +import org.qortal.api.model.CrossChainTradeLedgerEntry; +import org.qortal.api.model.crosschain.BitcoinyTBDRequest; import org.qortal.crosschain.*; import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.*; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.utils.Amounts; +import org.qortal.utils.BitTwiddling; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.Writer; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.*; import java.util.stream.Collectors; public class CrossChainUtils { + public static final String QORT_CURRENCY_CODE = "QORT"; private static final Logger LOGGER = LogManager.getLogger(CrossChainUtils.class); public static final String CORE_API_CALL = "Core API Call"; + public static final String QORTAL_EXCHANGE_LABEL = "Qortal"; public static ServerConfigurationInfo buildServerConfigurationInfo(Bitcoiny blockchain) { @@ -545,4 +565,210 @@ public class CrossChainUtils { server.getConnectionType().toString(), false); } + + /** + * Get Bitcoiny TBD (To Be Determined) + * + * @param bitcoinyTBDRequest the parameters for the Bitcoiny TBD + * @return the Bitcoiny TBD + * @throws DataException + */ + public static BitcoinyTBD getBitcoinyTBD(BitcoinyTBDRequest bitcoinyTBDRequest) throws DataException { + + try { + DeterminedNetworkParams networkParams = new DeterminedNetworkParams(bitcoinyTBDRequest); + + BitcoinyTBD bitcoinyTBD + = BitcoinyTBD.getInstance(bitcoinyTBDRequest.getCode()) + .orElse(BitcoinyTBD.buildInstance( + bitcoinyTBDRequest, + networkParams) + ); + + return bitcoinyTBD; + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + } + + return null; + } + + /** + * Get Version Decimal + * + * @param jsonObject the JSON object with the version attribute + * @param attribute the attribute that hold the version value + * @return the version as a decimal number, discarding + * @throws NumberFormatException + */ + public static double getVersionDecimal(JSONObject jsonObject, String attribute) throws NumberFormatException { + String versionString = (String) jsonObject.get(attribute); + return Double.parseDouble(reduceDelimeters(versionString, 1, '.')); + } + + /** + * Reduce Delimeters + * + * @param value the raw string + * @param max the max number of the delimeter + * @param delimeter the delimeter + * @return the processed value with the max number of delimeters + */ + public static String reduceDelimeters(String value, int max, char delimeter) { + + if( max < 1 ) return value; + + String[] splits = Strings.split(value, delimeter); + + StringBuffer buffer = new StringBuffer(splits[0]); + + int limit = Math.min(max + 1, splits.length); + + for( int index = 1; index < limit; index++) { + buffer.append(delimeter); + buffer.append(splits[index]); + } + + return buffer.toString(); + } + + /** Returns + + + /** + * Build Offer Message + * + * @param partnerBitcoinPKH + * @param hashOfSecretA + * @param lockTimeA + * @return 'offer' MESSAGE payload for trade partner to send to AT creator's trade address + */ + public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); + } + + /** + * Write To Ledger + * + * @param writer the writer to the ledger + * @param entries the entries to write to the ledger + * + * @throws IOException + */ + public static void writeToLedger(Writer writer, List entries) throws IOException { + + BufferedWriter bufferedWriter = new BufferedWriter(writer); + + StringJoiner header = new StringJoiner(","); + header.add("Market"); + header.add("Currency"); + header.add("Quantity"); + header.add("Commission Paid"); + header.add("Commission Currency"); + header.add("Total Price"); + header.add("Date Time"); + header.add("Exchange"); + + bufferedWriter.append(header.toString()); + + DateFormat dateFormatter = new SimpleDateFormat("yyyyMMdd HH:mm"); + dateFormatter.setTimeZone(TimeZone.getTimeZone("UTC")); + + for( CrossChainTradeLedgerEntry entry : entries ) { + StringJoiner joiner = new StringJoiner(","); + + joiner.add(entry.getMarket()); + joiner.add(entry.getCurrency()); + joiner.add(String.valueOf(Amounts.prettyAmount(entry.getQuantity()))); + joiner.add(String.valueOf(Amounts.prettyAmount(entry.getFeeAmount()))); + joiner.add(entry.getFeeCurrency()); + joiner.add(String.valueOf(Amounts.prettyAmount(entry.getTotalPrice()))); + joiner.add(dateFormatter.format(new Date(entry.getTradeTimestamp()))); + joiner.add(QORTAL_EXCHANGE_LABEL); + + bufferedWriter.newLine(); + bufferedWriter.append(joiner.toString()); + } + + bufferedWriter.newLine(); + bufferedWriter.flush(); + } + + /** + * Create Ledger File Name + * + * Create a file name the includes timestamp and address. + * + * @param address the address + * + * @return the file name created + */ + public static String createLedgerFileName(String address) { + DateFormat dateFormatter = new SimpleDateFormat("yyyyMMddHHmmss"); + String fileName = "ledger-" + address + "-" + dateFormatter.format(new Date()); + return fileName; + } + + /** + * Collect Ledger Entries + * + * @param publicKey the public key for the ledger entries, buy and sell + * @param repository the data repository + * @param minimumFinalHeight the minimum block height for entries to be collected + * @param entries the ledger entries to add to + * @param codeHash code hash for the entry blockchain + * @param acct the ACCT for the entry blockchain + * @param isBuy true collecting entries for a buy, otherwise false + * + * @throws DataException + */ + public static void collectLedgerEntries( + byte[] publicKey, + Repository repository, + Integer minimumFinalHeight, + List entries, + byte[] codeHash, + ACCT acct, + boolean isBuy) throws DataException { + + // get all the final AT states for the code hash (foreign coin) + List atStates + = repository.getATRepository().getMatchingFinalATStates( + codeHash, + isBuy ? publicKey : null, + !isBuy ? publicKey : null, + Boolean.TRUE, acct.getModeByteOffset(), + (long) AcctMode.REDEEMED.value, + minimumFinalHeight, + null, null, false + ); + + String foreignBlockchainCurrencyCode = acct.getBlockchain().getCurrencyCode(); + + // for each trade, build ledger entry, collect ledger entry + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + + // We also need block timestamp for use as trade timestamp + long localTimestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); + + if (localTimestamp == 0) { + // Try the archive + localTimestamp = repository.getBlockArchiveRepository().getTimestampFromHeight(atState.getHeight()); + } + + CrossChainTradeLedgerEntry ledgerEntry + = new CrossChainTradeLedgerEntry( + isBuy ? QORT_CURRENCY_CODE : foreignBlockchainCurrencyCode, + isBuy ? foreignBlockchainCurrencyCode : QORT_CURRENCY_CODE, + isBuy ? crossChainTradeData.qortAmount : crossChainTradeData.expectedForeignAmount, + 0, + foreignBlockchainCurrencyCode, + isBuy ? crossChainTradeData.expectedForeignAmount : crossChainTradeData.qortAmount, + localTimestamp); + + entries.add(ledgerEntry); + } + } } \ No newline at end of file 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 837288e5..439904eb 100644 --- a/src/main/java/org/qortal/api/restricted/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java @@ -32,13 +32,16 @@ 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.data.system.DbConnectionInfo; import org.qortal.network.Network; import org.qortal.network.Peer; import org.qortal.network.PeerAddress; +import org.qortal.repository.ReindexManager; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; +import org.qortal.data.system.SystemInfo; import org.qortal.utils.Base58; import org.qortal.utils.NTP; @@ -51,6 +54,7 @@ import java.net.InetSocketAddress; import java.net.UnknownHostException; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -458,7 +462,7 @@ public class AdminResource { // Qortal: check reward-share's minting account is still allowed to mint Account rewardShareMintingAccount = new Account(repository, rewardShareData.getMinter()); - if (!rewardShareMintingAccount.canMint()) + if (!rewardShareMintingAccount.canMint(false)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.CANNOT_MINT); MintingAccountData mintingAccountData = new MintingAccountData(mintingAccount.getPrivateKey(), mintingAccount.getPublicKey()); @@ -894,6 +898,50 @@ public class AdminResource { } } + @POST + @Path("/repository/reindex") + @Operation( + summary = "Reindex repository", + description = "Rebuilds all transactions and balances from archived blocks. Warning: takes around 1 week, and the core will not function normally during this time. If 'false' is returned, the database may be left in an inconsistent state, requiring another reindex or a bootstrap to correct it.", + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE, ApiError.BLOCKCHAIN_NEEDS_SYNC}) + @SecurityRequirement(name = "apiKey") + public String reindex(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { + Security.checkApiCallAllowed(request); + + if (Synchronizer.getInstance().isSynchronizing()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC); + + try { + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + ReindexManager reindexManager = new ReindexManager(); + reindexManager.reindex(); + return "true"; + + } catch (DataException e) { + LOGGER.info("DataException when reindexing: {}", e.getMessage()); + + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // We couldn't lock blockchain to perform reindex + return "false"; + } + + return "false"; + } + @DELETE @Path("/repository") @Operation( @@ -966,8 +1014,6 @@ public class AdminResource { } } - - @POST @Path("/apikey/generate") @Operation( @@ -1021,4 +1067,50 @@ public class AdminResource { return "true"; } -} + @GET + @Path("/systeminfo") + @Operation( + summary = "System Information", + description = "System memory usage and available processors.", + responses = { + @ApiResponse( + description = "memory usage and available processors", + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = SystemInfo.class)) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public SystemInfo getSystemInformation() { + + SystemInfo info + = new SystemInfo( + Runtime.getRuntime().freeMemory(), + Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(), + Runtime.getRuntime().totalMemory(), + Runtime.getRuntime().maxMemory(), + Runtime.getRuntime().availableProcessors()); + + return info; + } + + @GET + @Path("/dbstates") + @Operation( + summary = "Get DB States", + description = "Get DB States", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = DbConnectionInfo.class))) + ) + } + ) + public List getDbConnectionsStates() { + + try { + return Controller.REPOSITORY_FACTORY.getDbConnectionsStates(); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + return new ArrayList<>(0); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java index b92fb19f..ca3ef2b3 100644 --- a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java @@ -77,7 +77,9 @@ public class ActiveChatsWebSocket extends ApiWebSocket { } try (final Repository repository = RepositoryManager.getRepository()) { - ActiveChats activeChats = repository.getChatRepository().getActiveChats(ourAddress, getTargetEncoding(session)); + Boolean hasChatReference = getHasChatReference(session); + + ActiveChats activeChats = repository.getChatRepository().getActiveChats(ourAddress, getTargetEncoding(session), hasChatReference); StringWriter stringWriter = new StringWriter(); @@ -103,4 +105,20 @@ public class ActiveChatsWebSocket extends ApiWebSocket { return Encoding.valueOf(encoding); } + private Boolean getHasChatReference(Session session) { + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + List hasChatReferenceList = queryParams.get("haschatreference"); + + // Return null if not specified + if (hasChatReferenceList != null && hasChatReferenceList.size() == 1) { + String value = hasChatReferenceList.get(0).toLowerCase(); + if (value.equals("true")) { + return true; + } else if (value.equals("false")) { + return false; + } + } + return null; // Ignored if not present + } + } diff --git a/src/main/java/org/qortal/api/websocket/DataMonitorSocket.java b/src/main/java/org/qortal/api/websocket/DataMonitorSocket.java new file mode 100644 index 00000000..a93bf2ed --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/DataMonitorSocket.java @@ -0,0 +1,102 @@ +package org.qortal.api.websocket; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WebSocketException; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.qortal.api.ApiError; +import org.qortal.controller.Controller; +import org.qortal.data.arbitrary.DataMonitorInfo; +import org.qortal.event.DataMonitorEvent; +import org.qortal.event.Event; +import org.qortal.event.EventBus; +import org.qortal.event.Listener; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.Base58; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.List; + +@WebSocket +@SuppressWarnings("serial") +public class DataMonitorSocket extends ApiWebSocket implements Listener { + + private static final Logger LOGGER = LogManager.getLogger(DataMonitorSocket.class); + + @Override + public void configure(WebSocketServletFactory factory) { + LOGGER.info("configure"); + + factory.register(DataMonitorSocket.class); + + EventBus.INSTANCE.addListener(this); + } + + @Override + public void listen(Event event) { + if (!(event instanceof DataMonitorEvent)) + return; + + DataMonitorEvent dataMonitorEvent = (DataMonitorEvent) event; + + for (Session session : getSessions()) + sendDataEventSummary(session, buildInfo(dataMonitorEvent)); + } + + private DataMonitorInfo buildInfo(DataMonitorEvent dataMonitorEvent) { + + return new DataMonitorInfo( + dataMonitorEvent.getTimestamp(), + dataMonitorEvent.getIdentifier(), + dataMonitorEvent.getName(), + dataMonitorEvent.getService(), + dataMonitorEvent.getDescription(), + dataMonitorEvent.getTransactionTimestamp(), + dataMonitorEvent.getLatestPutTimestamp() + ); + } + + @OnWebSocketConnect + @Override + public void onWebSocketConnect(Session session) { + super.onWebSocketConnect(session); + } + + @OnWebSocketClose + @Override + public void onWebSocketClose(Session session, int statusCode, String reason) { + super.onWebSocketClose(session, statusCode, reason); + } + + @OnWebSocketError + public void onWebSocketError(Session session, Throwable throwable) { + /* We ignore errors for now, but method here to silence log spam */ + } + + @OnWebSocketMessage + public void onWebSocketMessage(Session session, String message) { + LOGGER.info("onWebSocketMessage: message = " + message); + } + + private void sendDataEventSummary(Session session, DataMonitorInfo dataMonitorInfo) { + StringWriter stringWriter = new StringWriter(); + + try { + marshall(stringWriter, dataMonitorInfo); + + session.getRemote().sendStringByFuture(stringWriter.toString()); + } catch (IOException | WebSocketException e) { + // No output this time + } + } + +} diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index 96257f4a..911cf188 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -98,7 +98,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { byte[] codeHash = acctInfo.getKey().value; ACCT acct = acctInfo.getValue().get(); - List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, null, null, isFinished, dataByteOffset, expectedValue, minimumFinalHeight, null, null, null); @@ -259,7 +259,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { ACCT acct = acctInfo.getValue().get(); Integer dataByteOffset = acct.getModeByteOffset(); - List initialAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + List initialAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash, null, null, isFinished, dataByteOffset, expectedValue, minimumFinalHeight, null, null, null); @@ -298,7 +298,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { byte[] codeHash = acctInfo.getKey().value; ACCT acct = acctInfo.getValue().get(); - List historicAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + List historicAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash, null, null, isFinished, dataByteOffset, expectedValue, minimumFinalHeight, null, null, null); diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index 78a9ee86..6d7e0e23 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -439,7 +439,15 @@ public class ArbitraryDataReader { // Ensure the complete hash matches the joined chunks if (!Arrays.equals(arbitraryDataFile.digest(), transactionData.getData())) { // Delete the invalid file - arbitraryDataFile.delete(); + LOGGER.info("Deleting invalid file: path = " + arbitraryDataFile.getFilePath()); + + if( arbitraryDataFile.delete() ) { + LOGGER.info("Deleted invalid file successfully: path = " + arbitraryDataFile.getFilePath()); + } + else { + LOGGER.warn("Could not delete invalid file: path = " + arbitraryDataFile.getFilePath()); + } + throw new DataException("Unable to validate complete file hash"); } } diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java index 5200b5e2..eb51e8a4 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java @@ -168,7 +168,7 @@ public class ArbitraryDataRenderer { byte[] data = Files.readAllBytes(filePath); // TODO: limit file size that can be read into memory 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.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; font-src 'self' data:; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' wss:;"); response.setContentType(context.getMimeType(filename)); response.setContentLength(htmlParser.getData().length); response.getOutputStream().write(htmlParser.getData()); diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 02a513fd..fccbb535 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -167,7 +167,7 @@ public enum Service { 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), + MAIL_PRIVATE(1901, true, 5*1024*1024L, true, true, null), MESSAGE(1910, true, 1024*1024L, true, false, null), MESSAGE_PRIVATE(1911, true, 1024*1024L, true, true, null); diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index bacff5b4..67e6dd43 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -23,12 +23,12 @@ import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.block.BlockTransactionData; +import org.qortal.data.group.GroupAdminData; import org.qortal.data.network.OnlineAccountData; import org.qortal.data.transaction.TransactionData; -import org.qortal.repository.ATRepository; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.TransactionRepository; +import org.qortal.group.Group; +import org.qortal.repository.*; +import org.qortal.settings.Settings; import org.qortal.transaction.AtTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.ApprovalStatus; @@ -39,6 +39,7 @@ import org.qortal.transform.block.BlockTransformer; import org.qortal.transform.transaction.TransactionTransformer; import org.qortal.utils.Amounts; import org.qortal.utils.Base58; +import org.qortal.utils.Groups; import org.qortal.utils.NTP; import java.io.ByteArrayOutputStream; @@ -104,6 +105,7 @@ public class Block { protected Repository repository; protected BlockData blockData; protected PublicKeyAccount minter; + boolean isTestnet = Settings.getInstance().isTestNet(); // Other properties private static final Logger LOGGER = LogManager.getLogger(Block.class); @@ -142,11 +144,14 @@ public class Block { private final Account mintingAccount; private final AccountData mintingAccountData; private final boolean isMinterFounder; + private final boolean isMinterMember; private final Account recipientAccount; private final AccountData recipientAccountData; - ExpandedAccount(Repository repository, RewardShareData rewardShareData) throws DataException { + final BlockChain blockChain = BlockChain.getInstance(); + + ExpandedAccount(Repository repository, RewardShareData rewardShareData, int blockHeight) throws DataException { this.rewardShareData = rewardShareData; this.sharePercent = this.rewardShareData.getSharePercent(); @@ -155,6 +160,12 @@ public class Block { this.isMinterFounder = Account.isFounder(mintingAccountData.getFlags()); this.isRecipientAlsoMinter = this.rewardShareData.getRecipient().equals(this.mintingAccount.getAddress()); + this.isMinterMember + = Groups.memberExistsInAnyGroup( + repository.getGroupRepository(), + Groups.getGroupIdsToMint(BlockChain.getInstance(), blockHeight), + this.mintingAccount.getAddress() + ); if (this.isRecipientAlsoMinter) { // Self-share: minter is also recipient @@ -167,6 +178,19 @@ public class Block { } } + /** + * Get Effective Minting Level + * + * @return the effective minting level, if a data exception is thrown, it catches the exception and returns a zero + */ + public int getEffectiveMintingLevel() { + try { + return this.mintingAccount.getEffectiveMintingLevel(); + } catch (DataException e) { + return 0; + } + } + public Account getMintingAccount() { return this.mintingAccount; } @@ -179,19 +203,23 @@ public class Block { *

* This is a method, not a final variable, because account's level can change between construction and call, * e.g. during Block.process() where account levels are bumped right before Block.distributeBlockReward(). - * + * * @return account-level share "bin" from blockchain config, or null if founder / none found */ public AccountLevelShareBin getShareBin(int blockHeight) { - if (this.isMinterFounder) + if (this.isMinterFounder && blockHeight < BlockChain.getInstance().getAdminsReplaceFoundersHeight()) return null; final int accountLevel = this.mintingAccountData.getLevel(); if (accountLevel <= 0) return null; // level 0 isn't included in any share bins + if (blockHeight >= blockChain.getFixBatchRewardHeight()) { + if (!this.isMinterMember) + return null; // not member of minter group isn't included in any share bins + } + // Select the correct set of share bins based on block height - final BlockChain blockChain = BlockChain.getInstance(); final AccountLevelShareBin[] shareBinsByLevel = (blockHeight >= blockChain.getSharesByLevelV2Height()) ? blockChain.getShareBinsByAccountLevelV2() : blockChain.getShareBinsByAccountLevelV1(); @@ -260,7 +288,7 @@ public class Block { * Constructs new Block without loading transactions and AT states. *

* Transactions and AT states are loaded on first call to getTransactions() or getATStates() respectively. - * + * * @param repository * @param blockData */ @@ -331,7 +359,7 @@ public class Block { /** * Constructs new Block with empty transaction list, using passed minter account. - * + * * @param repository * @param blockData * @param minter @@ -349,7 +377,7 @@ public class Block { * This constructor typically used when minting a new block. *

* Note that CIYAM ATs will be executed and AT-Transactions prepended to this block, along with AT state data and fees. - * + * * @param repository * @param parentBlockData * @param minter @@ -375,7 +403,7 @@ public class Block { byte[] encodedOnlineAccounts = new byte[0]; int onlineAccountsCount = 0; byte[] onlineAccountsSignatures = null; - + if (isBatchRewardDistributionBlock(height)) { // Batch reward distribution block - copy online accounts from recent block with highest online accounts count @@ -396,7 +424,9 @@ public class Block { onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); // After feature trigger, remove any online accounts that are level 0 - if (height >= BlockChain.getInstance().getOnlineAccountMinterLevelValidationHeight()) { + // but only if they are before the ignore level feature trigger + if (height < BlockChain.getInstance().getIgnoreLevelForRewardShareHeight() && + height >= BlockChain.getInstance().getOnlineAccountMinterLevelValidationHeight()) { onlineAccounts.removeIf(a -> { try { return Account.getRewardShareEffectiveMintingLevel(repository, a.getPublicKey()) == 0; @@ -407,6 +437,21 @@ public class Block { }); } + // After feature trigger, remove any online accounts that are not minter group member + if (height >= BlockChain.getInstance().getGroupMemberCheckHeight()) { + onlineAccounts.removeIf(a -> { + try { + List groupIdsToMint = Groups.getGroupIdsToMint(BlockChain.getInstance(), height); + String address = Account.getRewardShareMintingAddress(repository, a.getPublicKey()); + boolean isMinterGroupMember = Groups.memberExistsInAnyGroup(repository.getGroupRepository(), groupIdsToMint, address); + return !isMinterGroupMember; + } 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; @@ -510,7 +555,7 @@ public class Block { * Mints new block using this block as template, but with different minting account. *

* NOTE: uses the same transactions list, AT states, etc. - * + * * @param minter * @return * @throws DataException @@ -596,7 +641,7 @@ public class Block { /** * Return composite block signature (minterSignature + transactionsSignature). - * + * * @return byte[], or null if either component signature is null. */ public byte[] getSignature() { @@ -611,7 +656,7 @@ public class Block { *

* We're starting with version 4 as a nod to being newer than successor Qora, * whose latest block version was 3. - * + * * @return 1, 2, 3 or 4 */ public int getNextBlockVersion() { @@ -625,7 +670,7 @@ public class Block { * Return block's transactions. *

* If the block was loaded from repository then it's possible this method will call the repository to fetch the transactions if not done already. - * + * * @return * @throws DataException */ @@ -659,7 +704,7 @@ public class Block { * If the block was loaded from repository then it's possible this method will call the repository to fetch the AT states if not done already. *

* Note: AT states fetched from repository only contain summary info, not actual data like serialized state data or AT creation timestamps! - * + * * @return * @throws DataException */ @@ -695,7 +740,7 @@ public class Block { *

* Typically called as part of Block.process() or Block.orphan() * so ideally after any calls to Block.isValid(). - * + * * @throws DataException */ public List getExpandedAccounts() throws DataException { @@ -713,10 +758,12 @@ public class Block { List expandedAccounts = new ArrayList<>(); - for (RewardShareData rewardShare : this.cachedOnlineRewardShares) - expandedAccounts.add(new ExpandedAccount(repository, rewardShare)); + for (RewardShareData rewardShare : this.cachedOnlineRewardShares) { + expandedAccounts.add(new ExpandedAccount(repository, rewardShare, this.blockData.getHeight())); + } this.cachedExpandedAccounts = expandedAccounts; + LOGGER.trace(() -> String.format("Online reward-shares after expanded accounts %s", this.cachedOnlineRewardShares)); return this.cachedExpandedAccounts; } @@ -725,7 +772,7 @@ public class Block { /** * Load parent block's data from repository via this block's reference. - * + * * @return parent's BlockData, or null if no parent found * @throws DataException */ @@ -739,7 +786,7 @@ public class Block { /** * Load child block's data from repository via this block's signature. - * + * * @return child's BlockData, or null if no parent found * @throws DataException */ @@ -759,7 +806,7 @@ public class Block { * Used when constructing a new block during minting. *

* Requires block's {@code minter} being a {@code PrivateKeyAccount} so block's transactions signature can be recalculated. - * + * * @param transactionData * @return true if transaction successfully added to block, false otherwise * @throws IllegalStateException @@ -812,7 +859,7 @@ public class Block { * Used when constructing a new block during minting. *

* Requires block's {@code minter} being a {@code PrivateKeyAccount} so block's transactions signature can be recalculated. - * + * * @param transactionData * @throws IllegalStateException * if block's {@code minter} is not a {@code PrivateKeyAccount}. @@ -857,7 +904,7 @@ public class Block { * previous block's minter signature + minter's public key + (encoded) online-accounts data *

* (Previous block's minter signature is extracted from this block's reference). - * + * * @throws IllegalStateException * if block's {@code minter} is not a {@code PrivateKeyAccount}. * @throws RuntimeException @@ -874,7 +921,7 @@ public class Block { * Recalculate block's transactions signature. *

* Requires block's {@code minter} being a {@code PrivateKeyAccount}. - * + * * @throws IllegalStateException * if block's {@code minter} is not a {@code PrivateKeyAccount}. * @throws RuntimeException @@ -996,7 +1043,7 @@ public class Block { * Recalculate block's minter and transactions signatures, thus giving block full signature. *

* Note: Block instance must have been constructed with a PrivateKeyAccount minter or this call will throw an IllegalStateException. - * + * * @throws IllegalStateException * if block's {@code minter} is not a {@code PrivateKeyAccount}. */ @@ -1009,7 +1056,7 @@ public class Block { /** * Returns whether this block's signatures are valid. - * + * * @return true if both minter and transaction signatures are valid, false otherwise */ public boolean isSignatureValid() { @@ -1033,7 +1080,7 @@ public class Block { *

* Used by BlockMinter to check whether it's time to mint a new block, * and also used by Block.isValid for checks (if not a testchain). - * + * * @return ValidationResult.OK if timestamp valid, or some other ValidationResult otherwise. * @throws DataException */ @@ -1122,14 +1169,32 @@ 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(); + // After feature trigger, require all online account minters to be greater than level 0, + // but only if it is before the feature trigger where we ignore level again + if (this.blockData.getHeight() < BlockChain.getInstance().getIgnoreLevelForRewardShareHeight() && + this.getBlockData().getHeight() >= BlockChain.getInstance().getOnlineAccountMinterLevelValidationHeight()) { + List expandedAccounts + = this.getExpandedAccounts().stream() + .filter(expandedAccount -> expandedAccount.isMinterMember) + .collect(Collectors.toList()); + for (ExpandedAccount account : expandedAccounts) { if (account.getMintingAccount().getEffectiveMintingLevel() == 0) return ValidationResult.ONLINE_ACCOUNTS_INVALID; + + if (this.getBlockData().getHeight() >= BlockChain.getInstance().getFixBatchRewardHeight()) { + if (!account.isMinterMember) + return ValidationResult.ONLINE_ACCOUNTS_INVALID; + } } } + else if (this.blockData.getHeight() >= BlockChain.getInstance().getIgnoreLevelForRewardShareHeight()){ + Optional anyInvalidAccount + = this.getExpandedAccounts().stream() + .filter(account -> !account.isMinterMember) + .findAny(); + if( anyInvalidAccount.isPresent() ) 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(); @@ -1213,7 +1278,7 @@ public class Block { *

* Checks block's transactions by testing their validity then processing them.
* Hence uses a repository savepoint during execution. - * + * * @return ValidationResult.OK if block is valid, or some other ValidationResult otherwise. * @throws DataException */ @@ -1256,6 +1321,7 @@ public class Block { // Online Accounts ValidationResult onlineAccountsResult = this.areOnlineAccountsValid(); + LOGGER.trace("Accounts valid = {}", onlineAccountsResult); if (onlineAccountsResult != ValidationResult.OK) return onlineAccountsResult; @@ -1281,13 +1347,20 @@ public class Block { // Create repository savepoint here so we can rollback to it after testing transactions repository.setSavepoint(); - if (this.blockData.getHeight() == 212937) { - // Apply fix for block 212937 but fix will be rolled back before we exit method - Block212937.processFix(this); - } - else if (InvalidNameRegistrationBlocks.isAffectedBlock(this.blockData.getHeight())) { - // Apply fix for affected name registration blocks, but fix will be rolled back before we exit method - InvalidNameRegistrationBlocks.processFix(this); + if (!isTestnet) { + if (this.blockData.getHeight() == 212937) { + // Apply fix for block 212937 but fix will be rolled back before we exit method + Block212937.processFix(this); + } else if (this.blockData.getHeight() == 1333492) { + // Apply fix for block 1333492 but fix will be rolled back before we exit method + Block1333492.processFix(this); + } else if (InvalidNameRegistrationBlocks.isAffectedBlock(this.blockData.getHeight())) { + // Apply fix for affected name registration blocks, but fix will be rolled back before we exit method + InvalidNameRegistrationBlocks.processFix(this); + } else if (InvalidBalanceBlocks.isAffectedBlock(this.blockData.getHeight())) { + // Apply fix for affected balance blocks, but fix will be rolled back before we exit method + InvalidBalanceBlocks.processFix(this); + } } for (Transaction transaction : this.getTransactions()) { @@ -1337,7 +1410,7 @@ public class Block { // Check transaction can even be processed validationResult = transaction.isProcessable(); if (validationResult != Transaction.ValidationResult.OK) { - LOGGER.info(String.format("Error during transaction validation, tx %s: %s", Base58.encode(transactionData.getSignature()), validationResult.name())); + LOGGER.debug(String.format("Error during transaction validation, tx %s: %s", Base58.encode(transactionData.getSignature()), validationResult.name())); return ValidationResult.TRANSACTION_INVALID; } @@ -1377,7 +1450,7 @@ public class Block { *

* NOTE: will execute ATs locally if not already done.
* This is so we have locally-generated AT states for comparison. - * + * * @return OK, or some AT-related validation result * @throws DataException */ @@ -1453,11 +1526,11 @@ public class Block { * Note: this method does not store new AT state data into repository - that is handled by process(). *

* This method is not needed if fetching an existing block from the repository as AT state data will be loaded from repository as well. - * + * * @see #isValid() - * + * * @throws DataException - * + * */ private void executeATs() throws DataException { // We're expecting a lack of AT state data at this point. @@ -1509,7 +1582,7 @@ public class Block { return false; Account mintingAccount = new PublicKeyAccount(this.repository, rewardShareData.getMinterPublicKey()); - return mintingAccount.canMint(); + return mintingAccount.canMint(false); } /** @@ -1529,7 +1602,7 @@ public class Block { /** * Process block, and its transactions, adding them to the blockchain. - * + * * @throws DataException */ public void process() throws DataException { @@ -1538,6 +1611,7 @@ public class Block { this.blockData.setHeight(blockchainHeight + 1); LOGGER.trace(() -> String.format("Processing block %d", this.blockData.getHeight())); + LOGGER.trace(() -> String.format("Online Reward Shares in process %s", this.cachedOnlineRewardShares)); if (this.blockData.getHeight() > 1) { @@ -1550,21 +1624,23 @@ public class Block { processBlockRewards(); } - if (this.blockData.getHeight() == 212937) { - // Apply fix for block 212937 - Block212937.processFix(this); - } - - if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) { - SelfSponsorshipAlgoV1Block.processAccountPenalties(this); - } - - if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV2Height()) { - SelfSponsorshipAlgoV2Block.processAccountPenalties(this); - } - - if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV3Height()) { - SelfSponsorshipAlgoV3Block.processAccountPenalties(this); + if (!isTestnet) { + if (this.blockData.getHeight() == 212937) { + // Apply fix for block 212937 + Block212937.processFix(this); + } else if (this.blockData.getHeight() == 1333492) { + // Apply fix for block 1333492 + Block1333492.processFix(this); + } else if (InvalidBalanceBlocks.isAffectedBlock(this.blockData.getHeight())) { + // Apply fix for affected balance blocks + InvalidBalanceBlocks.processFix(this); + } else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) { + SelfSponsorshipAlgoV1Block.processAccountPenalties(this); + } else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV2Height()) { + SelfSponsorshipAlgoV2Block.processAccountPenalties(this); + } else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV3Height()) { + SelfSponsorshipAlgoV3Block.processAccountPenalties(this); + } } } @@ -1607,7 +1683,17 @@ public class Block { final List cumulativeBlocksByLevel = BlockChain.getInstance().getCumulativeBlocksByLevel(); final int maximumLevel = cumulativeBlocksByLevel.size() - 1; - final List expandedAccounts = this.getExpandedAccounts(); + final List expandedAccounts; + + if (this.getBlockData().getHeight() < BlockChain.getInstance().getFixBatchRewardHeight()) { + expandedAccounts = this.getExpandedAccounts().stream().collect(Collectors.toList()); + } + else { + expandedAccounts + = this.getExpandedAccounts().stream() + .filter(expandedAccount -> expandedAccount.isMinterMember) + .collect(Collectors.toList()); + } Set allUniqueExpandedAccounts = new HashSet<>(); for (ExpandedAccount expandedAccount : expandedAccounts) { @@ -1828,7 +1914,7 @@ public class Block { /** * Removes block from blockchain undoing transactions and adding them to unconfirmed pile. - * + * * @throws DataException */ public void orphan() throws DataException { @@ -1850,23 +1936,25 @@ public class Block { // Invalidate expandedAccounts as they may have changed due to orphaning TRANSFER_PRIVS transactions, etc. this.cachedExpandedAccounts = null; - if (this.blockData.getHeight() == 212937) { - // Revert fix for block 212937 - Block212937.orphanFix(this); + if (!isTestnet) { + if (this.blockData.getHeight() == 212937) { + // Revert fix for block 212937 + Block212937.orphanFix(this); + } else if (this.blockData.getHeight() == 1333492) { + // Revert fix for block 1333492 + Block1333492.orphanFix(this); + } else if (InvalidBalanceBlocks.isAffectedBlock(this.blockData.getHeight())) { + // Revert fix for affected balance blocks + InvalidBalanceBlocks.orphanFix(this); + } else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) { + SelfSponsorshipAlgoV1Block.orphanAccountPenalties(this); + } else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV2Height()) { + SelfSponsorshipAlgoV2Block.orphanAccountPenalties(this); + } else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV3Height()) { + SelfSponsorshipAlgoV3Block.orphanAccountPenalties(this); + } } - if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) { - SelfSponsorshipAlgoV1Block.orphanAccountPenalties(this); - } - - if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV2Height()) { - SelfSponsorshipAlgoV2Block.orphanAccountPenalties(this); - } - - if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV3Height()) { - SelfSponsorshipAlgoV3Block.orphanAccountPenalties(this); - } - // Account levels and block rewards are only processed/orphaned on block reward distribution blocks if (this.isRewardDistributionBlock()) { // Block rewards, including transaction fees, removed after transactions undone @@ -2005,7 +2093,17 @@ public class Block { final List cumulativeBlocksByLevel = BlockChain.getInstance().getCumulativeBlocksByLevel(); final int maximumLevel = cumulativeBlocksByLevel.size() - 1; - final List expandedAccounts = this.getExpandedAccounts(); + final List expandedAccounts; + + if (this.getBlockData().getHeight() < BlockChain.getInstance().getFixBatchRewardHeight()) { + expandedAccounts = this.getExpandedAccounts().stream().collect(Collectors.toList()); + } + else { + expandedAccounts + = this.getExpandedAccounts().stream() + .filter(expandedAccount -> expandedAccount.isMinterMember) + .collect(Collectors.toList()); + } Set allUniqueExpandedAccounts = new HashSet<>(); for (ExpandedAccount expandedAccount : expandedAccounts) { @@ -2200,6 +2298,7 @@ public class Block { List accountBalanceDeltas = balanceChanges.entrySet().stream() .map(entry -> new AccountBalanceData(entry.getKey(), Asset.QORT, entry.getValue())) .collect(Collectors.toList()); + LOGGER.trace("Account Balance Deltas: {}", accountBalanceDeltas); this.repository.getAccountRepository().modifyAssetBalances(accountBalanceDeltas); } @@ -2208,34 +2307,44 @@ public class Block { List rewardCandidates = new ArrayList<>(); // All online accounts - final List expandedAccounts = this.getExpandedAccounts(); + final List expandedAccounts; + + if (this.getBlockData().getHeight() < BlockChain.getInstance().getFixBatchRewardHeight()) { + expandedAccounts = this.getExpandedAccounts().stream().collect(Collectors.toList()); + } + else { + expandedAccounts + = this.getExpandedAccounts().stream() + .filter(expandedAccount -> expandedAccount.isMinterMember) + .collect(Collectors.toList()); + } /* * Distribution rules: - * + * * Distribution is based on the minting account of 'online' reward-shares. - * + * * If ANY founders are online, then they receive the leftover non-distributed reward. * If NO founders are online, then account-level-based rewards are scaled up so 100% of reward is allocated. - * + * * If ANY non-maxxed legacy QORA holders exist then they are always allocated their fixed share (e.g. 20%). - * + * * There has to be either at least one 'online' account for blocks to be minted * so there is always either one account-level-based or founder reward candidate. - * + * * Examples: - * + * * With at least one founder online: * Level 1/2 accounts: 5% * Legacy QORA holders: 20% * Founders: ~75% - * + * * No online founders: * Level 1/2 accounts: 5% * Level 5/6 accounts: 15% * Legacy QORA holders: 20% * Total: 40% - * + * * After scaling account-level-based shares to fill 100%: * Level 1/2 accounts: 20% * Level 5/6 accounts: 60% @@ -2251,7 +2360,6 @@ public class Block { // Select the correct set of share bins based on block height List accountLevelShareBinsForBlock = (this.blockData.getHeight() >= BlockChain.getInstance().getSharesByLevelV2Height()) ? BlockChain.getInstance().getAccountLevelShareBinsV2() : BlockChain.getInstance().getAccountLevelShareBinsV1(); - // Determine reward candidates based on account level // This needs a deep copy, so the shares can be modified when tiers aren't activated yet List accountLevelShareBins = new ArrayList<>(); @@ -2334,7 +2442,7 @@ public class Block { final long qoraHoldersShare = BlockChain.getInstance().getQoraHoldersShareAtHeight(this.blockData.getHeight()); // Perform account-level-based reward scaling if appropriate - if (!haveFounders) { + if (!haveFounders && this.blockData.getHeight() < BlockChain.getInstance().getAdminsReplaceFoundersHeight() ) { // Recalculate distribution ratios based on candidates // Nothing shared? This shouldn't happen @@ -2370,18 +2478,103 @@ public class Block { } // Add founders as reward candidate if appropriate - if (haveFounders) { + if (haveFounders && this.blockData.getHeight() < BlockChain.getInstance().getAdminsReplaceFoundersHeight()) { // Yes: add to reward candidates list BlockRewardDistributor founderDistributor = (distributionAmount, balanceChanges) -> distributeBlockRewardShare(distributionAmount, onlineFounderAccounts, balanceChanges); final long foundersShare = 1_00000000 - totalShares; BlockRewardCandidate rewardCandidate = new BlockRewardCandidate("Founders", foundersShare, founderDistributor); rewardCandidates.add(rewardCandidate); + LOGGER.info("logging foundersShare prior to reward modifications {}",foundersShare); + } + else if (this.blockData.getHeight() >= BlockChain.getInstance().getAdminsReplaceFoundersHeight()) { + try (final Repository repository = RepositoryManager.getRepository()) { + GroupRepository groupRepository = repository.getGroupRepository(); + + List mintingGroupIds = Groups.getGroupIdsToMint(BlockChain.getInstance(), this.blockData.getHeight()); + + // all minter admins + List minterAdmins = Groups.getAllAdmins(groupRepository, mintingGroupIds); + + // all minter admins that are online + List onlineMinterAdminAccounts + = expandedAccounts.stream() + .filter(expandedAccount -> minterAdmins.contains(expandedAccount.getMintingAccount().getAddress())) + .collect(Collectors.toList()); + + long minterAdminShare; + + if( onlineMinterAdminAccounts.isEmpty() ) { + minterAdminShare = 0; + } + else { + BlockRewardDistributor minterAdminDistributor + = (distributionAmount, balanceChanges) + -> + distributeBlockRewardShare(distributionAmount, onlineMinterAdminAccounts, balanceChanges); + + long adminShare = 1_00000000 - totalShares; + LOGGER.info("initial total Shares: {}", totalShares); + LOGGER.info("logging adminShare after hardfork, this is the primary reward that will be split {}", adminShare); + + minterAdminShare = adminShare / 2; + BlockRewardCandidate minterAdminRewardCandidate + = new BlockRewardCandidate("Minter Admins", minterAdminShare, minterAdminDistributor); + rewardCandidates.add(minterAdminRewardCandidate); + + totalShares += minterAdminShare; + } + + LOGGER.info("MINTER ADMIN SHARE: {}",minterAdminShare); + + // all dev admins + List devAdminAddresses + = groupRepository.getGroupAdmins(1).stream() + .map(GroupAdminData::getAdmin) + .collect(Collectors.toList()); + + LOGGER.info("Removing NULL Account Address, Dev Admin Count = {}", devAdminAddresses.size()); + devAdminAddresses.removeIf( address -> Group.NULL_OWNER_ADDRESS.equals(address) ); + LOGGER.info("Removed NULL Account Address, Dev Admin Count = {}", devAdminAddresses.size()); + + BlockRewardDistributor devAdminDistributor + = (distributionAmount, balanceChanges) -> distributeToAccounts(distributionAmount, devAdminAddresses, balanceChanges); + + long devAdminShare = 1_00000000 - totalShares; + LOGGER.info("DEV ADMIN SHARE: {}",devAdminShare); + BlockRewardCandidate devAdminRewardCandidate + = new BlockRewardCandidate("Dev Admins", devAdminShare,devAdminDistributor); + rewardCandidates.add(devAdminRewardCandidate); + } } return rewardCandidates; } + /** + * Distribute To Accounts + * + * Merges distribute shares to a map of distribution shares. + * + * @param distributionAmount the amount to distribute + * @param accountAddressess the addresses to distribute to + * @param balanceChanges the map of distribution shares, this gets appended to + * + * @return the total amount mapped to addresses for distribution + */ + public static long distributeToAccounts(long distributionAmount, List accountAddressess, Map balanceChanges) { + + if( accountAddressess.isEmpty() ) return 0; + + long distibutionShare = distributionAmount / accountAddressess.size(); + + for(String accountAddress : accountAddressess ) { + balanceChanges.merge(accountAddress, distibutionShare, Long::sum); + } + + return distibutionShare * accountAddressess.size(); + } + private static long distributeBlockRewardShare(long distributionAmount, List accounts, Map balanceChanges) { // Collate all expanded accounts by minting account Map> accountsByMinter = new HashMap<>(); @@ -2541,9 +2734,11 @@ public class Block { return; int minterLevel = Account.getRewardShareEffectiveMintingLevel(this.repository, this.getMinter().getPublicKey()); + String minterAddress = Account.getRewardShareMintingAddress(this.repository, this.getMinter().getPublicKey()); LOGGER.debug(String.format("======= BLOCK %d (%.8s) =======", this.getBlockData().getHeight(), Base58.encode(this.getSignature()))); LOGGER.debug(String.format("Timestamp: %d", this.getBlockData().getTimestamp())); + LOGGER.debug(String.format("Minter address: %s", minterAddress)); LOGGER.debug(String.format("Minter level: %d", minterLevel)); LOGGER.debug(String.format("Online accounts: %d", this.getBlockData().getOnlineAccountsCount())); LOGGER.debug(String.format("AT count: %d", this.getBlockData().getATCount())); diff --git a/src/main/java/org/qortal/block/Block1333492.java b/src/main/java/org/qortal/block/Block1333492.java new file mode 100644 index 00000000..ce2d7f99 --- /dev/null +++ b/src/main/java/org/qortal/block/Block1333492.java @@ -0,0 +1,101 @@ +package org.qortal.block; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.persistence.jaxb.JAXBContextFactory; +import org.eclipse.persistence.jaxb.UnmarshallerProperties; +import org.qortal.data.account.AccountBalanceData; +import org.qortal.repository.DataException; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.UnmarshalException; +import javax.xml.bind.Unmarshaller; +import javax.xml.transform.stream.StreamSource; +import java.io.InputStream; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Block 1333492 + *

+ * As described in InvalidBalanceBlocks.java, legacy bugs caused a small drift in account balances. + * This block adjusts any remaining differences between a clean reindex/resync and a recent bootstrap. + *

+ * The block height 1333492 isn't significant - it's simply the height of a recent bootstrap at the + * time of development, so that the account balances could be accessed and compared against the same + * block in a reindexed db. + *

+ * As with InvalidBalanceBlocks, the discrepancies are insignificant, except for a single + * account which has a 3.03 QORT discrepancy. This was due to the account being the first recipient + * of a name sale and encountering an early bug in this area. + *

+ * The total offset for this block is 3.02816514 QORT. + */ +public final class Block1333492 { + + private static final Logger LOGGER = LogManager.getLogger(Block1333492.class); + private static final String ACCOUNT_DELTAS_SOURCE = "block-1333492-deltas.json"; + + private static final List accountDeltas = readAccountDeltas(); + + private Block1333492() { + /* Do not instantiate */ + } + + @SuppressWarnings("unchecked") + private static List readAccountDeltas() { + Unmarshaller unmarshaller; + + try { + // Create JAXB context aware of classes we need to unmarshal + JAXBContext jc = JAXBContextFactory.createContext(new Class[] { + AccountBalanceData.class + }, null); + + // Create unmarshaller + unmarshaller = jc.createUnmarshaller(); + + // Set the unmarshaller media type to JSON + unmarshaller.setProperty(UnmarshallerProperties.MEDIA_TYPE, "application/json"); + + // Tell unmarshaller that there's no JSON root element in the JSON input + unmarshaller.setProperty(UnmarshallerProperties.JSON_INCLUDE_ROOT, false); + } catch (JAXBException e) { + String message = "Failed to setup unmarshaller to read block 1333492 deltas"; + LOGGER.error(message, e); + throw new RuntimeException(message, e); + } + + ClassLoader classLoader = BlockChain.class.getClassLoader(); + InputStream in = classLoader.getResourceAsStream(ACCOUNT_DELTAS_SOURCE); + StreamSource jsonSource = new StreamSource(in); + + try { + // Attempt to unmarshal JSON stream to BlockChain config + return (List) unmarshaller.unmarshal(jsonSource, AccountBalanceData.class).getValue(); + } catch (UnmarshalException e) { + String message = "Failed to parse block 1333492 deltas"; + LOGGER.error(message, e); + throw new RuntimeException(message, e); + } catch (JAXBException e) { + String message = "Unexpected JAXB issue while processing block 1333492 deltas"; + LOGGER.error(message, e); + throw new RuntimeException(message, e); + } + } + + public static void processFix(Block block) throws DataException { + block.repository.getAccountRepository().modifyAssetBalances(accountDeltas); + } + + public static void orphanFix(Block block) throws DataException { + // Create inverse deltas + List inverseDeltas = accountDeltas.stream() + .map(delta -> new AccountBalanceData(delta.getAddress(), delta.getAssetId(), 0 - delta.getBalance())) + .collect(Collectors.toList()); + + block.repository.getAccountRepository().modifyAssetBalances(inverseDeltas); + } + +} diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index dc9dfe4c..bce09aed 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -71,6 +71,7 @@ public class BlockChain { transactionV6Timestamp, disableReferenceTimestamp, increaseOnlineAccountsDifficultyTimestamp, + decreaseOnlineAccountsDifficultyTimestamp, onlineAccountMinterLevelValidationHeight, selfSponsorshipAlgoV1Height, selfSponsorshipAlgoV2Height, @@ -80,7 +81,18 @@ public class BlockChain { arbitraryOptionalFeeTimestamp, unconfirmableRewardSharesHeight, disableTransferPrivsTimestamp, - enableTransferPrivsTimestamp + enableTransferPrivsTimestamp, + cancelSellNameValidationTimestamp, + disableRewardshareHeight, + enableRewardshareHeight, + onlyMintWithNameHeight, + removeOnlyMintWithNameHeight, + groupMemberCheckHeight, + fixBatchRewardHeight, + adminsReplaceFoundersHeight, + nullGroupMembershipHeight, + ignoreLevelForRewardShareHeight, + adminQueryFixHeight } // Custom transaction fees @@ -201,6 +213,13 @@ public class BlockChain { private int maxRewardSharesPerFounderMintingAccount; private int founderEffectiveMintingLevel; + public static class IdsForHeight { + public int height; + public List ids; + } + + private List mintingGroupIds; + /** Minimum time to retain online account signatures (ms) for block validity checks. */ private long onlineAccountSignaturesMinLifetime; @@ -211,6 +230,10 @@ public class BlockChain { * featureTriggers because unit tests need to set this value via Reflection. */ private long onlineAccountsModulusV2Timestamp; + /** Feature trigger timestamp for ONLINE_ACCOUNTS_MODULUS time interval decrease. Can't use + * featureTriggers because unit tests need to set this value via Reflection. */ + private long onlineAccountsModulusV3Timestamp; + /** Snapshot timestamp for self sponsorship algo V1 */ private long selfSponsorshipAlgoV1SnapshotTimestamp; @@ -397,6 +420,9 @@ public class BlockChain { return this.onlineAccountsModulusV2Timestamp; } + public long getOnlineAccountsModulusV3Timestamp() { + return this.onlineAccountsModulusV3Timestamp; + } /* Block reward batching */ public long getBlockRewardBatchStartHeight() { @@ -524,6 +550,10 @@ public class BlockChain { return this.onlineAccountSignaturesMaxLifetime; } + public List getMintingGroupIds() { + return mintingGroupIds; + } + public CiyamAtSettings getCiyamAtSettings() { return this.ciyamAtSettings; } @@ -570,6 +600,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.increaseOnlineAccountsDifficultyTimestamp.name()).longValue(); } + public long getDecreaseOnlineAccountsDifficultyTimestamp() { + return this.featureTriggers.get(FeatureTrigger.decreaseOnlineAccountsDifficultyTimestamp.name()).longValue(); + } + public int getSelfSponsorshipAlgoV1Height() { return this.featureTriggers.get(FeatureTrigger.selfSponsorshipAlgoV1Height.name()).intValue(); } @@ -610,6 +644,50 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.enableTransferPrivsTimestamp.name()).longValue(); } + public long getCancelSellNameValidationTimestamp() { + return this.featureTriggers.get(FeatureTrigger.cancelSellNameValidationTimestamp.name()).longValue(); + } + + public int getDisableRewardshareHeight() { + return this.featureTriggers.get(FeatureTrigger.disableRewardshareHeight.name()).intValue(); + } + + public int getEnableRewardshareHeight() { + return this.featureTriggers.get(FeatureTrigger.enableRewardshareHeight.name()).intValue(); + } + + public int getOnlyMintWithNameHeight() { + return this.featureTriggers.get(FeatureTrigger.onlyMintWithNameHeight.name()).intValue(); + } + + public int getRemoveOnlyMintWithNameHeight() { + return this.featureTriggers.get(FeatureTrigger.removeOnlyMintWithNameHeight.name()).intValue(); + } + + public int getGroupMemberCheckHeight() { + return this.featureTriggers.get(FeatureTrigger.groupMemberCheckHeight.name()).intValue(); + } + + public int getFixBatchRewardHeight() { + return this.featureTriggers.get(FeatureTrigger.fixBatchRewardHeight.name()).intValue(); + } + + public int getAdminsReplaceFoundersHeight() { + return this.featureTriggers.get(FeatureTrigger.adminsReplaceFoundersHeight.name()).intValue(); + } + + public int getNullGroupMembershipHeight() { + return this.featureTriggers.get(FeatureTrigger.nullGroupMembershipHeight.name()).intValue(); + } + + public int getIgnoreLevelForRewardShareHeight() { + return this.featureTriggers.get(FeatureTrigger.ignoreLevelForRewardShareHeight.name()).intValue(); + } + + public int getAdminQueryFixHeight() { + return this.featureTriggers.get(FeatureTrigger.adminQueryFixHeight.name()).intValue(); + } + // More complex getters for aspects that change by height or timestamp public long getRewardAtHeight(int ourHeight) { @@ -805,10 +883,12 @@ public class BlockChain { boolean isLite = Settings.getInstance().isLite(); boolean canBootstrap = Settings.getInstance().getBootstrap(); boolean needsArchiveRebuild = false; + int checkHeight = 0; BlockData chainTip; try (final Repository repository = RepositoryManager.getRepository()) { chainTip = repository.getBlockRepository().getLastBlock(); + checkHeight = repository.getBlockRepository().getBlockchainHeight(); // Ensure archive is (at least partially) intact, and force a bootstrap if it isn't if (!isTopOnly && archiveEnabled && canBootstrap) { @@ -824,6 +904,17 @@ public class BlockChain { } } + if (!canBootstrap) { + if (checkHeight > 2) { + LOGGER.info("Retrieved block 2 from archive. Syncing from genesis block resumed!"); + } else { + needsArchiveRebuild = (repository.getBlockArchiveRepository().fromHeight(2) == null); + if (needsArchiveRebuild) { + LOGGER.info("Couldn't retrieve block 2 from archive. Bootstrapping is disabled. Syncing from genesis block!"); + } + } + } + // 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 @@ -856,11 +947,12 @@ public class BlockChain { // 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())); + if (checkHeight < 3) { + try { + rebuildBlockchain(); + } catch (InterruptedException e) { + throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage())); + } } } @@ -1001,5 +1093,4 @@ public class BlockChain { blockchainLock.unlock(); } } - } diff --git a/src/main/java/org/qortal/block/InvalidBalanceBlocks.java b/src/main/java/org/qortal/block/InvalidBalanceBlocks.java new file mode 100644 index 00000000..03b3e434 --- /dev/null +++ b/src/main/java/org/qortal/block/InvalidBalanceBlocks.java @@ -0,0 +1,134 @@ +package org.qortal.block; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.persistence.jaxb.JAXBContextFactory; +import org.eclipse.persistence.jaxb.UnmarshallerProperties; +import org.qortal.data.account.AccountBalanceData; +import org.qortal.repository.DataException; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.UnmarshalException; +import javax.xml.bind.Unmarshaller; +import javax.xml.transform.stream.StreamSource; +import java.io.InputStream; +import java.util.*; +import java.util.stream.Collectors; + + +/** + * Due to various bugs - which have been fixed - a small amount of balance drift occurred + * in the chainstate of running nodes and bootstraps, when compared with a clean sync from genesis. + * This resulted in a significant number of invalid transactions in the chain history due to + * subtle balance discrepancies. The sum of all discrepancies that resulted in an invalid + * transaction is 0.00198322 QORT, so despite the large quantity of transactions, they + * represent an insignificant amount when summed. + *

+ * This class is responsible for retroactively fixing all the past transactions which + * are invalid due to the balance discrepancies. + */ + + +public final class InvalidBalanceBlocks { + + private static final Logger LOGGER = LogManager.getLogger(InvalidBalanceBlocks.class); + + private static final String ACCOUNT_DELTAS_SOURCE = "invalid-transaction-balance-deltas.json"; + + private static final List accountDeltas = readAccountDeltas(); + private static final List affectedHeights = getAffectedHeights(); + + private InvalidBalanceBlocks() { + /* Do not instantiate */ + } + + @SuppressWarnings("unchecked") + private static List readAccountDeltas() { + Unmarshaller unmarshaller; + + try { + // Create JAXB context aware of classes we need to unmarshal + JAXBContext jc = JAXBContextFactory.createContext(new Class[] { + AccountBalanceData.class + }, null); + + // Create unmarshaller + unmarshaller = jc.createUnmarshaller(); + + // Set the unmarshaller media type to JSON + unmarshaller.setProperty(UnmarshallerProperties.MEDIA_TYPE, "application/json"); + + // Tell unmarshaller that there's no JSON root element in the JSON input + unmarshaller.setProperty(UnmarshallerProperties.JSON_INCLUDE_ROOT, false); + } catch (JAXBException e) { + String message = "Failed to setup unmarshaller to read block 212937 deltas"; + LOGGER.error(message, e); + throw new RuntimeException(message, e); + } + + ClassLoader classLoader = BlockChain.class.getClassLoader(); + InputStream in = classLoader.getResourceAsStream(ACCOUNT_DELTAS_SOURCE); + StreamSource jsonSource = new StreamSource(in); + + try { + // Attempt to unmarshal JSON stream to BlockChain config + return (List) unmarshaller.unmarshal(jsonSource, AccountBalanceData.class).getValue(); + } catch (UnmarshalException e) { + String message = "Failed to parse balance deltas"; + LOGGER.error(message, e); + throw new RuntimeException(message, e); + } catch (JAXBException e) { + String message = "Unexpected JAXB issue while processing balance deltas"; + LOGGER.error(message, e); + throw new RuntimeException(message, e); + } + } + + private static List getAffectedHeights() { + List heights = new ArrayList<>(); + for (AccountBalanceData accountBalanceData : accountDeltas) { + if (!heights.contains(accountBalanceData.getHeight())) { + heights.add(accountBalanceData.getHeight()); + } + } + return heights; + } + + private static List getAccountDeltasAtHeight(int height) { + return accountDeltas.stream().filter(a -> a.getHeight() == height).collect(Collectors.toList()); + } + + public static boolean isAffectedBlock(int height) { + return affectedHeights.contains(Integer.valueOf(height)); + } + + public static void processFix(Block block) throws DataException { + Integer blockHeight = block.getBlockData().getHeight(); + List deltas = getAccountDeltasAtHeight(blockHeight); + if (deltas == null) { + throw new DataException(String.format("Unable to lookup invalid balance data for block height %d", blockHeight)); + } + + block.repository.getAccountRepository().modifyAssetBalances(deltas); + + LOGGER.info("Applied balance patch for block {}", blockHeight); + } + + public static void orphanFix(Block block) throws DataException { + Integer blockHeight = block.getBlockData().getHeight(); + List deltas = getAccountDeltasAtHeight(blockHeight); + if (deltas == null) { + throw new DataException(String.format("Unable to lookup invalid balance data for block height %d", blockHeight)); + } + + // Create inverse delta(s) + for (AccountBalanceData accountBalanceData : deltas) { + AccountBalanceData inverseBalanceData = new AccountBalanceData(accountBalanceData.getAddress(), accountBalanceData.getAssetId(), -accountBalanceData.getBalance()); + block.repository.getAccountRepository().modifyAssetBalances(List.of(inverseBalanceData)); + } + + LOGGER.info("Reverted balance patch for block {}", blockHeight); + } + +} diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 91dd12bb..64024d00 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -64,6 +64,7 @@ public class BlockMinter extends Thread { @Override public void run() { Thread.currentThread().setName("BlockMinter"); + Thread.currentThread().setPriority(MAX_PRIORITY); if (Settings.getInstance().isTopOnly() || Settings.getInstance().isLite()) { // Top only and lite nodes do not sign blocks @@ -96,364 +97,375 @@ public class BlockMinter extends Thread { final boolean isSingleNodeTestnet = Settings.getInstance().isSingleNodeTestnet(); - try (final Repository repository = RepositoryManager.getRepository()) { - // Going to need this a lot... - BlockRepository blockRepository = repository.getBlockRepository(); - - // Flags for tracking change in whether minting is possible, - // so we can notify Controller, and further update SysTray, etc. - boolean isMintingPossible = false; - boolean wasMintingPossible = isMintingPossible; + // Flags for tracking change in whether minting is possible, + // so we can notify Controller, and further update SysTray, etc. + boolean isMintingPossible = false; + boolean wasMintingPossible = isMintingPossible; + try { while (running) { - if (isMintingPossible != wasMintingPossible) - Controller.getInstance().onMintingPossibleChange(isMintingPossible); + // recreate repository for new loop iteration + try (final Repository repository = RepositoryManager.getRepository()) { - wasMintingPossible = isMintingPossible; + // Going to need this a lot... + BlockRepository blockRepository = repository.getBlockRepository(); - try { - // Free up any repository locks - repository.discardChanges(); + if (isMintingPossible != wasMintingPossible) + Controller.getInstance().onMintingPossibleChange(isMintingPossible); - // 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; - - final Long now = NTP.getTime(); - if (now == null) - continue; - - final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); - if (minLatestBlockTimestamp == null) - continue; - - List mintingAccountsData = repository.getAccountRepository().getMintingAccounts(); - // No minting accounts? - if (mintingAccountsData.isEmpty()) - continue; - - // Disregard minting accounts that are no longer valid, e.g. by transfer/loss of founder flag or account level - // Note that minting accounts are actually reward-shares in Qortal - Iterator madi = mintingAccountsData.iterator(); - while (madi.hasNext()) { - MintingAccountData mintingAccountData = madi.next(); - - RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey()); - if (rewardShareData == null) { - // Reward-share doesn't exist - probably cancelled but not yet removed from node's list of minting accounts - madi.remove(); - continue; - } - - Account mintingAccount = new Account(repository, rewardShareData.getMinter()); - if (!mintingAccount.canMint()) { - // Minting-account component of reward-share can no longer mint - disregard - madi.remove(); - continue; - } - - // Optional (non-validated) prevention of block submissions below a defined level. - // This is an unvalidated version of Blockchain.minAccountLevelToMint - // and exists only to reduce block candidates by default. - int level = mintingAccount.getEffectiveMintingLevel(); - if (level < BlockChain.getInstance().getMinAccountLevelForBlockSubmissions()) { - madi.remove(); - } - } - - // Needs a mutable copy of the unmodifiableList - List peers = new ArrayList<>(Network.getInstance().getImmutableHandshakedPeers()); - BlockData lastBlockData = blockRepository.getLastBlock(); - - // Disregard peers that have "misbehaved" recently - peers.removeIf(Controller.hasMisbehaved); - - // Disregard peers that don't have a recent block, but only if we're not in recovery mode. - // In that mode, we want to allow minting on top of older blocks, to recover stalled networks. - if (!Synchronizer.getInstance().getRecoveryMode()) - peers.removeIf(Controller.hasNoRecentBlock); - - // Don't mint if we don't have enough up-to-date peers as where would the transactions/consensus come from? - if (peers.size() < Settings.getInstance().getMinBlockchainPeers()) - continue; - - // If we are stuck on an invalid block, we should allow an alternative to be minted - boolean recoverInvalidBlock = false; - if (Synchronizer.getInstance().timeInvalidBlockLastReceived != null) { - // We've had at least one invalid block - long timeSinceLastValidBlock = NTP.getTime() - Synchronizer.getInstance().timeValidBlockLastReceived; - long timeSinceLastInvalidBlock = NTP.getTime() - Synchronizer.getInstance().timeInvalidBlockLastReceived; - if (timeSinceLastValidBlock > INVALID_BLOCK_RECOVERY_TIMEOUT) { - if (timeSinceLastInvalidBlock < INVALID_BLOCK_RECOVERY_TIMEOUT) { - // Last valid block was more than 10 mins ago, but we've had an invalid block since then - // Assume that the chain has stalled because there is no alternative valid candidate - // Enter recovery mode to allow alternative, valid candidates to be minted - recoverInvalidBlock = true; - } - } - } - - // If our latest block isn't recent then we need to synchronize instead of minting, unless we're in recovery mode. - if (!peers.isEmpty() && lastBlockData.getTimestamp() < minLatestBlockTimestamp) - if (!Synchronizer.getInstance().getRecoveryMode() && !recoverInvalidBlock) - continue; - - // There are enough peers with a recent block and our latest block is recent - // so go ahead and mint a block if possible. - isMintingPossible = true; - - // Check blockchain hasn't changed - if (previousBlockData == null || !Arrays.equals(previousBlockData.getSignature(), lastBlockData.getSignature())) { - previousBlockData = lastBlockData; - newBlocks.clear(); - - // Reduce log timeout - logTimeout = 10 * 1000L; - - // Last low weight block is no longer valid - parentSignatureForLastLowWeightBlock = null; - } - - // Discard accounts we have already built blocks with - mintingAccountsData.removeIf(mintingAccountData -> newBlocks.stream().anyMatch(newBlock -> Arrays.equals(newBlock.getBlockData().getMinterPublicKey(), mintingAccountData.getPublicKey()))); - - // Do we need to build any potential new blocks? - 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 && !isSingleNodeTestnet) { - LOGGER.trace(String.format("One of our keys signed the last block, so we won't sign the next one")); - continue; - } - - if (parentSignatureForLastLowWeightBlock != null) { - // The last iteration found a higher weight block in the network, so sleep for a while - // to allow is to sync the higher weight chain. We are sleeping here rather than when - // detected as we don't want to hold the blockchain lock open. - LOGGER.info("Sleeping for 10 seconds..."); - Thread.sleep(10 * 1000L); - } - - for (PrivateKeyAccount mintingAccount : newBlocksMintingAccounts) { - // First block does the AT heavy-lifting - if (newBlocks.isEmpty()) { - Block newBlock = Block.mint(repository, previousBlockData, mintingAccount); - if (newBlock == null) { - // For some reason we can't mint right now - moderatedLog(() -> LOGGER.info("Couldn't build a to-be-minted block")); - continue; - } - - newBlocks.add(newBlock); - } else { - // The blocks for other minters require less effort... - Block newBlock = newBlocks.get(0).remint(mintingAccount); - if (newBlock == null) { - // For some reason we can't mint right now - moderatedLog(() -> LOGGER.error("Couldn't rebuild a to-be-minted block")); - continue; - } - - newBlocks.add(newBlock); - } - } - - // No potential block candidates? - if (newBlocks.isEmpty()) - continue; - - // Make sure we're the only thread modifying the blockchain - ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); - if (!blockchainLock.tryLock(30, TimeUnit.SECONDS)) { - LOGGER.debug("Couldn't acquire blockchain lock even after waiting 30 seconds"); - continue; - } - - boolean newBlockMinted = false; - Block newBlock = null; + wasMintingPossible = isMintingPossible; try { - // Clear repository session state so we have latest view of data + // reset the repository, to the repository recreated for this loop iteration + for( Block newBlock : newBlocks ) newBlock.setRepository(repository); + + // Free up any repository locks repository.discardChanges(); - // Now that we have blockchain lock, do final check that chain hasn't changed - BlockData latestBlockData = blockRepository.getLastBlock(); - if (!Arrays.equals(lastBlockData.getSignature(), latestBlockData.getSignature())) + // 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; + + final Long now = NTP.getTime(); + if (now == null) continue; - List goodBlocks = new ArrayList<>(); - boolean wasInvalidBlockDiscarded = false; - Iterator newBlocksIterator = newBlocks.iterator(); + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp == null) + continue; - while (newBlocksIterator.hasNext()) { - Block testBlock = newBlocksIterator.next(); + List mintingAccountsData = repository.getAccountRepository().getMintingAccounts(); + // No minting accounts? + if (mintingAccountsData.isEmpty()) + continue; - // Is new block's timestamp valid yet? - // We do a separate check as some timestamp checks are skipped for testchains - if (testBlock.isTimestampValid() != ValidationResult.OK) + // Disregard minting accounts that are no longer valid, e.g. by transfer/loss of founder flag or account level + // Note that minting accounts are actually reward-shares in Qortal + Iterator madi = mintingAccountsData.iterator(); + while (madi.hasNext()) { + MintingAccountData mintingAccountData = madi.next(); + + RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey()); + if (rewardShareData == null) { + // Reward-share doesn't exist - probably cancelled but not yet removed from node's list of minting accounts + madi.remove(); + continue; + } + + Account mintingAccount = new Account(repository, rewardShareData.getMinter()); + if (!mintingAccount.canMint(true)) { + // Minting-account component of reward-share can no longer mint - disregard + madi.remove(); + continue; + } + + // Optional (non-validated) prevention of block submissions below a defined level. + // This is an unvalidated version of Blockchain.minAccountLevelToMint + // and exists only to reduce block candidates by default. + int level = mintingAccount.getEffectiveMintingLevel(); + if (level < BlockChain.getInstance().getMinAccountLevelForBlockSubmissions()) { + madi.remove(); + } + } + + // Needs a mutable copy of the unmodifiableList + List peers = new ArrayList<>(Network.getInstance().getImmutableHandshakedPeers()); + BlockData lastBlockData = blockRepository.getLastBlock(); + + // Disregard peers that have "misbehaved" recently + peers.removeIf(Controller.hasMisbehaved); + + // Disregard peers that don't have a recent block, but only if we're not in recovery mode. + // In that mode, we want to allow minting on top of older blocks, to recover stalled networks. + if (!Synchronizer.getInstance().getRecoveryMode()) + peers.removeIf(Controller.hasNoRecentBlock); + + // Don't mint if we don't have enough up-to-date peers as where would the transactions/consensus come from? + if (peers.size() < Settings.getInstance().getMinBlockchainPeers()) + continue; + + // If we are stuck on an invalid block, we should allow an alternative to be minted + boolean recoverInvalidBlock = false; + if (Synchronizer.getInstance().timeInvalidBlockLastReceived != null) { + // We've had at least one invalid block + long timeSinceLastValidBlock = NTP.getTime() - Synchronizer.getInstance().timeValidBlockLastReceived; + long timeSinceLastInvalidBlock = NTP.getTime() - Synchronizer.getInstance().timeInvalidBlockLastReceived; + if (timeSinceLastValidBlock > INVALID_BLOCK_RECOVERY_TIMEOUT) { + if (timeSinceLastInvalidBlock < INVALID_BLOCK_RECOVERY_TIMEOUT) { + // Last valid block was more than 10 mins ago, but we've had an invalid block since then + // Assume that the chain has stalled because there is no alternative valid candidate + // Enter recovery mode to allow alternative, valid candidates to be minted + recoverInvalidBlock = true; + } + } + } + + // If our latest block isn't recent then we need to synchronize instead of minting, unless we're in recovery mode. + if (!peers.isEmpty() && lastBlockData.getTimestamp() < minLatestBlockTimestamp) + if (!Synchronizer.getInstance().getRecoveryMode() && !recoverInvalidBlock) continue; - testBlock.preProcess(); + // There are enough peers with a recent block and our latest block is recent + // so go ahead and mint a block if possible. + isMintingPossible = true; - // Is new block valid yet? (Before adding unconfirmed transactions) - ValidationResult result = testBlock.isValid(); - if (result != ValidationResult.OK) { - moderatedLog(() -> LOGGER.error(String.format("To-be-minted block invalid '%s' before adding transactions?", result.name()))); + // Check blockchain hasn't changed + if (previousBlockData == null || !Arrays.equals(previousBlockData.getSignature(), lastBlockData.getSignature())) { + previousBlockData = lastBlockData; + newBlocks.clear(); - newBlocksIterator.remove(); - wasInvalidBlockDiscarded = true; - /* - * Bail out fast so that we loop around from the top again. - * This gives BlockMinter the possibility to remint this candidate block using another block from newBlocks, - * via the Blocks.remint() method, which avoids having to re-process Block ATs all over again. - * Particularly useful if some aspect of Blocks changes due a timestamp-based feature-trigger (see BlockChain class). - */ - break; - } + // Reduce log timeout + logTimeout = 10 * 1000L; - goodBlocks.add(testBlock); + // Last low weight block is no longer valid + parentSignatureForLastLowWeightBlock = null; } - if (wasInvalidBlockDiscarded || goodBlocks.isEmpty()) + // Discard accounts we have already built blocks with + mintingAccountsData.removeIf(mintingAccountData -> newBlocks.stream().anyMatch(newBlock -> Arrays.equals(newBlock.getBlockData().getMinterPublicKey(), mintingAccountData.getPublicKey()))); + + // Do we need to build any potential new blocks? + 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 && !isSingleNodeTestnet) { + LOGGER.trace(String.format("One of our keys signed the last block, so we won't sign the next one")); continue; - - // Pick best block - final int parentHeight = previousBlockData.getHeight(); - final byte[] parentBlockSignature = previousBlockData.getSignature(); - - BigInteger bestWeight = null; - - for (int bi = 0; bi < goodBlocks.size(); ++bi) { - BlockData blockData = goodBlocks.get(bi).getBlockData(); - - BlockSummaryData blockSummaryData = new BlockSummaryData(blockData); - int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, blockData.getMinterPublicKey()); - blockSummaryData.setMinterLevel(minterLevel); - - BigInteger blockWeight = Block.calcBlockWeight(parentHeight, parentBlockSignature, blockSummaryData); - - if (bestWeight == null || blockWeight.compareTo(bestWeight) < 0) { - newBlock = goodBlocks.get(bi); - bestWeight = blockWeight; - } } - try { - if (this.higherWeightChainExists(repository, bestWeight)) { + if (parentSignatureForLastLowWeightBlock != null) { + // The last iteration found a higher weight block in the network, so sleep for a while + // to allow is to sync the higher weight chain. We are sleeping here rather than when + // detected as we don't want to hold the blockchain lock open. + LOGGER.info("Sleeping for 10 seconds..."); + Thread.sleep(10 * 1000L); + } - // Check if the base block has updated since the last time we were here - if (parentSignatureForLastLowWeightBlock == null || timeOfLastLowWeightBlock == null || - !Arrays.equals(parentSignatureForLastLowWeightBlock, previousBlockData.getSignature())) { - // We've switched to a different chain, so reset the timer - timeOfLastLowWeightBlock = NTP.getTime(); - } - parentSignatureForLastLowWeightBlock = previousBlockData.getSignature(); - - // If less than 30 seconds has passed since first detection the higher weight chain, - // we should skip our block submission to give us the opportunity to sync to the better chain - if (NTP.getTime() - timeOfLastLowWeightBlock < 30 * 1000L) { - LOGGER.info("Higher weight chain found in peers, so not signing a block this round"); - LOGGER.info("Time since detected: {}", NTP.getTime() - timeOfLastLowWeightBlock); + for (PrivateKeyAccount mintingAccount : newBlocksMintingAccounts) { + // First block does the AT heavy-lifting + if (newBlocks.isEmpty()) { + Block newBlock = Block.mint(repository, previousBlockData, mintingAccount); + if (newBlock == null) { + // For some reason we can't mint right now + moderatedLog(() -> LOGGER.info("Couldn't build a to-be-minted block")); continue; - } else { - // More than 30 seconds have passed, so we should submit our block candidate anyway. - LOGGER.info("More than 30 seconds passed, so proceeding to submit block candidate..."); } + + newBlocks.add(newBlock); } else { - LOGGER.debug("No higher weight chain found in peers"); + // The blocks for other minters require less effort... + Block newBlock = newBlocks.get(0).remint(mintingAccount); + if (newBlock == null) { + // For some reason we can't mint right now + moderatedLog(() -> LOGGER.error("Couldn't rebuild a to-be-minted block")); + continue; + } + + newBlocks.add(newBlock); } - } catch (DataException e) { - LOGGER.debug("Unable to check for a higher weight chain. Proceeding anyway..."); } - // Discard any uncommitted changes as a result of the higher weight chain detection - repository.discardChanges(); + // No potential block candidates? + if (newBlocks.isEmpty()) + continue; - // Clear variables that track low weight blocks - 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(); - - // Is newBlock still valid? - ValidationResult validationResult = newBlock.isValid(); - if (validationResult != ValidationResult.OK) { - // No longer valid? Report and discard - LOGGER.error(String.format("To-be-minted block now invalid '%s' after adding unconfirmed transactions?", validationResult.name())); - - // Rebuild block candidates, just to be sure - newBlocks.clear(); + // Make sure we're the only thread modifying the blockchain + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + if (!blockchainLock.tryLock(30, TimeUnit.SECONDS)) { + LOGGER.debug("Couldn't acquire blockchain lock even after waiting 30 seconds"); continue; } - // Add to blockchain - something else will notice and broadcast new block to network + boolean newBlockMinted = false; + Block newBlock = null; + try { - newBlock.process(); + // Clear repository session state so we have latest view of data + repository.discardChanges(); - repository.saveChanges(); + // Now that we have blockchain lock, do final check that chain hasn't changed + BlockData latestBlockData = blockRepository.getLastBlock(); + if (!Arrays.equals(lastBlockData.getSignature(), latestBlockData.getSignature())) + continue; - LOGGER.info(String.format("Minted new block: %d", newBlock.getBlockData().getHeight())); + List goodBlocks = new ArrayList<>(); + boolean wasInvalidBlockDiscarded = false; + Iterator newBlocksIterator = newBlocks.iterator(); - RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(newBlock.getBlockData().getMinterPublicKey()); + while (newBlocksIterator.hasNext()) { + Block testBlock = newBlocksIterator.next(); - if (rewardShareData != null) { - LOGGER.info(String.format("Minted block %d, sig %.8s, parent sig: %.8s by %s on behalf of %s", - newBlock.getBlockData().getHeight(), - Base58.encode(newBlock.getBlockData().getSignature()), - Base58.encode(newBlock.getParent().getSignature()), - rewardShareData.getMinter(), - rewardShareData.getRecipient())); - } else { - LOGGER.info(String.format("Minted block %d, sig %.8s, parent sig: %.8s by %s", - newBlock.getBlockData().getHeight(), - Base58.encode(newBlock.getBlockData().getSignature()), - Base58.encode(newBlock.getParent().getSignature()), - newBlock.getMinter().getAddress())); + // Is new block's timestamp valid yet? + // We do a separate check as some timestamp checks are skipped for testchains + if (testBlock.isTimestampValid() != ValidationResult.OK) + continue; + + testBlock.preProcess(); + + // Is new block valid yet? (Before adding unconfirmed transactions) + ValidationResult result = testBlock.isValid(); + if (result != ValidationResult.OK) { + moderatedLog(() -> LOGGER.error(String.format("To-be-minted block invalid '%s' before adding transactions?", result.name()))); + + newBlocksIterator.remove(); + wasInvalidBlockDiscarded = true; + /* + * Bail out fast so that we loop around from the top again. + * This gives BlockMinter the possibility to remint this candidate block using another block from newBlocks, + * via the Blocks.remint() method, which avoids having to re-process Block ATs all over again. + * Particularly useful if some aspect of Blocks changes due a timestamp-based feature-trigger (see BlockChain class). + */ + break; + } + + goodBlocks.add(testBlock); } - // Notify network after we're released blockchain lock - newBlockMinted = true; + if (wasInvalidBlockDiscarded || goodBlocks.isEmpty()) + continue; - // Notify Controller - repository.discardChanges(); // clear transaction status to prevent deadlocks - Controller.getInstance().onNewBlock(newBlock.getBlockData()); - } catch (DataException e) { - // 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(); + // Pick best block + final int parentHeight = previousBlockData.getHeight(); + final byte[] parentBlockSignature = previousBlockData.getSignature(); + + BigInteger bestWeight = null; + + for (int bi = 0; bi < goodBlocks.size(); ++bi) { + BlockData blockData = goodBlocks.get(bi).getBlockData(); + + BlockSummaryData blockSummaryData = new BlockSummaryData(blockData); + int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, blockData.getMinterPublicKey()); + blockSummaryData.setMinterLevel(minterLevel); + + BigInteger blockWeight = Block.calcBlockWeight(parentHeight, parentBlockSignature, blockSummaryData); + + if (bestWeight == null || blockWeight.compareTo(bestWeight) < 0) { + newBlock = goodBlocks.get(bi); + bestWeight = blockWeight; + } + } + + try { + if (this.higherWeightChainExists(repository, bestWeight)) { + + // Check if the base block has updated since the last time we were here + if (parentSignatureForLastLowWeightBlock == null || timeOfLastLowWeightBlock == null || + !Arrays.equals(parentSignatureForLastLowWeightBlock, previousBlockData.getSignature())) { + // We've switched to a different chain, so reset the timer + timeOfLastLowWeightBlock = NTP.getTime(); + } + parentSignatureForLastLowWeightBlock = previousBlockData.getSignature(); + + // If less than 30 seconds has passed since first detection the higher weight chain, + // we should skip our block submission to give us the opportunity to sync to the better chain + if (NTP.getTime() - timeOfLastLowWeightBlock < 30 * 1000L) { + LOGGER.info("Higher weight chain found in peers, so not signing a block this round"); + LOGGER.info("Time since detected: {}", NTP.getTime() - timeOfLastLowWeightBlock); + continue; + } else { + // More than 30 seconds have passed, so we should submit our block candidate anyway. + LOGGER.info("More than 30 seconds passed, so proceeding to submit block candidate..."); + } + } else { + LOGGER.debug("No higher weight chain found in peers"); + } + } catch (DataException e) { + LOGGER.debug("Unable to check for a higher weight chain. Proceeding anyway..."); + } + + // Discard any uncommitted changes as a result of the higher weight chain detection + repository.discardChanges(); + + // Clear variables that track low weight blocks + 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(); + + // Is newBlock still valid? + ValidationResult validationResult = newBlock.isValid(); + if (validationResult != ValidationResult.OK) { + // No longer valid? Report and discard + LOGGER.error(String.format("To-be-minted block now invalid '%s' after adding unconfirmed transactions?", validationResult.name())); + + // Rebuild block candidates, just to be sure + newBlocks.clear(); + continue; + } + + // Add to blockchain - something else will notice and broadcast new block to network + try { + newBlock.process(); + + repository.saveChanges(); + + LOGGER.info(String.format("Minted new block: %d", newBlock.getBlockData().getHeight())); + + RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(newBlock.getBlockData().getMinterPublicKey()); + + if (rewardShareData != null) { + LOGGER.info(String.format("Minted block %d, sig %.8s, parent sig: %.8s by %s on behalf of %s", + newBlock.getBlockData().getHeight(), + Base58.encode(newBlock.getBlockData().getSignature()), + Base58.encode(newBlock.getParent().getSignature()), + rewardShareData.getMinter(), + rewardShareData.getRecipient())); + } else { + LOGGER.info(String.format("Minted block %d, sig %.8s, parent sig: %.8s by %s", + newBlock.getBlockData().getHeight(), + Base58.encode(newBlock.getBlockData().getSignature()), + Base58.encode(newBlock.getParent().getSignature()), + newBlock.getMinter().getAddress())); + } + + // Notify network after we're released blockchain lock + newBlockMinted = true; + + // Notify Controller + repository.discardChanges(); // clear transaction status to prevent deadlocks + Controller.getInstance().onNewBlock(newBlock.getBlockData()); + } catch (DataException e) { + // 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(); } - } finally { - blockchainLock.unlock(); - } - if (newBlockMinted) { - // Broadcast our new chain to network - Network.getInstance().broadcastOurChain(); - } + if (newBlockMinted) { + // Broadcast our new chain to network + Network.getInstance().broadcastOurChain(); + } - } catch (InterruptedException e) { - // We've been interrupted - time to exit - return; + } catch (InterruptedException e) { + // We've been interrupted - time to exit + return; + } + } catch (DataException e) { + LOGGER.warn("Repository issue while running block minter - NO LONGER MINTING", e); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); } } - } catch (DataException e) { - LOGGER.warn("Repository issue while running block minter - NO LONGER MINTING", e); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); } } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 5ebcb2cd..b504c7fe 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -13,9 +13,12 @@ import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.arbitrary.*; +import org.qortal.controller.hsqldb.HSQLDBBalanceRecorder; +import org.qortal.controller.hsqldb.HSQLDBDataCacheManager; import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.controller.repository.PruneManager; import org.qortal.controller.tradebot.TradeBot; +import org.qortal.controller.tradebot.RNSTradeBot; import org.qortal.data.account.AccountBalanceData; import org.qortal.data.account.AccountData; import org.qortal.data.block.BlockData; @@ -32,7 +35,9 @@ import org.qortal.gui.Gui; import org.qortal.gui.SysTray; import org.qortal.network.Network; import org.qortal.network.RNSNetwork; +import org.qortal.network.RNSPeer; import org.qortal.network.Peer; +import org.qortal.network.PeerAddress; import org.qortal.network.message.*; import org.qortal.repository.*; import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; @@ -49,8 +54,11 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; import java.nio.file.Path; import java.nio.file.Paths; +import java.security.SecureRandom; import java.security.Security; import java.time.LocalDateTime; import java.time.ZoneOffset; @@ -68,6 +76,8 @@ import java.util.stream.Collectors; public class Controller extends Thread { + public static HSQLDBRepositoryFactory REPOSITORY_FACTORY; + static { // This must go before any calls to LogManager/Logger System.setProperty("log4j2.formatMsgNoLookups", "true"); @@ -96,7 +106,7 @@ public class Controller extends Thread { private final long buildTimestamp; // seconds private final String[] savedArgs; - private ExecutorService callbackExecutor = Executors.newFixedThreadPool(3); + private ExecutorService callbackExecutor = Executors.newFixedThreadPool(4); private volatile boolean notifyGroupMembershipChange = false; /** Latest blocks on our chain. Note: tail/last is the latest block. */ @@ -399,14 +409,44 @@ public class Controller extends Thread { LOGGER.info("Starting repository"); try { - RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl()); - RepositoryManager.setRepositoryFactory(repositoryFactory); + REPOSITORY_FACTORY = new HSQLDBRepositoryFactory(getRepositoryUrl()); + RepositoryManager.setRepositoryFactory(REPOSITORY_FACTORY); RepositoryManager.setRequestedCheckpoint(Boolean.TRUE); try (final Repository repository = RepositoryManager.getRepository()) { - RepositoryManager.rebuildTransactionSequences(repository); + // RepositoryManager.rebuildTransactionSequences(repository); ArbitraryDataCacheManager.getInstance().buildArbitraryResourcesCache(repository, false); } + + if( Settings.getInstance().isDbCacheEnabled() ) { + LOGGER.info("Db Cache Starting ..."); + HSQLDBDataCacheManager hsqldbDataCacheManager = new HSQLDBDataCacheManager(); + hsqldbDataCacheManager.start(); + } + else { + LOGGER.info("Db Cache Disabled"); + } + + LOGGER.info("Arbitrary Indexing Starting ..."); + ArbitraryIndexUtils.startCaching( + Settings.getInstance().getArbitraryIndexingPriority(), + Settings.getInstance().getArbitraryIndexingFrequency() + ); + + if( Settings.getInstance().isBalanceRecorderEnabled() ) { + Optional recorder = HSQLDBBalanceRecorder.getInstance(); + + if( recorder.isPresent() ) { + LOGGER.info("Balance Recorder Starting ..."); + recorder.get().start(); + } + else { + LOGGER.info("Balance Recorder won't start."); + } + } + else { + LOGGER.info("Balance Recorder Disabled"); + } } catch (DataException e) { // If exception has no cause or message then repository is in use by some other process. if (e.getCause() == null && e.getMessage() == null) { @@ -496,7 +536,6 @@ public class Controller extends Thread { @Override public void run() { Thread.currentThread().setName("Shutdown hook"); - Controller.getInstance().shutdown(); } }); @@ -521,6 +560,16 @@ public class Controller extends Thread { ArbitraryDataStorageManager.getInstance().start(); ArbitraryDataRenderManager.getInstance().start(); + // start rebuild arbitrary resource cache timer task + if( Settings.getInstance().isRebuildArbitraryResourceCacheTaskEnabled() ) { + new Timer().schedule( + new RebuildArbitraryResourceCacheTask(), + Settings.getInstance().getRebuildArbitraryResourceCacheTaskDelay() * RebuildArbitraryResourceCacheTask.MILLIS_IN_MINUTE, + Settings.getInstance().getRebuildArbitraryResourceCacheTaskPeriod() * RebuildArbitraryResourceCacheTask.MILLIS_IN_HOUR + ); + } + + LOGGER.info("Starting online accounts manager"); OnlineAccountsManager.getInstance().start(); @@ -576,10 +625,33 @@ public class Controller extends Thread { // If GUI is enabled, we're no longer starting up but actually running now Gui.getInstance().notifyRunning(); - // Check every 10 minutes to see if the block minter is running - Timer timer = new Timer(); + if (Settings.getInstance().isAutoRestartEnabled()) { + // Check every 10 minutes if we have enough connected peers + Timer checkConnectedPeers = new Timer(); - timer.schedule(new TimerTask() { + checkConnectedPeers.schedule(new TimerTask() { + @Override + public void run() { + // Get the connected peers + int myConnectedPeers = Network.getInstance().getImmutableHandshakedPeers().size(); + LOGGER.debug("Node have {} connected peers", myConnectedPeers); + if (myConnectedPeers == 0) { + // Restart node if we have 0 peers + LOGGER.info("Node have no connected peers, restarting node"); + try { + RestartNode.attemptToRestart(); + } catch (Exception e) { + LOGGER.error("Unable to restart the node", e); + } + } + } + }, 10*60*1000, 10*60*1000); + } + + // Check every 10 minutes to see if the block minter is running + Timer checkBlockMinter = new Timer(); + + checkBlockMinter.schedule(new TimerTask() { @Override public void run() { if (blockMinter.isAlive()) { @@ -603,6 +675,71 @@ public class Controller extends Thread { } } }, 10*60*1000, 10*60*1000); + + // Check if we need sync from genesis and start syncing + Timer syncFromGenesis = new Timer(); + syncFromGenesis.schedule(new TimerTask() { + @Override + public void run() { + LOGGER.debug("Start sync from genesis check."); + boolean canBootstrap = Settings.getInstance().getBootstrap(); + boolean needsArchiveRebuild = false; + int checkHeight = 0; + + try (final Repository repository = RepositoryManager.getRepository()){ + needsArchiveRebuild = (repository.getBlockArchiveRepository().fromHeight(2) == null); + checkHeight = repository.getBlockRepository().getBlockchainHeight(); + } catch (DataException e) { + throw new RuntimeException(e); + } + + if (canBootstrap || !needsArchiveRebuild || checkHeight > 3) { + LOGGER.debug("Bootstrapping is enabled or we have more than 2 blocks, cancel sync from genesis check."); + syncFromGenesis.cancel(); + return; + } + + if (needsArchiveRebuild && !canBootstrap) { + LOGGER.info("Start syncing from genesis!"); + List seeds = new ArrayList<>(Network.getInstance().getImmutableHandshakedPeers()); + + // Check if have a qualified peer to sync + if (seeds.isEmpty()) { + LOGGER.info("No connected peers, will try again later."); + return; + } + + int index = new SecureRandom().nextInt(seeds.size()); + String syncNode = String.valueOf(seeds.get(index)); + PeerAddress peerAddress = PeerAddress.fromString(syncNode); + InetSocketAddress resolvedAddress = null; + + try { + resolvedAddress = peerAddress.toSocketAddress(); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + + InetSocketAddress finalResolvedAddress = resolvedAddress; + Peer targetPeer = seeds.stream().filter(peer -> peer.getResolvedAddress().equals(finalResolvedAddress)).findFirst().orElse(null); + Synchronizer.SynchronizationResult syncResult; + + try { + do { + try { + syncResult = Synchronizer.getInstance().actuallySynchronize(targetPeer, true); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + while (syncResult == Synchronizer.SynchronizationResult.OK); + } finally { + // We are syncing now, so can cancel the check + syncFromGenesis.cancel(); + } + } + } + }, 3*60*1000, 3*60*1000); } /** Called by AdvancedInstaller's launch EXE in single-instance mode, when an instance is already running. */ @@ -718,29 +855,29 @@ public class Controller extends Thread { repositoryMaintenanceInterval = getRandomRepositoryMaintenanceInterval(); } - // Prune stuck/slow/old peers - if (now >= prunePeersTimestamp + prunePeersInterval) { - prunePeersTimestamp = now + prunePeersInterval; + //// Prune stuck/slow/old peers + //if (now >= prunePeersTimestamp + prunePeersInterval) { + // prunePeersTimestamp = now + prunePeersInterval; + // + // try { + // LOGGER.debug("Pruning peers..."); + // Network.getInstance().prunePeers(); + // } catch (DataException e) { + // LOGGER.warn(String.format("Repository issue when trying to prune peers: %s", e.getMessage())); + // } + //} - try { - LOGGER.debug("Pruning peers..."); - Network.getInstance().prunePeers(); - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue when trying to prune peers: %s", e.getMessage())); - } - } - - // Q: Do we need global pruning? - if (now >= pruneRNSPeersTimestamp + pruneRNSPeersInterval) { - pruneRNSPeersTimestamp = now + pruneRNSPeersInterval; - - try { - LOGGER.debug("Pruning Reticulum peers..."); - RNSNetwork.getInstance().prunePeers(); - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue when trying to prune Reticulum peers: %s", e.getMessage())); - } - } + //// Q: Do we need global pruning? + //if (now >= pruneRNSPeersTimestamp + pruneRNSPeersInterval) { + // pruneRNSPeersTimestamp = now + pruneRNSPeersInterval; + // + // try { + // LOGGER.debug("Pruning Reticulum peers..."); + // RNSNetwork.getInstance().prunePeers(); + // } catch (DataException e) { + // LOGGER.warn(String.format("Repository issue when trying to prune Reticulum peers: %s", e.getMessage())); + // } + //} // Delete expired transactions if (now >= deleteExpiredTimestamp) { @@ -1125,6 +1262,35 @@ public class Controller extends Thread { network.broadcast(network::buildGetUnconfirmedTransactionsMessage); } + public void doRNSNetworkBroadcast() { + if (Settings.getInstance().isLite()) { + // Lite nodes have nothing to broadcast + return; + } + RNSNetwork network = RNSNetwork.getInstance(); + + // Send our current height + network.broadcastOurChain(); + + // Request unconfirmed transaction signatures, but only if we're up-to-date. + // if we're not up-to-dat then priority is synchronizing first + if (isUpToDateRNS()) { + network.broadcast(network::buildGetUnconfirmedTransactionsMessage); + } + + } + + public void doRNSPrunePeers() { + RNSNetwork network = RNSNetwork.getInstance(); + + try { + LOGGER.debug("Pruning peers..."); + network.prunePeers(); + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue when trying to prune peers: %s", e.getMessage())); + } + } + public void onMintingPossibleChange(boolean isMintingPossible) { this.isMintingPossible = isMintingPossible; requestSysTrayUpdate = true; @@ -2056,4 +2222,688 @@ public class Controller extends Thread { public StatsSnapshot getStatsSnapshot() { return this.stats; } + + public void onRNSNetworkMessage(RNSPeer peer, Message message) { + LOGGER.trace(() -> String.format("Processing %s message from %s", message.getType().name(), peer)); + + // Ordered by message type value + switch (message.getType()) { + case GET_BLOCK: + onRNSNetworkGetBlockMessage(peer, message); + break; + + case GET_BLOCK_SUMMARIES: + onRNSNetworkGetBlockSummariesMessage(peer, message); + break; + + case GET_SIGNATURES_V2: + onRNSNetworkGetSignaturesV2Message(peer, message); + break; + + case HEIGHT_V2: + onRNSNetworkHeightV2Message(peer, message); + break; + + case BLOCK_SUMMARIES_V2: + onRNSNetworkBlockSummariesV2Message(peer, message); + break; + + case GET_TRANSACTION: + RNSTransactionImporter.getInstance().onNetworkGetTransactionMessage(peer, message); + break; + + case TRANSACTION: + RNSTransactionImporter.getInstance().onNetworkTransactionMessage(peer, message); + break; + + case GET_UNCONFIRMED_TRANSACTIONS: + RNSTransactionImporter.getInstance().onNetworkGetUnconfirmedTransactionsMessage(peer, message); + break; + + case TRANSACTION_SIGNATURES: + RNSTransactionImporter.getInstance().onNetworkTransactionSignaturesMessage(peer, message); + break; + + //case GET_ONLINE_ACCOUNTS_V3: + // OnlineAccountsManager.getInstance().onNetworkGetOnlineAccountsV3Message(peer, message); + // break; + // + //case ONLINE_ACCOUNTS_V3: + // OnlineAccountsManager.getInstance().onNetworkOnlineAccountsV3Message(peer, message); + // break; + + //// TODO: Compiles but much of the Manager details need to be rethought for Reticulum + //case GET_ARBITRARY_DATA: + // // Not currently supported + // break; + //// + //case ARBITRARY_DATA_FILE_LIST: + // RNSArbitraryDataFileListManager.getInstance().onNetworkArbitraryDataFileListMessage(peer, message); + // break; + // + //case GET_ARBITRARY_DATA_FILE: + // RNSArbitraryDataFileManager.getInstance().onNetworkGetArbitraryDataFileMessage(peer, message); + // break; + // + //case GET_ARBITRARY_DATA_FILE_LIST: + // RNSArbitraryDataFileListManager.getInstance().onNetworkGetArbitraryDataFileListMessage(peer, message); + // break; + // + case ARBITRARY_SIGNATURES: + // Not currently supported + break; + + case GET_ARBITRARY_METADATA: + RNSArbitraryMetadataManager.getInstance().onNetworkGetArbitraryMetadataMessage(peer, message); + break; + + case ARBITRARY_METADATA: + RNSArbitraryMetadataManager.getInstance().onNetworkArbitraryMetadataMessage(peer, message); + break; + + case GET_TRADE_PRESENCES: + RNSTradeBot.getInstance().onGetTradePresencesMessage(peer, message); + break; + + case TRADE_PRESENCES: + RNSTradeBot.getInstance().onTradePresencesMessage(peer, message); + break; + + case GET_ACCOUNT: + onRNSNetworkGetAccountMessage(peer, message); + break; + + case GET_ACCOUNT_BALANCE: + onRNSNetworkGetAccountBalanceMessage(peer, message); + break; + + case GET_ACCOUNT_TRANSACTIONS: + onRNSNetworkGetAccountTransactionsMessage(peer, message); + break; + + case GET_ACCOUNT_NAMES: + onRNSNetworkGetAccountNamesMessage(peer, message); + break; + + case GET_NAME: + onRNSNetworkGetNameMessage(peer, message); + break; + + default: + LOGGER.debug(() -> String.format("Unhandled %s message [ID %d] from peer %s", message.getType().name(), message.getId(), peer)); + break; + } + } + + private void onRNSNetworkGetBlockMessage(RNSPeer peer, Message message) { + GetBlockMessage getBlockMessage = (GetBlockMessage) message; + byte[] signature = getBlockMessage.getSignature(); + this.stats.getBlockMessageStats.requests.incrementAndGet(); + + ByteArray signatureAsByteArray = ByteArray.wrap(signature); + + CachedBlockMessage cachedBlockMessage = this.blockMessageCache.get(signatureAsByteArray); + int blockCacheSize = Settings.getInstance().getBlockCacheSize(); + + // Check cached latest block message + if (cachedBlockMessage != null) { + this.stats.getBlockMessageStats.cacheHits.incrementAndGet(); + + // We need to duplicate it to prevent multiple threads setting ID on the same message + CachedBlockMessage clonedBlockMessage = Message.cloneWithNewId(cachedBlockMessage, message.getId()); + + //if (!peer.sendMessage(clonedBlockMessage)) + // peer.disconnect("failed to send block"); + peer.sendMessage(clonedBlockMessage); + + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData blockData = repository.getBlockRepository().fromSignature(signature); + + if (blockData != null) { + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight())) { + // If this is a pruned block, we likely only have partial data, so best not to sent it + blockData = null; + } + } + + // If we have no block data, we should check the archive in case it's there + if (blockData == null) { + if (Settings.getInstance().isArchiveEnabled()) { + 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 + //if (!peer.sendMessage(blockMessage)) { + // peer.disconnect("failed to send block"); + // // Don't fall-through to caching because failure to send might be from failure to build message + // return; + //} + peer.sendMessage(blockMessage); + + // Sent successfully from archive, so nothing more to do + return; + } + } + } + + if (blockData == null) { + // We don't have this block + this.stats.getBlockMessageStats.unknownBlocks.getAndIncrement(); + + // Send valid, yet unexpected message type in response, so peer's synchronizer doesn't have to wait for timeout + LOGGER.debug(() -> String.format("Sending 'block unknown' response to peer %s for GET_BLOCK request for unknown block %s", peer, Base58.encode(signature))); + + // Send generic 'unknown' message as it's very short + //Message blockUnknownMessage = peer.getPeersVersion() >= GenericUnknownMessage.MINIMUM_PEER_VERSION + // ? new GenericUnknownMessage() + // : new BlockSummariesMessage(Collections.emptyList()); + Message blockUnknownMessage = new GenericUnknownMessage(); + blockUnknownMessage.setId(message.getId()); + //if (!peer.sendMessage(blockUnknownMessage)) + // peer.disconnect("failed to send block-unknown response"); + peer.sendMessage(blockUnknownMessage); + return; + } + + Block block = new Block(repository, blockData); + + //// V2 support + //if (peer.getPeersVersion() >= BlockV2Message.MIN_PEER_VERSION) { + // Message blockMessage = new BlockV2Message(block); + // blockMessage.setId(message.getId()); + // if (!peer.sendMessage(blockMessage)) { + // peer.disconnect("failed to send block"); + // // Don't fall-through to caching because failure to send might be from failure to build message + // return; + // } + // return; + //} + + CachedBlockMessage blockMessage = new CachedBlockMessage(block); + blockMessage.setId(message.getId()); + + //if (!peer.sendMessage(blockMessage)) { + // peer.disconnect("failed to send block"); + // // Don't fall-through to caching because failure to send might be from failure to build message + // return; + //} + peer.sendMessage(blockMessage); + + // If request is for a recent block, cache it + if (getChainHeight() - blockData.getHeight() <= blockCacheSize) { + this.stats.getBlockMessageStats.cacheFills.incrementAndGet(); + + this.blockMessageCache.put(ByteArray.wrap(blockData.getSignature()), blockMessage); + } + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending block %s to peer %s", Base58.encode(signature), peer), e); + } catch (TransformationException e) { + LOGGER.error(String.format("Serialization issue while sending block %s to peer %s", Base58.encode(signature), peer), e); + } + } + + private void onRNSNetworkGetBlockSummariesMessage(RNSPeer peer, Message message) { + GetBlockSummariesMessage getBlockSummariesMessage = (GetBlockSummariesMessage) message; + final byte[] parentSignature = getBlockSummariesMessage.getParentSignature(); + this.stats.getBlockSummariesStats.requests.incrementAndGet(); + + // If peer's parent signature matches our latest block signature + // 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 BlockSummariesV2Message(Collections.emptyList()); + + blockSummariesMessage.setId(message.getId()); + + //if (!peer.sendMessage(blockSummariesMessage)) + // peer.disconnect("failed to send block summaries"); + peer.sendMessage(blockSummariesMessage); + + return; + } + + List blockSummaries = new ArrayList<>(); + + // Attempt to serve from our cache of latest blocks + synchronized (this.latestBlocks) { + blockSummaries = this.latestBlocks.stream() + .dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature)) + .map(BlockSummaryData::new) + .collect(Collectors.toList()); + } + + if (blockSummaries.isEmpty()) { + try (final Repository repository = RepositoryManager.getRepository()) { + int numberRequested = Math.min(Network.MAX_BLOCK_SUMMARIES_PER_REPLY, getBlockSummariesMessage.getNumberRequested()); + + BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromReference(parentSignature); + } + + if (blockData != null) { + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight())) { + // If this request contains a pruned block, we likely only have partial data, so best not to sent anything + // We always prune from the oldest first, so it's fine to just check the first block requested + blockData = null; + } + } + + while (blockData != null && blockSummaries.size() < numberRequested) { + BlockSummaryData blockSummary = new BlockSummaryData(blockData); + blockSummaries.add(blockSummary); + + byte[] previousSignature = blockData.getSignature(); + blockData = repository.getBlockRepository().fromReference(previousSignature); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromReference(previousSignature); + } + } + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending block summaries after %s to peer %s", Base58.encode(parentSignature), peer), e); + } + } else { + this.stats.getBlockSummariesStats.cacheHits.incrementAndGet(); + + if (blockSummaries.size() >= getBlockSummariesMessage.getNumberRequested()) + this.stats.getBlockSummariesStats.fullyFromCache.incrementAndGet(); + } + + //Message blockSummariesMessage = peer.getPeersVersion() >= BlockSummariesV2Message.MINIMUM_PEER_VERSION + // ? new BlockSummariesV2Message(blockSummaries) + // : new BlockSummariesMessage(blockSummaries); + Message blockSummariesMessage = new BlockSummariesV2Message(blockSummaries); + blockSummariesMessage.setId(message.getId()); + //if (!peer.sendMessage(blockSummariesMessage)) + // peer.disconnect("failed to send block summaries"); + peer.sendMessage(blockSummariesMessage); + } + + private void onRNSNetworkGetSignaturesV2Message(RNSPeer peer, Message message) { + GetSignaturesV2Message getSignaturesMessage = (GetSignaturesV2Message) message; + final byte[] parentSignature = getSignaturesMessage.getParentSignature(); + this.stats.getBlockSignaturesV2Stats.requests.incrementAndGet(); + + // If peer's parent signature matches our latest block signature + // then we can short-circuit with an empty response + BlockData chainTip = getChainTip(); + if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { + Message signaturesMessage = new SignaturesMessage(Collections.emptyList()); + signaturesMessage.setId(message.getId()); + //if (!peer.sendMessage(signaturesMessage)) + // peer.disconnect("failed to send signatures (v2)"); + peer.sendMessage(signaturesMessage); + + return; + } + + List signatures = new ArrayList<>(); + + // Attempt to serve from our cache of latest blocks + synchronized (this.latestBlocks) { + signatures = this.latestBlocks.stream() + .dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature)) + .map(BlockData::getSignature) + .collect(Collectors.toList()); + } + + if (signatures.isEmpty()) { + try (final Repository repository = RepositoryManager.getRepository()) { + int numberRequested = getSignaturesMessage.getNumberRequested(); + BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromReference(parentSignature); + } + + while (blockData != null && signatures.size() < numberRequested) { + signatures.add(blockData.getSignature()); + + byte[] previousSignature = blockData.getSignature(); + blockData = repository.getBlockRepository().fromReference(previousSignature); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromReference(previousSignature); + } + } + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending V2 signatures after %s to peer %s", Base58.encode(parentSignature), peer), e); + } + } else { + this.stats.getBlockSignaturesV2Stats.cacheHits.incrementAndGet(); + + if (signatures.size() >= getSignaturesMessage.getNumberRequested()) + this.stats.getBlockSignaturesV2Stats.fullyFromCache.incrementAndGet(); + } + + Message signaturesMessage = new SignaturesMessage(signatures); + signaturesMessage.setId(message.getId()); + //if (!peer.sendMessage(signaturesMessage)) + // peer.disconnect("failed to send signatures (v2)"); + peer.sendMessage(signaturesMessage); + } + + private void onRNSNetworkHeightV2Message(RNSPeer peer, Message message) { + HeightV2Message heightV2Message = (HeightV2Message) message; + + if (!Settings.getInstance().isLite()) { + // If peer is inbound and we've not updated their height + // then this is probably their initial HEIGHT_V2 message + // so they need a corresponding HEIGHT_V2 message from us + if (!peer.getIsInitiator() && peer.getChainTipData() == null) { + Message responseMessage = RNSNetwork.getInstance().buildHeightOrChainTipInfo(peer); + peer.sendMessage(responseMessage); + } + } + + // Update peer chain tip data + BlockSummaryData newChainTipData = new BlockSummaryData(heightV2Message.getHeight(), heightV2Message.getSignature(), heightV2Message.getMinterPublicKey(), heightV2Message.getTimestamp()); + peer.setChainTipData(newChainTipData); + + // Potentially synchronize + Synchronizer.getInstance().requestSync(); + } + + private void onRNSNetworkBlockSummariesV2Message(RNSPeer peer, Message message) { + BlockSummariesV2Message blockSummariesV2Message = (BlockSummariesV2Message) message; + + if (!Settings.getInstance().isLite()) { + //// If peer is inbound and we've not updated their height + //// then this is probably their initial BLOCK_SUMMARIES_V2 message + //// so they need a corresponding BLOCK_SUMMARIES_V2 message from us + if (!peer.getIsInitiator() && peer.getChainTipData() == null) { + Message responseMessage = RNSNetwork.getInstance().buildHeightOrChainTipInfo(peer); + peer.sendMessage(responseMessage); + } + } + + 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.debug("Discarding late {} message with ID {} from {}", message.getType().name(), message.getId(), peer); + return; + } + + // Update peer chain tip data + peer.setChainTipSummaries(blockSummariesV2Message.getBlockSummaries()); + + // Potentially synchronize + Synchronizer.getInstance().requestSync(); + } + + // ************ + + private void onRNSNetworkGetAccountMessage(RNSPeer peer, Message message) { + GetAccountMessage getAccountMessage = (GetAccountMessage) message; + String address = getAccountMessage.getAddress(); + this.stats.getAccountMessageStats.requests.incrementAndGet(); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountData accountData = repository.getAccountRepository().getAccount(address); + + if (accountData == null) { + // We don't have this account + this.stats.getAccountMessageStats.unknownAccounts.getAndIncrement(); + + // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout + LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT request for unknown account %s", peer, address)); + + // Send generic 'unknown' message as it's very short + Message accountUnknownMessage = new GenericUnknownMessage(); + accountUnknownMessage.setId(message.getId()); + peer.sendMessage(accountUnknownMessage); + return; + } + + AccountMessage accountMessage = new AccountMessage(accountData); + accountMessage.setId(message.getId()); + + // handle in timeout callback instead + //if (!peer.sendMessage(accountMessage)) { + // peer.disconnect("failed to send account"); + //} + peer.sendMessage(accountMessage); + + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while send account %s to peer %s", address, peer), e); + } + } + + private void onRNSNetworkGetAccountBalanceMessage(RNSPeer peer, Message message) { + GetAccountBalanceMessage getAccountBalanceMessage = (GetAccountBalanceMessage) message; + String address = getAccountBalanceMessage.getAddress(); + long assetId = getAccountBalanceMessage.getAssetId(); + this.stats.getAccountBalanceMessageStats.requests.incrementAndGet(); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountBalanceData accountBalanceData = repository.getAccountRepository().getBalance(address, assetId); + + if (accountBalanceData == null) { + // We don't have this account + this.stats.getAccountBalanceMessageStats.unknownAccounts.getAndIncrement(); + + // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout + LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT_BALANCE request for unknown account %s and asset ID %d", peer, address, assetId)); + + // Send generic 'unknown' message as it's very short + Message accountUnknownMessage = new GenericUnknownMessage(); + accountUnknownMessage.setId(message.getId()); + peer.sendMessage(accountUnknownMessage); + return; + } + + AccountBalanceMessage accountMessage = new AccountBalanceMessage(accountBalanceData); + accountMessage.setId(message.getId()); + + // handle in timeout callback instead + //if (!peer.sendMessage(accountMessage)) { + // peer.disconnect("failed to send account balance"); + //} + peer.sendMessage(accountMessage); + + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while send balance for account %s and asset ID %d to peer %s", address, assetId, peer), e); + } + } + + private void onRNSNetworkGetAccountTransactionsMessage(RNSPeer peer, Message message) { + GetAccountTransactionsMessage getAccountTransactionsMessage = (GetAccountTransactionsMessage) message; + String address = getAccountTransactionsMessage.getAddress(); + int limit = Math.min(getAccountTransactionsMessage.getLimit(), 100); + int offset = getAccountTransactionsMessage.getOffset(); + this.stats.getAccountTransactionsMessageStats.requests.incrementAndGet(); + + try (final Repository repository = RepositoryManager.getRepository()) { + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, + null, null, null, address, TransactionsResource.ConfirmationStatus.CONFIRMED, limit, offset, false); + + // Expand signatures to transactions + List transactions = new ArrayList<>(signatures.size()); + for (byte[] signature : signatures) { + transactions.add(repository.getTransactionRepository().fromSignature(signature)); + } + + if (transactions == null) { + // We don't have this account + this.stats.getAccountTransactionsMessageStats.unknownAccounts.getAndIncrement(); + + // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout + LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT_TRANSACTIONS request for unknown account %s", peer, address)); + + // Send generic 'unknown' message as it's very short + Message accountUnknownMessage = new GenericUnknownMessage(); + accountUnknownMessage.setId(message.getId()); + peer.sendMessage(accountUnknownMessage); + return; + } + + TransactionsMessage transactionsMessage = new TransactionsMessage(transactions); + transactionsMessage.setId(message.getId()); + + // handle in timeout callback instead + //if (!peer.sendMessage(transactionsMessage)) { + // peer.disconnect("failed to send account transactions"); + //} + peer.sendMessage(transactionsMessage); + + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending transactions for account %s %d to peer %s", address, peer), e); + } catch (MessageException e) { + LOGGER.error(String.format("Message serialization issue while sending transactions for account %s %d to peer %s", address, peer), e); + } + } + + private void onRNSNetworkGetAccountNamesMessage(RNSPeer peer, Message message) { + GetAccountNamesMessage getAccountNamesMessage = (GetAccountNamesMessage) message; + String address = getAccountNamesMessage.getAddress(); + this.stats.getAccountNamesMessageStats.requests.incrementAndGet(); + + try (final Repository repository = RepositoryManager.getRepository()) { + List namesDataList = repository.getNameRepository().getNamesByOwner(address); + + if (namesDataList == null) { + // We don't have this account + this.stats.getAccountNamesMessageStats.unknownAccounts.getAndIncrement(); + + // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout + LOGGER.debug(() -> String.format("Sending 'account unknown' response to peer %s for GET_ACCOUNT_NAMES request for unknown account %s", peer, address)); + + // Send generic 'unknown' message as it's very short + Message accountUnknownMessage = new GenericUnknownMessage(); + accountUnknownMessage.setId(message.getId()); + peer.sendMessage(accountUnknownMessage); + return; + } + + NamesMessage namesMessage = new NamesMessage(namesDataList); + namesMessage.setId(message.getId()); + + // handle in timeout callback instead + //if (!peer.sendMessage(namesMessage)) { + // peer.disconnect("failed to send account names"); + //} + peer.sendMessage(namesMessage); + + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while send names for account %s to peer %s", address, peer), e); + } + } + + private void onRNSNetworkGetNameMessage(RNSPeer peer, Message message) { + GetNameMessage getNameMessage = (GetNameMessage) message; + String name = getNameMessage.getName(); + this.stats.getNameMessageStats.requests.incrementAndGet(); + + try (final Repository repository = RepositoryManager.getRepository()) { + NameData nameData = repository.getNameRepository().fromName(name); + + if (nameData == null) { + // We don't have this account + this.stats.getNameMessageStats.unknownAccounts.getAndIncrement(); + + // Send valid, yet unexpected message type in response, so peer doesn't have to wait for timeout + LOGGER.debug(() -> String.format("Sending 'name unknown' response to peer %s for GET_NAME request for unknown name %s", peer, name)); + + // Send generic 'unknown' message as it's very short + Message nameUnknownMessage = new GenericUnknownMessage(); + nameUnknownMessage.setId(message.getId()); + if (!peer.sendMessage(nameUnknownMessage)) + peer.sendMessage(nameUnknownMessage); + return; + } + + NamesMessage namesMessage = new NamesMessage(Arrays.asList(nameData)); + namesMessage.setId(message.getId()); + + // handle in timeout callback instead + //if (!peer.sendMessage(namesMessage)) { + // peer.disconnect("failed to send name data"); + //} + peer.sendMessage(namesMessage); + + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while send name %s to peer %s", name, peer), e); + } + } + + /** + * Returns whether we think our node has up-to-date blockchain based on our info about other peers. + * @param minLatestBlockTimestamp - the minimum block timestamp to be considered recent + * @return boolean - whether our node's blockchain is up to date or not + */ + public boolean isUpToDateRNS(Long minLatestBlockTimestamp) { + if (Settings.getInstance().isLite()) { + // Lite nodes are always "up to date" + return true; + } + + // Do we even have a vaguely recent block? + if (minLatestBlockTimestamp == null) + return false; + + final BlockData latestBlockData = getChainTip(); + 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<>(RNSNetwork.getInstance().getImmutableLinkedPeers()); + if (peers == null) + return false; + + //// Disregard peers that have "misbehaved" recently + //peers.removeIf(hasMisbehaved); + // + //// Disregard peers that don't have a recent block + //peers.removeIf(hasNoRecentBlock); + + // Check we have enough peers to potentially synchronize/mint + if (peers.size() < Settings.getInstance().getReticulumMinDesiredPeers()) + return false; + + // If we don't have any peers left then can't synchronize, therefore consider ourself not up to date + return !peers.isEmpty(); + } + + /** + * Returns whether we think our node has up-to-date blockchain based on our info about other peers. + * Uses the default minLatestBlockTimestamp value. + * @return boolean - whether our node's blockchain is up to date or not + */ + public boolean isUpToDateRNS() { + final Long minLatestBlockTimestamp = getMinimumLatestBlockTimestamp(); + return this.isUpToDate(minLatestBlockTimestamp); + } } diff --git a/src/main/java/org/qortal/controller/OnlineAccountsManager.java b/src/main/java/org/qortal/controller/OnlineAccountsManager.java index d37b2aef..bbca4c7b 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsManager.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsManager.java @@ -13,6 +13,7 @@ import org.qortal.crypto.MemoryPoW; import org.qortal.crypto.Qortal25519Extras; import org.qortal.data.account.MintingAccountData; import org.qortal.data.account.RewardShareData; +import org.qortal.data.group.GroupMemberData; import org.qortal.data.network.OnlineAccountData; import org.qortal.network.Network; import org.qortal.network.Peer; @@ -24,6 +25,7 @@ import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.utils.Base58; +import org.qortal.utils.Groups; import org.qortal.utils.NTP; import org.qortal.utils.NamedThreadFactory; @@ -44,6 +46,7 @@ public class OnlineAccountsManager { */ private static final long ONLINE_TIMESTAMP_MODULUS_V1 = 5 * 60 * 1000L; private static final long ONLINE_TIMESTAMP_MODULUS_V2 = 30 * 60 * 1000L; + private static final long ONLINE_TIMESTAMP_MODULUS_V3 = 10 * 60 * 1000L; /** * How many 'current' timestamp-sets of online accounts we cache. @@ -67,12 +70,13 @@ public class OnlineAccountsManager { private static final long ONLINE_ACCOUNTS_COMPUTE_INITIAL_SLEEP_INTERVAL = 30 * 1000L; // ms // MemoryPoW - mainnet - public static final int POW_BUFFER_SIZE = 1 * 1024 * 1024; // bytes + public static final int POW_BUFFER_SIZE = 1024 * 1024; // bytes public static final int POW_DIFFICULTY_V1 = 18; // leading zero bits public static final int POW_DIFFICULTY_V2 = 19; // leading zero bits + public static final int POW_DIFFICULTY_V3 = 6; // leading zero bits // MemoryPoW - testnet - public static final int POW_BUFFER_SIZE_TESTNET = 1 * 1024 * 1024; // bytes + public static final int POW_BUFFER_SIZE_TESTNET = 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 @@ -80,7 +84,7 @@ public class OnlineAccountsManager { // 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 final ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, new NamedThreadFactory("OnlineAccounts", Thread.NORM_PRIORITY)); private volatile boolean isStopping = false; private final Set onlineAccountsImportQueue = ConcurrentHashMap.newKeySet(); @@ -106,11 +110,15 @@ public class OnlineAccountsManager { public static long getOnlineTimestampModulus() { Long now = NTP.getTime(); - if (now != null && now >= BlockChain.getInstance().getOnlineAccountsModulusV2Timestamp()) { + if (now != null && now >= BlockChain.getInstance().getOnlineAccountsModulusV2Timestamp() && now < BlockChain.getInstance().getOnlineAccountsModulusV3Timestamp()) { return ONLINE_TIMESTAMP_MODULUS_V2; } + if (now != null && now >= BlockChain.getInstance().getOnlineAccountsModulusV3Timestamp()) { + return ONLINE_TIMESTAMP_MODULUS_V3; + } return ONLINE_TIMESTAMP_MODULUS_V1; } + public static Long getCurrentOnlineAccountTimestamp() { Long now = NTP.getTime(); if (now == null) @@ -135,9 +143,12 @@ public class OnlineAccountsManager { if (Settings.getInstance().isTestNet()) return POW_DIFFICULTY_TESTNET; - if (timestamp >= BlockChain.getInstance().getIncreaseOnlineAccountsDifficultyTimestamp()) + if (timestamp >= BlockChain.getInstance().getIncreaseOnlineAccountsDifficultyTimestamp() && timestamp < BlockChain.getInstance().getDecreaseOnlineAccountsDifficultyTimestamp()) return POW_DIFFICULTY_V2; + if (timestamp >= BlockChain.getInstance().getDecreaseOnlineAccountsDifficultyTimestamp()) + return POW_DIFFICULTY_V3; + return POW_DIFFICULTY_V1; } @@ -215,6 +226,15 @@ public class OnlineAccountsManager { Set onlineAccountsToAdd = new HashSet<>(); Set onlineAccountsToRemove = new HashSet<>(); try (final Repository repository = RepositoryManager.getRepository()) { + + int blockHeight = repository.getBlockRepository().getBlockchainHeight(); + + List mintingGroupMemberAddresses + = Groups.getAllMembers( + repository.getGroupRepository(), + Groups.getGroupIdsToMint(BlockChain.getInstance(), blockHeight) + ); + for (OnlineAccountData onlineAccountData : this.onlineAccountsImportQueue) { if (isStopping) return; @@ -227,7 +247,7 @@ public class OnlineAccountsManager { continue; } - boolean isValid = this.isValidCurrentAccount(repository, onlineAccountData); + boolean isValid = this.isValidCurrentAccount(repository, mintingGroupMemberAddresses, onlineAccountData); if (isValid) onlineAccountsToAdd.add(onlineAccountData); @@ -306,7 +326,7 @@ public class OnlineAccountsManager { return inplaceArray; } - private static boolean isValidCurrentAccount(Repository repository, OnlineAccountData onlineAccountData) throws DataException { + private static boolean isValidCurrentAccount(Repository repository, List mintingGroupMemberAddresses, OnlineAccountData onlineAccountData) throws DataException { final Long now = NTP.getTime(); if (now == null) return false; @@ -341,9 +361,14 @@ public class OnlineAccountsManager { LOGGER.trace(() -> String.format("Rejecting unknown online reward-share public key %s", Base58.encode(rewardSharePublicKey))); return false; } + // reject account address that are not in the MINTER Group + else if( !mintingGroupMemberAddresses.contains(rewardShareData.getMinter())) { + LOGGER.trace(() -> String.format("Rejecting online reward-share that is not in MINTER Group, account %s", rewardShareData.getMinter())); + return false; + } Account mintingAccount = new Account(repository, rewardShareData.getMinter()); - if (!mintingAccount.canMint()) { + if (!mintingAccount.canMint(true)) { // group validation is a few lines above // Minting-account component of reward-share can no longer mint - disregard LOGGER.trace(() -> String.format("Rejecting online reward-share with non-minting account %s", mintingAccount.getAddress())); return false; @@ -530,7 +555,7 @@ public class OnlineAccountsManager { } Account mintingAccount = new Account(repository, rewardShareData.getMinter()); - if (!mintingAccount.canMint()) { + if (!mintingAccount.canMint(true)) { // Minting-account component of reward-share can no longer mint - disregard iterator.remove(); continue; diff --git a/src/main/java/org/qortal/controller/PirateChainWalletController.java b/src/main/java/org/qortal/controller/PirateChainWalletController.java index e009d531..8f0c63b7 100644 --- a/src/main/java/org/qortal/controller/PirateChainWalletController.java +++ b/src/main/java/org/qortal/controller/PirateChainWalletController.java @@ -65,6 +65,7 @@ public class PirateChainWalletController extends Thread { @Override public void run() { Thread.currentThread().setName("Pirate Chain Wallet Controller"); + Thread.currentThread().setPriority(MIN_PRIORITY); try { while (running && !Controller.isStopping()) { diff --git a/src/main/java/org/qortal/controller/RNSTransactionImporter.java b/src/main/java/org/qortal/controller/RNSTransactionImporter.java new file mode 100644 index 00000000..40d89ada --- /dev/null +++ b/src/main/java/org/qortal/controller/RNSTransactionImporter.java @@ -0,0 +1,460 @@ +package org.qortal.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.network.RNSNetwork; +import org.qortal.network.RNSPeer; +import org.qortal.network.message.GetTransactionMessage; +import org.qortal.network.message.Message; +import org.qortal.network.message.TransactionMessage; +import org.qortal.network.message.TransactionSignaturesMessage; +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.utils.Base58; +import org.qortal.utils.NTP; + +import java.util.*; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +public class RNSTransactionImporter extends Thread { + + private static final Logger LOGGER = LogManager.getLogger(RNSTransactionImporter.class); + + private static RNSTransactionImporter instance; + private volatile boolean isStopping = false; + + private static final int MAX_INCOMING_TRANSACTIONS = 5000; + + /** Minimum time before considering an invalid unconfirmed transaction as "stale" */ + public static final long INVALID_TRANSACTION_STALE_TIMEOUT = 30 * 60 * 1000L; // ms + /** Minimum frequency to re-request stale unconfirmed transactions from peers, to recheck validity */ + public static final long INVALID_TRANSACTION_RECHECK_INTERVAL = 60 * 60 * 1000L; // ms\ + /** Minimum frequency to re-request expired unconfirmed transactions from peers, to recheck validity + * This mainly exists to stop expired transactions from bloating the list */ + public static final long EXPIRED_TRANSACTION_RECHECK_INTERVAL = 10 * 60 * 1000L; // ms + + + /** Map of incoming transaction that are in the import queue. Key is transaction data, value is whether signature has been validated. */ + private final Map incomingTransactions = Collections.synchronizedMap(new HashMap<>()); + + /** 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 RNSTransactionImporter getInstance() { + if (instance == null) { + instance = new RNSTransactionImporter(); + } + + return instance; + } + + @Override + public void run() { + Thread.currentThread().setName("Transaction Importer"); + + try { + while (!Controller.isStopping()) { + Thread.sleep(500L); + + // Process incoming transactions queue + validateTransactionsInQueue(); + importTransactionsInQueue(); + + // Clean up invalid incoming transactions list + cleanupInvalidTransactionsList(NTP.getTime()); + } + } catch (InterruptedException e) { + // Fall through to exit thread + } + } + + public void shutdown() { + isStopping = true; + this.interrupt(); + } + + + // Incoming transactions queue + + private boolean incomingTransactionQueueContains(byte[] signature) { + synchronized (incomingTransactions) { + return incomingTransactions.keySet().stream().anyMatch(t -> Arrays.equals(t.getSignature(), signature)); + } + } + + private void removeIncomingTransaction(byte[] signature) { + incomingTransactions.keySet().removeIf(t -> Arrays.equals(t.getSignature(), signature)); + } + + /** + * Retrieve all pending unconfirmed transactions that have had their signatures validated. + * @return a list of TransactionData objects, with valid signatures. + */ + private List getCachedSigValidTransactions() { + synchronized (this.incomingTransactions) { + return this.incomingTransactions.entrySet().stream() + .filter(t -> Boolean.TRUE.equals(t.getValue())) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + } + } + + /** + * Validate the signatures of any transactions pending import, then update their + * entries in the queue to mark them as valid/invalid. + * + * No database lock is required. + */ + private void validateTransactionsInQueue() { + if (this.incomingTransactions.isEmpty()) { + // Nothing to do? + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + // Take a snapshot of incomingTransactions, so we don't need to lock it while processing + Map incomingTransactionsCopy = Map.copyOf(this.incomingTransactions); + + int unvalidatedCount = Collections.frequency(incomingTransactionsCopy.values(), Boolean.FALSE); + int validatedCount = 0; + + if (unvalidatedCount > 0) { + LOGGER.debug("Validating signatures in incoming transactions queue (size {})...", unvalidatedCount); + } + + // A list of all currently pending transactions that have valid signatures + List sigValidTransactions = new ArrayList<>(); + + // A list of signatures that became valid in this round + List newlyValidSignatures = new ArrayList<>(); + + boolean isLiteNode = Settings.getInstance().isLite(); + + // We need the latest block in order to check for expired transactions + BlockData latestBlock = Controller.getInstance().getChainTip(); + + // Signature validation round - does not require blockchain lock + for (Map.Entry transactionEntry : incomingTransactionsCopy.entrySet()) { + // Quick exit? + if (isStopping) { + return; + } + + TransactionData transactionData = transactionEntry.getKey(); + Transaction transaction = Transaction.fromData(repository, transactionData); + String signature58 = Base58.encode(transactionData.getSignature()); + + Long now = NTP.getTime(); + if (now == null) { + return; + } + + // Drop expired transactions before they are considered "sig valid" + if (latestBlock != null && transaction.getDeadline() <= latestBlock.getTimestamp()) { + LOGGER.debug("Removing expired {} transaction {} from import queue", transactionData.getType().name(), signature58); + removeIncomingTransaction(transactionData.getSignature()); + invalidUnconfirmedTransactions.put(signature58, (now + EXPIRED_TRANSACTION_RECHECK_INTERVAL)); + continue; + } + + // Only validate signature if we haven't already done so + Boolean isSigValid = transactionEntry.getValue(); + if (!Boolean.TRUE.equals(isSigValid)) { + if (isLiteNode) { + // Lite nodes can't easily validate transactions, so for now we will have to assume that everything is valid + sigValidTransactions.add(transaction); + newlyValidSignatures.add(transactionData.getSignature()); + // Add mark signature as valid if transaction still exists in import queue + incomingTransactions.computeIfPresent(transactionData, (k, v) -> Boolean.TRUE); + continue; + } + + if (!transaction.isSignatureValid()) { + LOGGER.debug("Ignoring {} transaction {} with invalid signature", transactionData.getType().name(), signature58); + removeIncomingTransaction(transactionData.getSignature()); + + // Also add to invalidIncomingTransactions map + now = NTP.getTime(); + if (now != null) { + Long expiry = now + INVALID_TRANSACTION_RECHECK_INTERVAL; + LOGGER.trace("Adding invalid transaction {} to invalidUnconfirmedTransactions...", signature58); + // Add to invalidUnconfirmedTransactions so that we don't keep requesting it + invalidUnconfirmedTransactions.put(signature58, expiry); + } + + // We're done with this transaction + continue; + } + + // Count the number that were validated in this round, for logging purposes + validatedCount++; + + // Add mark signature as valid if transaction still exists in import queue + incomingTransactions.computeIfPresent(transactionData, (k, v) -> Boolean.TRUE); + + // Signature validated in this round + newlyValidSignatures.add(transactionData.getSignature()); + + } else { + LOGGER.trace(() -> String.format("Transaction %s known to have valid signature", Base58.encode(transactionData.getSignature()))); + } + + // Signature valid - add to shortlist + sigValidTransactions.add(transaction); + } + + if (unvalidatedCount > 0) { + LOGGER.debug("Finished validating signatures in incoming transactions queue (valid this round: {}, total pending import: {})...", validatedCount, sigValidTransactions.size()); + } + + } catch (DataException e) { + LOGGER.error("Repository issue while processing incoming transactions", e); + } + } + + /** + * Import any transactions in the queue that have valid signatures. + * + * A database lock is required. + */ + private void importTransactionsInQueue() { + List sigValidTransactions = this.getCachedSigValidTransactions(); + if (sigValidTransactions.isEmpty()) { + // Don't bother locking if there are no new transactions to process + return; + } + + if (Synchronizer.getInstance().isSyncRequested() || Synchronizer.getInstance().isSynchronizing()) { + // Prioritize syncing, and don't attempt to lock + return; + } + + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + if (!blockchainLock.tryLock()) { + LOGGER.debug("Too busy to import incoming transactions queue"); + return; + } + + LOGGER.debug("Importing incoming transactions queue (size {})...", sigValidTransactions.size()); + + 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; + + // 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) { + if (isStopping) { + return; + } + + if (Synchronizer.getInstance().isSyncRequestPending()) { + LOGGER.debug("Breaking out of transaction importing with {} remaining, because a sync request is pending", sigValidTransactions.size() - i); + return; + } + + TransactionData transactionData = sigValidTransactions.get(i); + Transaction transaction = Transaction.fromData(repository, transactionData); + + Transaction.ValidationResult validationResult = transaction.importAsUnconfirmed(); + processedCount++; + + switch (validationResult) { + case TRANSACTION_ALREADY_EXISTS: { + LOGGER.trace(() -> String.format("Ignoring existing transaction %s", Base58.encode(transactionData.getSignature()))); + break; + } + + case NO_BLOCKCHAIN_LOCK: { + // Is this even possible considering we acquired blockchain lock above? + LOGGER.trace(() -> String.format("Couldn't lock blockchain to import unconfirmed transaction %s", Base58.encode(transactionData.getSignature()))); + break; + } + + 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); + } + + // Signature imported in this round + newlyImportedSignatures.add(transactionData.getSignature()); + + break; + } + + // All other invalid cases: + default: { + final String signature58 = Base58.encode(transactionData.getSignature()); + LOGGER.debug(() -> String.format("Ignoring invalid (%s) %s transaction %s", validationResult.name(), transactionData.getType().name(), signature58)); + + Long now = NTP.getTime(); + if (now != null && now - transactionData.getTimestamp() > INVALID_TRANSACTION_STALE_TIMEOUT) { + Long expiryLength = INVALID_TRANSACTION_RECHECK_INTERVAL; + + if (validationResult == Transaction.ValidationResult.TIMESTAMP_TOO_OLD) { + // Use shorter recheck interval for expired transactions + expiryLength = EXPIRED_TRANSACTION_RECHECK_INTERVAL; + } + + Long expiry = now + expiryLength; + LOGGER.trace("Adding stale invalid transaction {} to invalidUnconfirmedTransactions...", signature58); + // Invalid, unconfirmed transaction has become stale - add to invalidUnconfirmedTransactions so that we don't keep requesting it + invalidUnconfirmedTransactions.put(signature58, expiry); + } + } + } + + // 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); + RNSNetwork.getInstance().broadcast(broadcastPeer -> newTransactionSignatureMessage); + } + } 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); + } + } + + private void cleanupInvalidTransactionsList(Long now) { + if (now == null) { + return; + } + // Periodically remove invalid unconfirmed transactions from the list, so that they can be fetched again + invalidUnconfirmedTransactions.entrySet().removeIf(entry -> entry.getValue() == null || entry.getValue() < now); + } + + + // Network handlers + + public void onNetworkTransactionMessage(RNSPeer peer, Message message) { + TransactionMessage transactionMessage = (TransactionMessage) message; + TransactionData transactionData = transactionMessage.getTransactionData(); + + if (this.incomingTransactions.size() < MAX_INCOMING_TRANSACTIONS) { + synchronized (this.incomingTransactions) { + if (!incomingTransactionQueueContains(transactionData.getSignature())) { + this.incomingTransactions.put(transactionData, Boolean.FALSE); + } + } + } + } + + public void onNetworkGetTransactionMessage(RNSPeer peer, Message message) { + GetTransactionMessage getTransactionMessage = (GetTransactionMessage) message; + byte[] signature = getTransactionMessage.getSignature(); + + try (final Repository repository = RepositoryManager.getRepository()) { + // Firstly check the sig-valid transactions that are currently queued for import + TransactionData transactionData = this.getCachedSigValidTransactions().stream() + .filter(t -> Arrays.equals(signature, t.getSignature())) + .findFirst().orElse(null); + + if (transactionData == null) { + // Not found in import queue, so try the database + transactionData = repository.getTransactionRepository().fromSignature(signature); + } + + if (transactionData == null) { + // Still not found - so we don't have this transaction + LOGGER.debug(() -> String.format("Ignoring GET_TRANSACTION request from peer %s for unknown transaction %s", peer, Base58.encode(signature))); + // Send no response at all??? + return; + } + + Message transactionMessage = new TransactionMessage(transactionData); + transactionMessage.setId(message.getId()); + peer.sendMessage(transactionMessage); + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending transaction %s to peer %s", Base58.encode(signature), peer), e); + } catch (TransformationException e) { + LOGGER.error(String.format("Serialization issue while sending transaction %s to peer %s", Base58.encode(signature), peer), e); + } + } + + public void onNetworkGetUnconfirmedTransactionsMessage(RNSPeer peer, Message message) { + try (final Repository repository = RepositoryManager.getRepository()) { + List signatures = Collections.emptyList(); + + // If we're NOT up-to-date then don't send out unconfirmed transactions + // as it's possible they are already included in a later block that we don't have. + if (Controller.getInstance().isUpToDate()) + signatures = repository.getTransactionRepository().getUnconfirmedTransactionSignatures(); + + Message transactionSignaturesMessage = new TransactionSignaturesMessage(signatures); + peer.sendMessage(transactionSignaturesMessage); + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending unconfirmed transaction signatures to peer %s", peer), e); + } + } + + public void onNetworkTransactionSignaturesMessage(RNSPeer peer, Message message) { + TransactionSignaturesMessage transactionSignaturesMessage = (TransactionSignaturesMessage) message; + List signatures = transactionSignaturesMessage.getSignatures(); + + try (final Repository repository = RepositoryManager.getRepository()) { + for (byte[] signature : signatures) { + String signature58 = Base58.encode(signature); + if (invalidUnconfirmedTransactions.containsKey(signature58)) { + // Previously invalid transaction - don't keep requesting it + // It will be periodically removed from invalidUnconfirmedTransactions to allow for rechecks + continue; + } + + // Ignore if this transaction is in the queue + if (incomingTransactionQueueContains(signature)) { + LOGGER.trace(() -> String.format("Ignoring existing queued transaction %s from peer %s", Base58.encode(signature), peer)); + continue; + } + + // Do we have it already? (Before requesting transaction data itself) + if (repository.getTransactionRepository().exists(signature)) { + LOGGER.trace(() -> String.format("Ignoring existing transaction %s from peer %s", Base58.encode(signature), peer)); + continue; + } + + // Check isInterrupted() here and exit fast + if (Thread.currentThread().isInterrupted()) + return; + + // Fetch actual transaction data from peer + Message getTransactionMessage = new GetTransactionMessage(signature); + peer.sendMessage(getTransactionMessage); + } + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while processing unconfirmed transactions from peer %s", peer), e); + } + } + +} diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 306784f5..400e7965 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -118,8 +118,12 @@ public class Synchronizer extends Thread { } public static Synchronizer getInstance() { - if (instance == null) + if (instance == null) { instance = new Synchronizer(); + instance.setPriority(Settings.getInstance().getSynchronizerThreadPriority()); + + LOGGER.info("thread priority = " + instance.getPriority()); + } return instance; } diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataBuilderThread.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataBuilderThread.java index 11f613ae..7f70ac05 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataBuilderThread.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataBuilderThread.java @@ -14,6 +14,7 @@ import java.io.IOException; import java.util.Comparator; import java.util.Map; +import static java.lang.Thread.NORM_PRIORITY; import static org.qortal.data.arbitrary.ArbitraryResourceStatus.Status.NOT_PUBLISHED; @@ -28,6 +29,7 @@ public class ArbitraryDataBuilderThread implements Runnable { @Override public void run() { Thread.currentThread().setName("Arbitrary Data Builder Thread"); + Thread.currentThread().setPriority(NORM_PRIORITY); ArbitraryDataBuildManager buildManager = ArbitraryDataBuildManager.getInstance(); while (!Controller.isStopping()) { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCacheManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCacheManager.java index 36d53761..9accd9c7 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCacheManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCacheManager.java @@ -2,22 +2,30 @@ package org.qortal.controller.arbitrary; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.api.resource.TransactionsResource; import org.qortal.controller.Controller; import org.qortal.data.arbitrary.ArbitraryResourceData; import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.event.DataMonitorEvent; +import org.qortal.event.EventBus; import org.qortal.gui.SplashFrame; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.transaction.ArbitraryTransaction; -import org.qortal.transaction.Transaction; import org.qortal.utils.Base58; +import java.text.NumberFormat; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; public class ArbitraryDataCacheManager extends Thread { @@ -29,6 +37,11 @@ public class ArbitraryDataCacheManager extends Thread { /** Queue of arbitrary transactions that require cache updates */ private final List updateQueue = Collections.synchronizedList(new ArrayList<>()); + private static final NumberFormat FORMATTER = NumberFormat.getNumberInstance(); + + static { + FORMATTER.setGroupingUsed(true); + } public static synchronized ArbitraryDataCacheManager getInstance() { if (instance == null) { @@ -41,20 +54,26 @@ public class ArbitraryDataCacheManager extends Thread { @Override public void run() { Thread.currentThread().setName("Arbitrary Data Cache Manager"); + Thread.currentThread().setPriority(NORM_PRIORITY); try { while (!Controller.isStopping()) { - Thread.sleep(500L); + try { + Thread.sleep(500L); - // Process queue - processResourceQueue(); + // Process queue + processResourceQueue(); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + Thread.sleep(600_000L); // wait 10 minutes to continue + } } - } catch (InterruptedException e) { - // Fall through to exit thread - } - // Clear queue before terminating thread - processResourceQueue(); + // Clear queue before terminating thread + processResourceQueue(); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + } } public void shutdown() { @@ -84,14 +103,25 @@ public class ArbitraryDataCacheManager extends Thread { // Update arbitrary resource caches try { ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData); - arbitraryTransaction.updateArbitraryResourceCache(repository); - arbitraryTransaction.updateArbitraryMetadataCache(repository); + arbitraryTransaction.updateArbitraryResourceCacheIncludingMetadata(repository, new HashSet<>(0), new HashMap<>(0)); repository.saveChanges(); // Update status as separate commit, as this is more prone to failure arbitraryTransaction.updateArbitraryResourceStatus(repository); repository.saveChanges(); + EventBus.INSTANCE.notify( + new DataMonitorEvent( + System.currentTimeMillis(), + transactionData.getIdentifier(), + transactionData.getName(), + transactionData.getService().name(), + "updated resource cache and status, queue", + transactionData.getTimestamp(), + transactionData.getTimestamp() + ) + ); + LOGGER.debug(() -> String.format("Finished processing transaction %.8s in arbitrary resource queue...", Base58.encode(transactionData.getSignature()))); } catch (DataException e) { @@ -102,6 +132,9 @@ public class ArbitraryDataCacheManager extends Thread { } catch (DataException e) { LOGGER.error("Repository issue while processing arbitrary resource cache updates", e); } + catch (Exception e) { + LOGGER.error(e.getMessage(), e); + } } public void addToUpdateQueue(ArbitraryTransactionData transactionData) { @@ -147,34 +180,66 @@ public class ArbitraryDataCacheManager extends Thread { LOGGER.info("Building arbitrary resources cache..."); SplashFrame.getInstance().updateStatus("Building QDN cache - please wait..."); - final int batchSize = 100; + final int batchSize = Settings.getInstance().getBuildArbitraryResourcesBatchSize(); int offset = 0; + List allArbitraryTransactionsInDescendingOrder + = repository.getArbitraryRepository().getLatestArbitraryTransactions(); + + LOGGER.info("arbitrary transactions: count = " + allArbitraryTransactionsInDescendingOrder.size()); + + List resources = repository.getArbitraryRepository().getArbitraryResources(null, null, true); + + Map resourceByWrapper = new HashMap<>(resources.size()); + for( ArbitraryResourceData resource : resources ) { + resourceByWrapper.put( + new ArbitraryTransactionDataHashWrapper(resource.service.value, resource.name, resource.identifier), + resource + ); + } + + LOGGER.info("arbitrary resources: count = " + resourceByWrapper.size()); + + Set latestTransactionsWrapped = new HashSet<>(allArbitraryTransactionsInDescendingOrder.size()); + // Loop through all ARBITRARY transactions, and determine latest state while (!Controller.isStopping()) { - LOGGER.info("Fetching arbitrary transactions {} - {}", offset, offset+batchSize-1); + LOGGER.info( + "Fetching arbitrary transactions {} - {} / {} Total", + FORMATTER.format(offset), + FORMATTER.format(offset+batchSize-1), + FORMATTER.format(allArbitraryTransactionsInDescendingOrder.size()) + ); - List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, List.of(Transaction.TransactionType.ARBITRARY), null, null, null, TransactionsResource.ConfirmationStatus.BOTH, batchSize, offset, false); - if (signatures.isEmpty()) { + List transactionsToProcess + = allArbitraryTransactionsInDescendingOrder.stream() + .skip(offset) + .limit(batchSize) + .collect(Collectors.toList()); + + if (transactionsToProcess.isEmpty()) { // Complete break; } - // Expand signatures to transactions - for (byte[] signature : signatures) { - ArbitraryTransactionData transactionData = (ArbitraryTransactionData) repository - .getTransactionRepository().fromSignature(signature); + try { + for( ArbitraryTransactionData transactionData : transactionsToProcess) { + if (transactionData.getService() == null) { + // Unsupported service - ignore this resource + continue; + } - if (transactionData.getService() == null) { - // Unsupported service - ignore this resource - continue; + latestTransactionsWrapped.add(new ArbitraryTransactionDataHashWrapper(transactionData)); + + // Update arbitrary resource caches + ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData); + arbitraryTransaction.updateArbitraryResourceCacheIncludingMetadata(repository, latestTransactionsWrapped, resourceByWrapper); } - - // Update arbitrary resource caches - ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData); - arbitraryTransaction.updateArbitraryResourceCache(repository); - arbitraryTransaction.updateArbitraryMetadataCache(repository); repository.saveChanges(); + } catch (DataException e) { + repository.discardChanges(); + + LOGGER.error(e.getMessage(), e); } offset += batchSize; } @@ -192,6 +257,11 @@ public class ArbitraryDataCacheManager extends Thread { repository.discardChanges(); throw new DataException("Build of arbitrary resources cache failed."); } + catch (Exception e) { + LOGGER.error(e.getMessage(), e); + + return false; + } } private boolean refreshArbitraryStatuses(Repository repository) throws DataException { @@ -199,27 +269,48 @@ public class ArbitraryDataCacheManager extends Thread { LOGGER.info("Refreshing arbitrary resource statuses for locally hosted transactions..."); SplashFrame.getInstance().updateStatus("Refreshing statuses - please wait..."); - final int batchSize = 100; + final int batchSize = Settings.getInstance().getBuildArbitraryResourcesBatchSize(); int offset = 0; + List allHostedTransactions + = ArbitraryDataStorageManager.getInstance() + .listAllHostedTransactions(repository, null, null); + // Loop through all ARBITRARY transactions, and determine latest state while (!Controller.isStopping()) { - LOGGER.info("Fetching hosted transactions {} - {}", offset, offset+batchSize-1); + LOGGER.info( + "Fetching hosted transactions {} - {} / {} Total", + FORMATTER.format(offset), + FORMATTER.format(offset+batchSize-1), + FORMATTER.format(allHostedTransactions.size()) + ); + + List hostedTransactions + = allHostedTransactions.stream() + .skip(offset) + .limit(batchSize) + .collect(Collectors.toList()); - List hostedTransactions = ArbitraryDataStorageManager.getInstance().listAllHostedTransactions(repository, batchSize, offset); if (hostedTransactions.isEmpty()) { // Complete break; } - // Loop through hosted transactions - for (ArbitraryTransactionData transactionData : hostedTransactions) { + try { + // Loop through hosted transactions + for (ArbitraryTransactionData transactionData : hostedTransactions) { - // Determine status and update cache - ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData); - arbitraryTransaction.updateArbitraryResourceStatus(repository); + // Determine status and update cache + ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData); + arbitraryTransaction.updateArbitraryResourceStatus(repository); + } repository.saveChanges(); + } catch (DataException e) { + repository.discardChanges(); + + LOGGER.error(e.getMessage(), e); } + offset += batchSize; } @@ -233,6 +324,11 @@ public class ArbitraryDataCacheManager extends Thread { repository.discardChanges(); throw new DataException("Refresh of arbitrary resource statuses failed."); } + catch (Exception e) { + LOGGER.error(e.getMessage(), e); + + return false; + } } } diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java index 7b434acb..ce4dd565 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataCleanupManager.java @@ -2,9 +2,10 @@ package org.qortal.controller.arbitrary; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; import org.qortal.data.transaction.ArbitraryTransactionData; import org.qortal.data.transaction.TransactionData; +import org.qortal.event.DataMonitorEvent; +import org.qortal.event.EventBus; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -21,8 +22,12 @@ import java.nio.file.Paths; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import static org.qortal.controller.arbitrary.ArbitraryDataStorageManager.DELETION_THRESHOLD; @@ -71,11 +76,25 @@ public class ArbitraryDataCleanupManager extends Thread { @Override public void run() { Thread.currentThread().setName("Arbitrary Data Cleanup Manager"); + Thread.currentThread().setPriority(NORM_PRIORITY); // Paginate queries when fetching arbitrary transactions final int limit = 100; int offset = 0; + List allArbitraryTransactionsInDescendingOrder; + + try (final Repository repository = RepositoryManager.getRepository()) { + allArbitraryTransactionsInDescendingOrder + = repository.getArbitraryRepository() + .getLatestArbitraryTransactions(); + } catch( Exception e) { + LOGGER.error(e.getMessage(), e); + allArbitraryTransactionsInDescendingOrder = new ArrayList<>(0); + } + + Set processedTransactions = new HashSet<>(); + try { while (!isStopping) { Thread.sleep(30000); @@ -106,27 +125,31 @@ public class ArbitraryDataCleanupManager extends Thread { // Any arbitrary transactions we want to fetch data for? try (final Repository repository = RepositoryManager.getRepository()) { - List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, ARBITRARY_TX_TYPE, null, null, null, ConfirmationStatus.BOTH, limit, offset, true); - // LOGGER.info("Found {} arbitrary transactions at offset: {}, limit: {}", signatures.size(), offset, limit); + List transactions = allArbitraryTransactionsInDescendingOrder.stream().skip(offset).limit(limit).collect(Collectors.toList()); if (isStopping) { return; } - if (signatures == null || signatures.isEmpty()) { + if (transactions == null || transactions.isEmpty()) { offset = 0; - continue; + allArbitraryTransactionsInDescendingOrder + = repository.getArbitraryRepository() + .getLatestArbitraryTransactions(); + transactions = allArbitraryTransactionsInDescendingOrder.stream().limit(limit).collect(Collectors.toList()); + processedTransactions.clear(); } + offset += limit; now = NTP.getTime(); // Loop through the signatures in this batch - for (int i=0; i moreRecentPutTransaction + = processedTransactions.stream() + .filter(data -> data.equals(arbitraryTransactionData)) + .findAny(); + + if( moreRecentPutTransaction.isPresent() ) { + EventBus.INSTANCE.notify( + new DataMonitorEvent( + System.currentTimeMillis(), + arbitraryTransactionData.getIdentifier(), + arbitraryTransactionData.getName(), + arbitraryTransactionData.getService().name(), + "deleting data due to replacement", + arbitraryTransactionData.getTimestamp(), + moreRecentPutTransaction.get().getTimestamp() + ) + ); + } + else { + LOGGER.warn("Something went wrong with the most recent put transaction determination!"); + } + continue; } @@ -198,7 +255,21 @@ public class ArbitraryDataCleanupManager extends Thread { LOGGER.debug(String.format("Transaction %s has complete file and all chunks", Base58.encode(arbitraryTransactionData.getSignature()))); - ArbitraryTransactionUtils.deleteCompleteFile(arbitraryTransactionData, now, STALE_FILE_TIMEOUT); + boolean wasDeleted = ArbitraryTransactionUtils.deleteCompleteFile(arbitraryTransactionData, now, STALE_FILE_TIMEOUT); + + if( wasDeleted ) { + EventBus.INSTANCE.notify( + new DataMonitorEvent( + System.currentTimeMillis(), + arbitraryTransactionData.getIdentifier(), + arbitraryTransactionData.getName(), + arbitraryTransactionData.getService().name(), + "deleting file, retaining chunks", + arbitraryTransactionData.getTimestamp(), + arbitraryTransactionData.getTimestamp() + ) + ); + } continue; } @@ -236,17 +307,6 @@ public class ArbitraryDataCleanupManager extends Thread { this.storageLimitReached(repository); } - // Delete random data associated with name if we're over our storage limit for this name - // Use the DELETION_THRESHOLD, for the same reasons as above - for (String followedName : ListUtils.followedNames()) { - if (isStopping) { - return; - } - if (!storageManager.isStorageSpaceAvailableForName(repository, followedName, DELETION_THRESHOLD)) { - this.storageLimitReachedForName(repository, followedName); - } - } - } catch (DataException e) { LOGGER.error("Repository issue when cleaning up arbitrary transaction data", e); } @@ -325,25 +385,6 @@ public class ArbitraryDataCleanupManager extends Thread { // FUTURE: consider reducing the expiry time of the reader cache } - public void storageLimitReachedForName(Repository repository, String name) throws InterruptedException { - // We think that the storage limit has been reached for supplied name - but we should double check - if (ArbitraryDataStorageManager.getInstance().isStorageSpaceAvailableForName(repository, name, DELETION_THRESHOLD)) { - // We have space available for this name, so don't delete anything - return; - } - - // Delete a batch of random chunks associated with this name - // This reduces the chance of too many nodes deleting the same chunk - // when they reach their storage limit - Path dataPath = Paths.get(Settings.getInstance().getDataPath()); - for (int i=0; i allArbitraryTransactionsInDescendingOrder; + + try (final Repository repository = RepositoryManager.getRepository()) { + + if( name == null ) { + allArbitraryTransactionsInDescendingOrder + = repository.getArbitraryRepository() + .getLatestArbitraryTransactions(); + } + else { + allArbitraryTransactionsInDescendingOrder + = repository.getArbitraryRepository() + .getLatestArbitraryTransactionsByName(name); + } + } catch( Exception e) { + LOGGER.error(e.getMessage(), e); + allArbitraryTransactionsInDescendingOrder = new ArrayList<>(0); + } + + // collect processed transactions in a set to ensure outdated data transactions do not get fetched + Set processedTransactions = new HashSet<>(); + while (!isStopping) { Thread.sleep(1000L); // Any arbitrary transactions we want to fetch data for? try (final Repository repository = RepositoryManager.getRepository()) { - List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, ARBITRARY_TX_TYPE, null, name, null, ConfirmationStatus.BOTH, limit, offset, true); - // LOGGER.trace("Found {} arbitrary transactions at offset: {}, limit: {}", signatures.size(), offset, limit); + List signatures = processTransactionsForSignatures(limit, offset, allArbitraryTransactionsInDescendingOrder, processedTransactions); + if (signatures == null || signatures.isEmpty()) { offset = 0; break; @@ -222,14 +248,38 @@ public class ArbitraryDataManager extends Thread { ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) arbitraryTransaction.getTransactionData(); // Skip transactions that we don't need to proactively store data for - if (!storageManager.shouldPreFetchData(repository, arbitraryTransactionData)) { + ArbitraryDataExamination arbitraryDataExamination = storageManager.shouldPreFetchData(repository, arbitraryTransactionData); + if (!arbitraryDataExamination.isPass()) { iterator.remove(); + + EventBus.INSTANCE.notify( + new DataMonitorEvent( + System.currentTimeMillis(), + arbitraryTransactionData.getIdentifier(), + arbitraryTransactionData.getName(), + arbitraryTransactionData.getService().name(), + arbitraryDataExamination.getNotes(), + arbitraryTransactionData.getTimestamp(), + arbitraryTransactionData.getTimestamp() + ) + ); continue; } // Remove transactions that we already have local data for if (hasLocalData(arbitraryTransaction)) { iterator.remove(); + EventBus.INSTANCE.notify( + new DataMonitorEvent( + System.currentTimeMillis(), + arbitraryTransactionData.getIdentifier(), + arbitraryTransactionData.getName(), + arbitraryTransactionData.getService().name(), + "already have local data, skipping", + arbitraryTransactionData.getTimestamp(), + arbitraryTransactionData.getTimestamp() + ) + ); } } @@ -247,8 +297,21 @@ public class ArbitraryDataManager extends Thread { // Check to see if we have had a more recent PUT ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); - boolean hasMoreRecentPutTransaction = ArbitraryTransactionUtils.hasMoreRecentPutTransaction(repository, arbitraryTransactionData); - if (hasMoreRecentPutTransaction) { + + Optional moreRecentPutTransaction = ArbitraryTransactionUtils.hasMoreRecentPutTransaction(repository, arbitraryTransactionData); + + if (moreRecentPutTransaction.isPresent()) { + EventBus.INSTANCE.notify( + new DataMonitorEvent( + System.currentTimeMillis(), + arbitraryTransactionData.getIdentifier(), + arbitraryTransactionData.getName(), + arbitraryTransactionData.getService().name(), + "not fetching old data", + arbitraryTransactionData.getTimestamp(), + moreRecentPutTransaction.get().getTimestamp() + ) + ); // There is a more recent PUT transaction than the one we are currently processing. // When a PUT is issued, it replaces any layers that would have been there before. // Therefore any data relating to this older transaction is no longer needed and we @@ -256,10 +319,34 @@ public class ArbitraryDataManager extends Thread { continue; } + EventBus.INSTANCE.notify( + new DataMonitorEvent( + System.currentTimeMillis(), + arbitraryTransactionData.getIdentifier(), + arbitraryTransactionData.getName(), + arbitraryTransactionData.getService().name(), + "fetching data", + arbitraryTransactionData.getTimestamp(), + arbitraryTransactionData.getTimestamp() + ) + ); + // Ask our connected peers if they have files for this signature // This process automatically then fetches the files themselves if a peer is found fetchData(arbitraryTransactionData); + EventBus.INSTANCE.notify( + new DataMonitorEvent( + System.currentTimeMillis(), + arbitraryTransactionData.getIdentifier(), + arbitraryTransactionData.getName(), + arbitraryTransactionData.getService().name(), + "fetched data", + arbitraryTransactionData.getTimestamp(), + arbitraryTransactionData.getTimestamp() + ) + ); + } catch (DataException e) { LOGGER.error("Repository issue when fetching arbitrary transaction data", e); } @@ -273,6 +360,20 @@ public class ArbitraryDataManager extends Thread { final int limit = 100; int offset = 0; + List allArbitraryTransactionsInDescendingOrder; + + try (final Repository repository = RepositoryManager.getRepository()) { + allArbitraryTransactionsInDescendingOrder + = repository.getArbitraryRepository() + .getLatestArbitraryTransactions(); + } catch( Exception e) { + LOGGER.error(e.getMessage(), e); + allArbitraryTransactionsInDescendingOrder = new ArrayList<>(0); + } + + // collect processed transactions in a set to ensure outdated data transactions do not get fetched + Set processedTransactions = new HashSet<>(); + while (!isStopping) { final int minSeconds = 3; final int maxSeconds = 10; @@ -281,8 +382,8 @@ public class ArbitraryDataManager extends Thread { // Any arbitrary transactions we want to fetch data for? try (final Repository repository = RepositoryManager.getRepository()) { - List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, ARBITRARY_TX_TYPE, null, null, null, ConfirmationStatus.BOTH, limit, offset, true); - // LOGGER.trace("Found {} arbitrary transactions at offset: {}, limit: {}", signatures.size(), offset, limit); + List signatures = processTransactionsForSignatures(limit, offset, allArbitraryTransactionsInDescendingOrder, processedTransactions); + if (signatures == null || signatures.isEmpty()) { offset = 0; break; @@ -327,26 +428,74 @@ public class ArbitraryDataManager extends Thread { continue; } - // Check to see if we have had a more recent PUT + // No longer need to see if we have had a more recent PUT since we compared the transactions to process + // to the transactions previously processed, so we can fetch the transactiondata, notify the event bus, + // fetch the metadata and notify the event bus again ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); - boolean hasMoreRecentPutTransaction = ArbitraryTransactionUtils.hasMoreRecentPutTransaction(repository, arbitraryTransactionData); - if (hasMoreRecentPutTransaction) { - // There is a more recent PUT transaction than the one we are currently processing. - // When a PUT is issued, it replaces any layers that would have been there before. - // Therefore any data relating to this older transaction is no longer needed and we - // shouldn't fetch it from the network. - continue; - } // Ask our connected peers if they have metadata for this signature fetchMetadata(arbitraryTransactionData); + EventBus.INSTANCE.notify( + new DataMonitorEvent( + System.currentTimeMillis(), + arbitraryTransactionData.getIdentifier(), + arbitraryTransactionData.getName(), + arbitraryTransactionData.getService().name(), + "fetched metadata", + arbitraryTransactionData.getTimestamp(), + arbitraryTransactionData.getTimestamp() + ) + ); } catch (DataException e) { LOGGER.error("Repository issue when fetching arbitrary transaction data", e); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); } } } + private static List processTransactionsForSignatures( + int limit, + int offset, + List transactionsInDescendingOrder, + Set processedTransactions) { + // these transactions are in descending order, latest transactions come first + List transactions + = transactionsInDescendingOrder.stream() + .skip(offset) + .limit(limit) + .collect(Collectors.toList()); + + // wrap the transactions, so they can be used for hashing and comparing + // Class ArbitraryTransactionDataHashWrapper supports hashCode() and equals(...) for this purpose + List wrappedTransactions + = transactions.stream() + .map(transaction -> new ArbitraryTransactionDataHashWrapper(transaction)) + .collect(Collectors.toList()); + + // create a set of wrappers and populate it first to last, so that all outdated transactions get rejected + Set transactionsToProcess = new HashSet<>(wrappedTransactions.size()); + for(ArbitraryTransactionDataHashWrapper wrappedTransaction : wrappedTransactions) { + transactionsToProcess.add(wrappedTransaction); + } + + // remove the matches for previously processed transactions, + // because these transactions have had updates that have already been processed + transactionsToProcess.removeAll(processedTransactions); + + // add to processed transactions to compare and remove matches from future processing iterations + processedTransactions.addAll(transactionsToProcess); + + List signatures + = transactionsToProcess.stream() + .map(transactionToProcess -> transactionToProcess.getData() + .getSignature()) + .collect(Collectors.toList()); + + return signatures; + } + private ArbitraryTransaction fetchTransaction(final Repository repository, byte[] signature) { try { TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataRenderManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataRenderManager.java index 809db7af..c2a720fa 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataRenderManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataRenderManager.java @@ -36,6 +36,7 @@ public class ArbitraryDataRenderManager extends Thread { @Override public void run() { Thread.currentThread().setName("Arbitrary Data Render Manager"); + Thread.currentThread().setPriority(NORM_PRIORITY); try { while (!isStopping) { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java index 91cb9965..c54a1e12 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataStorageManager.java @@ -72,6 +72,8 @@ public class ArbitraryDataStorageManager extends Thread { @Override public void run() { Thread.currentThread().setName("Arbitrary Data Storage Manager"); + Thread.currentThread().setPriority(NORM_PRIORITY); + try { while (!isStopping) { Thread.sleep(1000); @@ -153,31 +155,24 @@ public class ArbitraryDataStorageManager extends Thread { * @param arbitraryTransactionData - the transaction * @return boolean - whether to prefetch or not */ - public boolean shouldPreFetchData(Repository repository, ArbitraryTransactionData arbitraryTransactionData) { + public ArbitraryDataExamination shouldPreFetchData(Repository repository, ArbitraryTransactionData arbitraryTransactionData) { String name = arbitraryTransactionData.getName(); // Only fetch data associated with hashes, as we already have RAW_DATA if (arbitraryTransactionData.getDataType() != ArbitraryTransactionData.DataType.DATA_HASH) { - return false; + return new ArbitraryDataExamination(false, "Only fetch data associated with hashes"); } // Don't fetch anything more if we're (nearly) out of space // Make sure to keep STORAGE_FULL_THRESHOLD considerably less than 1, to // avoid a fetch/delete loop if (!this.isStorageSpaceAvailable(STORAGE_FULL_THRESHOLD)) { - return false; - } - - // Don't fetch anything if we're (nearly) out of space for this name - // Again, make sure to keep STORAGE_FULL_THRESHOLD considerably less than 1, to - // avoid a fetch/delete loop - if (!this.isStorageSpaceAvailableForName(repository, arbitraryTransactionData.getName(), STORAGE_FULL_THRESHOLD)) { - return false; + return new ArbitraryDataExamination(false,"Don't fetch anything more if we're (nearly) out of space"); } // Don't store data unless it's an allowed type (public/private) if (!this.isDataTypeAllowed(arbitraryTransactionData)) { - return false; + return new ArbitraryDataExamination(false, "Don't store data unless it's an allowed type (public/private)"); } // Handle transactions without names differently @@ -187,21 +182,21 @@ public class ArbitraryDataStorageManager extends Thread { // Never fetch data from blocked names, even if they are followed if (ListUtils.isNameBlocked(name)) { - return false; + return new ArbitraryDataExamination(false, "blocked name"); } switch (Settings.getInstance().getStoragePolicy()) { case FOLLOWED: case FOLLOWED_OR_VIEWED: - return ListUtils.isFollowingName(name); + return new ArbitraryDataExamination(ListUtils.isFollowingName(name), Settings.getInstance().getStoragePolicy().name()); case ALL: - return true; + return new ArbitraryDataExamination(true, Settings.getInstance().getStoragePolicy().name()); case NONE: case VIEWED: default: - return false; + return new ArbitraryDataExamination(false, Settings.getInstance().getStoragePolicy().name()); } } @@ -212,17 +207,17 @@ public class ArbitraryDataStorageManager extends Thread { * * @return boolean - whether the storage policy allows for unnamed data */ - private boolean shouldPreFetchDataWithoutName() { + private ArbitraryDataExamination shouldPreFetchDataWithoutName() { switch (Settings.getInstance().getStoragePolicy()) { case ALL: - return true; + return new ArbitraryDataExamination(true, "Fetching all data"); case NONE: case VIEWED: case FOLLOWED: case FOLLOWED_OR_VIEWED: default: - return false; + return new ArbitraryDataExamination(false, Settings.getInstance().getStoragePolicy().name()); } } @@ -482,51 +477,6 @@ public class ArbitraryDataStorageManager extends Thread { return true; } - public boolean isStorageSpaceAvailableForName(Repository repository, String name, double threshold) { - if (!this.isStorageSpaceAvailable(threshold)) { - // No storage space available at all, so no need to check this name - 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; - } - - int followedNamesCount = ListUtils.followedNamesCount(); - if (followedNamesCount == 0) { - // Not following any names, so we have space - return true; - } - - long totalSizeForName = 0; - long maxStoragePerName = this.storageCapacityPerName(threshold); - - // Fetch all hosted transactions - List hostedTransactions = this.listAllHostedTransactions(repository, null, null); - for (ArbitraryTransactionData transactionData : hostedTransactions) { - String transactionName = transactionData.getName(); - if (!Objects.equals(name, transactionName)) { - // Transaction relates to a different name - continue; - } - - totalSizeForName += transactionData.getSize(); - } - - // Have we reached the limit for this name? - if (totalSizeForName > maxStoragePerName) { - return false; - } - - return true; - } - public long storageCapacityPerName(double threshold) { int followedNamesCount = ListUtils.followedNamesCount(); if (followedNamesCount == 0) { diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryTransactionDataHashWrapper.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryTransactionDataHashWrapper.java new file mode 100644 index 00000000..9ff40771 --- /dev/null +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryTransactionDataHashWrapper.java @@ -0,0 +1,48 @@ +package org.qortal.controller.arbitrary; + +import org.qortal.arbitrary.misc.Service; +import org.qortal.data.transaction.ArbitraryTransactionData; + +import java.util.Objects; + +public class ArbitraryTransactionDataHashWrapper { + + private ArbitraryTransactionData data; + + private int service; + + private String name; + + private String identifier; + + public ArbitraryTransactionDataHashWrapper(ArbitraryTransactionData data) { + this.data = data; + + this.service = data.getService().value; + this.name = data.getName(); + this.identifier = data.getIdentifier(); + } + + public ArbitraryTransactionDataHashWrapper(int service, String name, String identifier) { + this.service = service; + this.name = name; + this.identifier = identifier; + } + + public ArbitraryTransactionData getData() { + return data; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ArbitraryTransactionDataHashWrapper that = (ArbitraryTransactionDataHashWrapper) o; + return service == that.service && name.equals(that.name) && Objects.equals(identifier, that.identifier); + } + + @Override + public int hashCode() { + return Objects.hash(service, name, identifier); + } +} diff --git a/src/main/java/org/qortal/controller/arbitrary/RNSArbitraryDataFileListManager.java b/src/main/java/org/qortal/controller/arbitrary/RNSArbitraryDataFileListManager.java new file mode 100644 index 00000000..93c3cd11 --- /dev/null +++ b/src/main/java/org/qortal/controller/arbitrary/RNSArbitraryDataFileListManager.java @@ -0,0 +1,731 @@ +package org.qortal.controller.arbitrary; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.arbitrary.ArbitraryDataFile; +import org.qortal.arbitrary.ArbitraryDataFileChunk; +import org.qortal.controller.Controller; +import org.qortal.data.arbitrary.RNSArbitraryDirectConnectionInfo; +import org.qortal.data.arbitrary.RNSArbitraryFileListResponseInfo; +import org.qortal.data.arbitrary.RNSArbitraryRelayInfo; +import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.network.RNSNetwork; +import org.qortal.network.RNSPeer; +import org.qortal.network.message.ArbitraryDataFileListMessage; +import org.qortal.network.message.GetArbitraryDataFileListMessage; +import org.qortal.network.message.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.ListUtils; +import org.qortal.utils.NTP; +import org.qortal.utils.Triple; + +import java.util.*; + +import static org.qortal.controller.arbitrary.RNSArbitraryDataFileManager.MAX_FILE_HASH_RESPONSES; + +public class RNSArbitraryDataFileListManager { + + private static final Logger LOGGER = LogManager.getLogger(RNSArbitraryDataFileListManager.class); + + private static RNSArbitraryDataFileListManager instance; + + private static String MIN_PEER_VERSION_FOR_FILE_LIST_STATS = "3.2.0"; + + /** + * Map of recent incoming requests for ARBITRARY transaction data file lists. + *

+ * Key is original request's message ID
+ * Value is Triple<transaction signature in base58, first requesting peer, first request's timestamp> + *

+ * If peer is null then either:
+ *

    + *
  • we are the original requesting peer
  • + *
  • we have already sent data payload to original requesting peer.
  • + *
+ * If signature is null then we have already received the file list and either:
+ *
    + *
  • we are the original requesting peer and have processed it
  • + *
  • we have forwarded the file list
  • + *
+ */ + public Map> arbitraryDataFileListRequests = Collections.synchronizedMap(new HashMap<>()); + + /** + * Map to keep track of in progress arbitrary data signature requests + * Key: string - the signature encoded in base58 + * Value: Triple + */ + private Map> arbitraryDataSignatureRequests = Collections.synchronizedMap(new HashMap<>()); + + + /** Maximum number of seconds that a file list relay request is able to exist on the network */ + public static long RELAY_REQUEST_MAX_DURATION = 5000L; + /** Maximum number of hops that a file list relay request is allowed to make */ + public static int RELAY_REQUEST_MAX_HOPS = 4; + + /** Minimum peer version to use relay */ + public static String RELAY_MIN_PEER_VERSION = "3.4.0"; + + + private RNSArbitraryDataFileListManager() { + } + + public static RNSArbitraryDataFileListManager getInstance() { + if (instance == null) + instance = new RNSArbitraryDataFileListManager(); + + return instance; + } + + + public void cleanupRequestCache(Long now) { + if (now == null) { + return; + } + final long requestMinimumTimestamp = now - ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT; + arbitraryDataFileListRequests.entrySet().removeIf(entry -> entry.getValue().getC() == null || entry.getValue().getC() < requestMinimumTimestamp); + } + + + // Track file list lookups by signature + + private boolean shouldMakeFileListRequestForSignature(String signature58) { + Triple request = arbitraryDataSignatureRequests.get(signature58); + + if (request == null) { + // Not attempted yet + return true; + } + + // Extract the components + Integer networkBroadcastCount = request.getA(); + // Integer directPeerRequestCount = request.getB(); + Long lastAttemptTimestamp = request.getC(); + + if (lastAttemptTimestamp == null) { + // Not attempted yet + return true; + } + + long timeSinceLastAttempt = NTP.getTime() - lastAttemptTimestamp; + + // Allow a second attempt after 15 seconds, and another after 30 seconds + if (timeSinceLastAttempt > 15 * 1000L) { + // We haven't tried for at least 15 seconds + + if (networkBroadcastCount < 3) { + // We've made less than 3 total attempts + return true; + } + } + + // Then allow another 5 attempts, each 1 minute apart + if (timeSinceLastAttempt > 60 * 1000L) { + // We haven't tried for at least 1 minute + + if (networkBroadcastCount < 8) { + // We've made less than 8 total attempts + return true; + } + } + + // 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 < 16) { + // We've made less than 16 total attempts + return true; + } + } + + // 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; + } + + return false; + } + + private boolean shouldMakeDirectFileRequestsForSignature(String signature58) { + if (!Settings.getInstance().isDirectDataRetrievalEnabled()) { + // Direct connections are disabled in the settings + return false; + } + + Triple request = arbitraryDataSignatureRequests.get(signature58); + + if (request == null) { + // Not attempted yet + return true; + } + + // Extract the components + //Integer networkBroadcastCount = request.getA(); + Integer directPeerRequestCount = request.getB(); + Long lastAttemptTimestamp = request.getC(); + + if (lastAttemptTimestamp == null) { + // Not attempted yet + return true; + } + + if (directPeerRequestCount == 0) { + // We haven't tried asking peers directly yet, so we should + return true; + } + + long timeSinceLastAttempt = NTP.getTime() - lastAttemptTimestamp; + if (timeSinceLastAttempt > 10 * 1000L) { + // We haven't tried for at least 10 seconds + if (directPeerRequestCount < 5) { + // We've made less than 5 total attempts + return true; + } + } + + if (timeSinceLastAttempt > 5 * 60 * 1000L) { + // We haven't tried for at least 5 minutes + if (directPeerRequestCount < 10) { + // We've made less than 10 total attempts + return true; + } + } + + if (timeSinceLastAttempt > 60 * 60 * 1000L) { + // We haven't tried for at least 1 hour + return true; + } + + return false; + } + + public boolean isSignatureRateLimited(byte[] signature) { + String signature58 = Base58.encode(signature); + return !this.shouldMakeFileListRequestForSignature(signature58) + && !this.shouldMakeDirectFileRequestsForSignature(signature58); + } + + public long lastRequestForSignature(byte[] signature) { + String signature58 = Base58.encode(signature); + Triple request = arbitraryDataSignatureRequests.get(signature58); + + if (request == null) { + // Not attempted yet + return 0; + } + + // Extract the components + Long lastAttemptTimestamp = request.getC(); + if (lastAttemptTimestamp != null) { + return lastAttemptTimestamp; + } + return 0; + } + + public void addToSignatureRequests(String signature58, boolean incrementNetworkRequests, boolean incrementPeerRequests) { + Triple request = arbitraryDataSignatureRequests.get(signature58); + Long now = NTP.getTime(); + + if (request == null) { + // No entry yet + Triple newRequest = new Triple<>(0, 0, now); + arbitraryDataSignatureRequests.put(signature58, newRequest); + } + else { + // There is an existing entry + if (incrementNetworkRequests) { + request.setA(request.getA() + 1); + } + if (incrementPeerRequests) { + request.setB(request.getB() + 1); + } + request.setC(now); + arbitraryDataSignatureRequests.put(signature58, request); + } + } + + public void removeFromSignatureRequests(String signature58) { + arbitraryDataSignatureRequests.remove(signature58); + } + + + // Lookup file lists by signature (and optionally hashes) + + public boolean fetchArbitraryDataFileList(ArbitraryTransactionData arbitraryTransactionData) { + byte[] signature = arbitraryTransactionData.getSignature(); + String signature58 = Base58.encode(signature); + + // Require an NTP sync + Long now = NTP.getTime(); + if (now == null) { + return false; + } + + // If we've already tried too many times in a short space of time, make sure to give up + if (!this.shouldMakeFileListRequestForSignature(signature58)) { + // Check if we should make direct connections to peers + if (this.shouldMakeDirectFileRequestsForSignature(signature58)) { + return RNSArbitraryDataFileManager.getInstance().fetchDataFilesFromPeersForSignature(signature); + } + + LOGGER.trace("Skipping file list request for signature {} due to rate limit", signature58); + return false; + } + this.addToSignatureRequests(signature58, true, false); + + //List handshakedPeers = Network.getInstance().getImmutableHandshakedPeers(); + List handshakedPeers = RNSNetwork.getInstance().getLinkedPeers(); + List missingHashes = null; + + // Find hashes that we are missing + try { + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); + missingHashes = arbitraryDataFile.missingHashes(); + } catch (DataException e) { + // Leave missingHashes as null, so that all hashes are requested + } + int hashCount = missingHashes != null ? missingHashes.size() : 0; + + LOGGER.debug(String.format("Sending data file list request for signature %s with %d hashes to %d peers...", signature58, hashCount, handshakedPeers.size())); + + //// Send our address as requestingPeer, to allow for potential direct connections with seeds/peers + //String requestingPeer = Network.getInstance().getOurExternalIpAddressAndPort(); + String requestingPeer = null; + + // Build request + Message getArbitraryDataFileListMessage = new GetArbitraryDataFileListMessage(signature, missingHashes, now, 0, requestingPeer); + + // Save our request into requests map + Triple requestEntry = new Triple<>(signature58, null, NTP.getTime()); + + // Assign random ID to this message + int id; + do { + id = new Random().nextInt(Integer.MAX_VALUE - 1) + 1; + + // Put queue into map (keyed by message ID) so we can poll for a response + // If putIfAbsent() doesn't return null, then this ID is already taken + } while (arbitraryDataFileListRequests.put(id, requestEntry) != null); + getArbitraryDataFileListMessage.setId(id); + + // Broadcast request + RNSNetwork.getInstance().broadcast(peer -> getArbitraryDataFileListMessage); + + // Poll to see if data has arrived + final long singleWait = 100; + long totalWait = 0; + while (totalWait < ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT) { + try { + Thread.sleep(singleWait); + } catch (InterruptedException e) { + break; + } + + requestEntry = arbitraryDataFileListRequests.get(id); + if (requestEntry == null) + return false; + + if (requestEntry.getA() == null) + break; + + totalWait += singleWait; + } + return true; + } + + public boolean fetchArbitraryDataFileList(RNSPeer peer, byte[] signature) { + String signature58 = Base58.encode(signature); + + // Require an NTP sync + Long now = NTP.getTime(); + if (now == null) { + return false; + } + + int hashCount = 0; + LOGGER.debug(String.format("Sending data file list request for signature %s with %d hashes to peer %s...", signature58, hashCount, peer)); + + // Build request + // Use a time in the past, so that the recipient peer doesn't try and relay it + // Also, set hashes to null since it's easier to request all hashes than it is to determine which ones we need + // This could be optimized in the future + long timestamp = now - 60000L; + List hashes = null; + Message getArbitraryDataFileListMessage = new GetArbitraryDataFileListMessage(signature, hashes, timestamp, 0, null); + + // Save our request into requests map + Triple requestEntry = new Triple<>(signature58, null, NTP.getTime()); + + // Assign random ID to this message + int id; + do { + id = new Random().nextInt(Integer.MAX_VALUE - 1) + 1; + + // Put queue into map (keyed by message ID) so we can poll for a response + // If putIfAbsent() doesn't return null, then this ID is already taken + } while (arbitraryDataFileListRequests.put(id, requestEntry) != null); + getArbitraryDataFileListMessage.setId(id); + + // Send the request + peer.sendMessage(getArbitraryDataFileListMessage); + + // Poll to see if data has arrived + final long singleWait = 100; + long totalWait = 0; + while (totalWait < ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT) { + try { + Thread.sleep(singleWait); + } catch (InterruptedException e) { + break; + } + + requestEntry = arbitraryDataFileListRequests.get(id); + if (requestEntry == null) + return false; + + if (requestEntry.getA() == null) + break; + + totalWait += singleWait; + } + return true; + } + + public void deleteFileListRequestsForSignature(byte[] signature) { + String signature58 = Base58.encode(signature); + for (Iterator>> it = arbitraryDataFileListRequests.entrySet().iterator(); it.hasNext();) { + Map.Entry> entry = it.next(); + if (entry == null || entry.getKey() == null || entry.getValue() != null) { + continue; + } + if (Objects.equals(entry.getValue().getA(), signature58)) { + // Update requests map to reflect that we've received all chunks + Triple newEntry = new Triple<>(null, null, entry.getValue().getC()); + arbitraryDataFileListRequests.put(entry.getKey(), newEntry); + } + } + } + + // Network handlers + + public void onNetworkArbitraryDataFileListMessage(RNSPeer peer, Message message) { + // Don't process if QDN is disabled + if (!Settings.getInstance().isQdnEnabled()) { + return; + } + + ArbitraryDataFileListMessage arbitraryDataFileListMessage = (ArbitraryDataFileListMessage) message; + LOGGER.debug("Received hash list from peer {} with {} hashes", peer, arbitraryDataFileListMessage.getHashes().size()); + + if (LOGGER.isDebugEnabled() && arbitraryDataFileListMessage.getRequestTime() != null) { + long totalRequestTime = NTP.getTime() - arbitraryDataFileListMessage.getRequestTime(); + LOGGER.debug("totalRequestTime: {}, requestHops: {}, peerAddress: {}, isRelayPossible: {}", + totalRequestTime, arbitraryDataFileListMessage.getRequestHops(), + arbitraryDataFileListMessage.getPeerAddress(), arbitraryDataFileListMessage.isRelayPossible()); + } + + // Do we have a pending request for this data? + Triple request = arbitraryDataFileListRequests.get(message.getId()); + if (request == null || request.getA() == null) { + return; + } + boolean isRelayRequest = (request.getB() != null); + + // Does this message's signature match what we're expecting? + byte[] signature = arbitraryDataFileListMessage.getSignature(); + String signature58 = Base58.encode(signature); + if (!request.getA().equals(signature58)) { + return; + } + + List hashes = arbitraryDataFileListMessage.getHashes(); + if (hashes == null || hashes.isEmpty()) { + return; + } + + ArbitraryTransactionData arbitraryTransactionData = null; + + // Check transaction exists and hashes are correct + try (final Repository repository = RepositoryManager.getRepository()) { + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + if (!(transactionData instanceof ArbitraryTransactionData)) + return; + + arbitraryTransactionData = (ArbitraryTransactionData) transactionData; + +// // Load data file(s) +// ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); +// +// // Check all hashes exist +// for (byte[] hash : hashes) { +// //LOGGER.debug("Received hash {}", Base58.encode(hash)); +// if (!arbitraryDataFile.containsChunk(hash)) { +// // Check the hash against the complete file +// if (!Arrays.equals(arbitraryDataFile.getHash(), hash)) { +// LOGGER.info("Received non-matching chunk hash {} for signature {}. This could happen if we haven't obtained the metadata file yet.", Base58.encode(hash), signature58); +// return; +// } +// } +// } + + if (!isRelayRequest || !Settings.getInstance().isRelayModeEnabled()) { + Long now = NTP.getTime(); + + if (RNSArbitraryDataFileManager.getInstance().arbitraryDataFileHashResponses.size() < MAX_FILE_HASH_RESPONSES) { + // Keep track of the hashes this peer reports to have access to + for (byte[] hash : hashes) { + String hash58 = Base58.encode(hash); + + // Treat null request hops as 100, so that they are able to be sorted (and put to the end of the list) + int requestHops = arbitraryDataFileListMessage.getRequestHops() != null ? arbitraryDataFileListMessage.getRequestHops() : 100; + + RNSArbitraryFileListResponseInfo responseInfo = new RNSArbitraryFileListResponseInfo(hash58, signature58, + peer, now, arbitraryDataFileListMessage.getRequestTime(), requestHops); + + RNSArbitraryDataFileManager.getInstance().arbitraryDataFileHashResponses.add(responseInfo); + } + } + + // Keep track of the source peer, for direct connections + if (arbitraryDataFileListMessage.getPeerAddress() != null) { + RNSArbitraryDataFileManager.getInstance().addDirectConnectionInfoIfUnique( + new RNSArbitraryDirectConnectionInfo(signature, arbitraryDataFileListMessage.getPeerAddress(), hashes, now)); + } + } + + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while finding arbitrary transaction data list for peer %s", peer), e); + } + + // Forwarding + if (isRelayRequest && Settings.getInstance().isRelayModeEnabled()) { + boolean isBlocked = (arbitraryTransactionData == null || ListUtils.isNameBlocked(arbitraryTransactionData.getName())); + if (!isBlocked) { + RNSPeer requestingPeer = request.getB(); + if (requestingPeer != null) { + Long requestTime = arbitraryDataFileListMessage.getRequestTime(); + Integer requestHops = arbitraryDataFileListMessage.getRequestHops(); + + // Add each hash to our local mapping so we know who to ask later + Long now = NTP.getTime(); + for (byte[] hash : hashes) { + String hash58 = Base58.encode(hash); + RNSArbitraryRelayInfo relayInfo = new RNSArbitraryRelayInfo(hash58, signature58, peer, now, requestTime, requestHops); + RNSArbitraryDataFileManager.getInstance().addToRelayMap(relayInfo); + } + + // Bump requestHops if it exists + if (requestHops != null) { + requestHops++; + } + + ArbitraryDataFileListMessage forwardArbitraryDataFileListMessage; + + //// TODO - rework for Reticulum + //// Remove optional parameters if the requesting peer doesn't support it yet + //// A message with less statistical data is better than no message at all + //if (!requestingPeer.isAtLeastVersion(MIN_PEER_VERSION_FOR_FILE_LIST_STATS)) { + // forwardArbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, hashes); + //} else { + // forwardArbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, hashes, requestTime, requestHops, + // arbitraryDataFileListMessage.getPeerAddress(), arbitraryDataFileListMessage.isRelayPossible()); + //} + forwardArbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, hashes, requestTime, requestHops, + arbitraryDataFileListMessage.getPeerAddress(), arbitraryDataFileListMessage.isRelayPossible()); + forwardArbitraryDataFileListMessage.setId(message.getId()); + + // Forward to requesting peer + LOGGER.debug("Forwarding file list with {} hashes to requesting peer: {}", hashes.size(), requestingPeer); + //if (!requestingPeer.sendMessage(forwardArbitraryDataFileListMessage)) { + // requestingPeer.disconnect("failed to forward arbitrary data file list"); + //} + requestingPeer.sendMessage(forwardArbitraryDataFileListMessage); + } + } + } + } + + public void onNetworkGetArbitraryDataFileListMessage(RNSPeer peer, Message message) { + // Don't respond if QDN is disabled + if (!Settings.getInstance().isQdnEnabled()) { + return; + } + + Controller.getInstance().stats.getArbitraryDataFileListMessageStats.requests.incrementAndGet(); + + GetArbitraryDataFileListMessage getArbitraryDataFileListMessage = (GetArbitraryDataFileListMessage) message; + byte[] signature = getArbitraryDataFileListMessage.getSignature(); + String signature58 = Base58.encode(signature); + Long now = NTP.getTime(); + Triple newEntry = new Triple<>(signature58, peer, now); + + // If we've seen this request recently, then ignore + if (arbitraryDataFileListRequests.putIfAbsent(message.getId(), newEntry) != null) { + LOGGER.trace("Ignoring hash list request from peer {} for signature {}", peer, signature58); + return; + } + + List requestedHashes = getArbitraryDataFileListMessage.getHashes(); + int hashCount = requestedHashes != null ? requestedHashes.size() : 0; + String requestingPeer = getArbitraryDataFileListMessage.getRequestingPeer(); + + if (requestingPeer != null) { + LOGGER.debug("Received hash list request with {} hashes from peer {} (requesting peer {}) for signature {}", hashCount, peer, requestingPeer, signature58); + } + else { + LOGGER.debug("Received hash list request with {} hashes from peer {} for signature {}", hashCount, peer, signature58); + } + + List hashes = new ArrayList<>(); + ArbitraryTransactionData transactionData = null; + boolean allChunksExist = false; + boolean hasMetadata = false; + + try (final Repository repository = RepositoryManager.getRepository()) { + + // Firstly we need to lookup this file on chain to get a list of its hashes + transactionData = (ArbitraryTransactionData)repository.getTransactionRepository().fromSignature(signature); + if (transactionData instanceof ArbitraryTransactionData) { + + // Check if we're even allowed to serve data for this transaction + if (ArbitraryDataStorageManager.getInstance().canStoreData(transactionData)) { + + // Load file(s) and add any that exist to the list of hashes + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData); + + // If the peer didn't supply a hash list, we need to return all hashes for this transaction + if (requestedHashes == null || requestedHashes.isEmpty()) { + requestedHashes = new ArrayList<>(); + + // Add the metadata file + if (arbitraryDataFile.getMetadataHash() != null) { + requestedHashes.add(arbitraryDataFile.getMetadataHash()); + hasMetadata = true; + } + + // Add the chunk hashes + if (!arbitraryDataFile.getChunkHashes().isEmpty()) { + requestedHashes.addAll(arbitraryDataFile.getChunkHashes()); + } + // Add complete file if there are no hashes + else { + requestedHashes.add(arbitraryDataFile.getHash()); + } + } + + // Assume all chunks exists, unless one can't be found below + allChunksExist = true; + + for (byte[] requestedHash : requestedHashes) { + ArbitraryDataFileChunk chunk = ArbitraryDataFileChunk.fromHash(requestedHash, signature); + if (chunk.exists()) { + hashes.add(chunk.getHash()); + //LOGGER.trace("Added hash {}", chunk.getHash58()); + } else { + LOGGER.trace("Couldn't add hash {} because it doesn't exist", chunk.getHash58()); + allChunksExist = false; + } + } + } + } + + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while fetching arbitrary file list for peer %s", peer), e); + } + + // If the only file we have is the metadata then we shouldn't respond. Most nodes will already have that, + // or can use the separate metadata protocol to fetch it. This should greatly reduce network spam. + if (hasMetadata && hashes.size() == 1) { + hashes.clear(); + } + + // We should only respond if we have at least one hash + if (!hashes.isEmpty()) { + + // Firstly we should keep track of the requesting peer, to allow for potential direct connections later + RNSArbitraryDataFileManager.getInstance().addRecentDataRequest(requestingPeer); + + // We have all the chunks, so update requests map to reflect that we've sent it + // There is no need to keep track of the request, as we can serve all the chunks + if (allChunksExist) { + newEntry = new Triple<>(null, null, now); + arbitraryDataFileListRequests.put(message.getId(), newEntry); + } + + //String ourAddress = RNSNetwork.getInstance().getOurExternalIpAddressAndPort(); + String ourAddress = RNSNetwork.getInstance().getBaseDestination().getHexHash(); + ArbitraryDataFileListMessage arbitraryDataFileListMessage; + + // TODO: rework for Reticulum + // Remove optional parameters if the requesting peer doesn't support it yet + // A message with less statistical data is better than no message at all + //if (!peer.isAtLeastVersion(MIN_PEER_VERSION_FOR_FILE_LIST_STATS)) { + // arbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, hashes); + //} else { + // arbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, + // hashes, NTP.getTime(), 0, ourAddress, true); + //} + arbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, hashes); + + arbitraryDataFileListMessage.setId(message.getId()); + + //if (!peer.sendMessage(arbitraryDataFileListMessage)) { + // LOGGER.debug("Couldn't send list of hashes"); + // peer.disconnect("failed to send list of hashes"); + // return; + //} + peer.sendMessage(arbitraryDataFileListMessage); + LOGGER.debug("Sent list of hashes (count: {})", hashes.size()); + + if (allChunksExist) { + // Nothing left to do, so return to prevent any unnecessary forwarding from occurring + LOGGER.debug("No need for any forwarding because file list request is fully served"); + return; + } + + } + + // We may need to forward this request on + boolean isBlocked = (transactionData == null || ListUtils.isNameBlocked(transactionData.getName())); + if (Settings.getInstance().isRelayModeEnabled() && !isBlocked) { + // In relay mode - so ask our other peers if they have it + + long requestTime = getArbitraryDataFileListMessage.getRequestTime(); + int requestHops = getArbitraryDataFileListMessage.getRequestHops() + 1; + long totalRequestTime = now - requestTime; + + if (totalRequestTime < RELAY_REQUEST_MAX_DURATION) { + // Relay request hasn't timed out yet, so can potentially be rebroadcast + if (requestHops < RELAY_REQUEST_MAX_HOPS) { + // Relay request hasn't reached the maximum number of hops yet, so can be rebroadcast + + Message relayGetArbitraryDataFileListMessage = new GetArbitraryDataFileListMessage(signature, hashes, requestTime, requestHops, requestingPeer); + relayGetArbitraryDataFileListMessage.setId(message.getId()); + + LOGGER.debug("Rebroadcasting hash list request from peer {} for signature {} to our other peers... totalRequestTime: {}, requestHops: {}", peer, Base58.encode(signature), totalRequestTime, requestHops); + //Network.getInstance().broadcast( + // broadcastPeer -> + // !broadcastPeer.isAtLeastVersion(RELAY_MIN_PEER_VERSION) ? null : + // broadcastPeer == peer || Objects.equals(broadcastPeer.getPeerData().getAddress().getHost(), peer.getPeerData().getAddress().getHost()) ? null : relayGetArbitraryDataFileListMessage + //); + RNSNetwork.getInstance().broadcast(broadcastPeer -> relayGetArbitraryDataFileListMessage); + + } + else { + // This relay request has reached the maximum number of allowed hops + } + } + else { + // This relay request has timed out + } + } + } + +} diff --git a/src/main/java/org/qortal/controller/arbitrary/RNSArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/RNSArbitraryDataFileManager.java new file mode 100644 index 00000000..fc68fdec --- /dev/null +++ b/src/main/java/org/qortal/controller/arbitrary/RNSArbitraryDataFileManager.java @@ -0,0 +1,639 @@ +package org.qortal.controller.arbitrary; + +import com.google.common.net.InetAddresses; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.arbitrary.ArbitraryDataFile; +import org.qortal.controller.Controller; +import org.qortal.data.arbitrary.RNSArbitraryDirectConnectionInfo; +import org.qortal.data.arbitrary.RNSArbitraryFileListResponseInfo; +import org.qortal.data.arbitrary.RNSArbitraryRelayInfo; +import org.qortal.data.network.PeerData; +import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.network.RNSNetwork; +import org.qortal.network.RNSPeer; +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.ArbitraryTransactionUtils; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +import java.security.SecureRandom; +import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +public class RNSArbitraryDataFileManager extends Thread { + + private static final Logger LOGGER = LogManager.getLogger(RNSArbitraryDataFileManager.class); + + private static RNSArbitraryDataFileManager instance; + private volatile boolean isStopping = false; + + + /** + * Map to keep track of our in progress (outgoing) arbitrary data file requests + */ + public Map arbitraryDataFileRequests = Collections.synchronizedMap(new HashMap<>()); + + /** + * Map to keep track of hashes that we might need to relay + */ + public final List arbitraryRelayMap = Collections.synchronizedList(new ArrayList<>()); + + /** + * List to keep track of any arbitrary data file hash responses + */ + public final List arbitraryDataFileHashResponses = Collections.synchronizedList(new ArrayList<>()); + + /** + * List to keep track of peers potentially available for direct connections, based on recent requests + */ + private final List directConnectionInfo = Collections.synchronizedList(new ArrayList<>()); + + /** + * Map to keep track of peers requesting QDN data that we hold. + * Key = peer address string, value = time of last request. + * This allows for additional "burst" connections beyond existing limits. + */ + private Map recentDataRequests = Collections.synchronizedMap(new HashMap<>()); + + + public static int MAX_FILE_HASH_RESPONSES = 1000; + + + private RNSArbitraryDataFileManager() { + } + + public static RNSArbitraryDataFileManager getInstance() { + if (instance == null) + instance = new RNSArbitraryDataFileManager(); + + return instance; + } + + @Override + public void run() { + Thread.currentThread().setName("Arbitrary Data File Manager"); + + try { + // Use a fixed thread pool to execute the arbitrary data file requests + int threadCount = 5; + ExecutorService arbitraryDataFileRequestExecutor = Executors.newFixedThreadPool(threadCount); + for (int i = 0; i < threadCount; i++) { + arbitraryDataFileRequestExecutor.execute(new ArbitraryDataFileRequestThread()); + } + + while (!isStopping) { + // Nothing to do yet + Thread.sleep(1000); + } + } catch (InterruptedException e) { + // Fall-through to exit thread... + } + } + + public void shutdown() { + isStopping = true; + this.interrupt(); + } + + + public void cleanupRequestCache(Long now) { + if (now == null) { + return; + } + final long requestMinimumTimestamp = now - ArbitraryDataManager.getInstance().ARBITRARY_REQUEST_TIMEOUT; + arbitraryDataFileRequests.entrySet().removeIf(entry -> entry.getValue() == null || entry.getValue() < requestMinimumTimestamp); + + final long relayMinimumTimestamp = now - ArbitraryDataManager.getInstance().ARBITRARY_RELAY_TIMEOUT; + arbitraryRelayMap.removeIf(entry -> entry == null || entry.getTimestamp() == null || entry.getTimestamp() < relayMinimumTimestamp); + arbitraryDataFileHashResponses.removeIf(entry -> entry.getTimestamp() < relayMinimumTimestamp); + + final long directConnectionInfoMinimumTimestamp = now - ArbitraryDataManager.getInstance().ARBITRARY_DIRECT_CONNECTION_INFO_TIMEOUT; + directConnectionInfo.removeIf(entry -> entry.getTimestamp() < directConnectionInfoMinimumTimestamp); + + final long recentDataRequestMinimumTimestamp = now - ArbitraryDataManager.getInstance().ARBITRARY_RECENT_DATA_REQUESTS_TIMEOUT; + recentDataRequests.entrySet().removeIf(entry -> entry.getValue() < recentDataRequestMinimumTimestamp); + } + + + + // Fetch data files by hash + + public boolean fetchArbitraryDataFiles(Repository repository, + RNSPeer peer, + byte[] signature, + ArbitraryTransactionData arbitraryTransactionData, + List hashes) throws DataException { + + // Load data file(s) + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData); + boolean receivedAtLeastOneFile = false; + + // Now fetch actual data from this peer + for (byte[] hash : hashes) { + if (isStopping) { + return false; + } + String hash58 = Base58.encode(hash); + if (!arbitraryDataFile.chunkExists(hash)) { + // Only request the file if we aren't already requesting it from someone else + if (!arbitraryDataFileRequests.containsKey(Base58.encode(hash))) { + LOGGER.debug("Requesting data file {} from peer {}", hash58, peer); + Long startTime = NTP.getTime(); + ArbitraryDataFile receivedArbitraryDataFile = fetchArbitraryDataFile(peer, null, arbitraryTransactionData, signature, hash, null); + Long endTime = NTP.getTime(); + 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 + arbitraryDataFileHashResponses.remove(hash58); + } + else { + LOGGER.debug("Peer {} didn't respond with data file {} for signature {}. Time taken: {} ms", peer, Base58.encode(hash), Base58.encode(signature), (endTime-startTime)); + + // Remove this hash from arbitraryDataFileHashResponses now that we have failed to receive it + arbitraryDataFileHashResponses.remove(hash58); + + // Stop asking for files from this peer + break; + } + } + else { + LOGGER.trace("Already requesting data file {} for signature {} from peer {}", arbitraryDataFile, Base58.encode(signature), peer); + } + } + else { + // Remove this hash from arbitraryDataFileHashResponses because we have a local copy + arbitraryDataFileHashResponses.remove(hash58); + } + } + + if (receivedAtLeastOneFile) { + // Invalidate the hosted transactions cache as we are now hosting something new + ArbitraryDataStorageManager.getInstance().invalidateHostedTransactionsCache(); + + // Check if we have all the files we need for this transaction + if (arbitraryDataFile.allFilesExist()) { + + // We have all the chunks for this transaction, so we should invalidate the transaction's name's + // data cache so that it is rebuilt the next time we serve it + ArbitraryDataManager.getInstance().invalidateCache(arbitraryTransactionData); + } + } + + return receivedAtLeastOneFile; + } + + private ArbitraryDataFile fetchArbitraryDataFile(RNSPeer peer, RNSPeer requestingPeer, ArbitraryTransactionData arbitraryTransactionData, byte[] signature, byte[] hash, Message originalMessage) throws DataException { + ArbitraryDataFile existingFile = ArbitraryDataFile.fromHash(hash, signature); + boolean fileAlreadyExists = existingFile.exists(); + String hash58 = Base58.encode(hash); + ArbitraryDataFile arbitraryDataFile; + + // Fetch the file if it doesn't exist locally + if (!fileAlreadyExists) { + LOGGER.debug(String.format("Fetching data file %.8s from peer %s", hash58, peer)); + arbitraryDataFileRequests.put(hash58, NTP.getTime()); + Message getArbitraryDataFileMessage = new GetArbitraryDataFileMessage(signature, hash); + + Message response = null; + //// TODO - revisit (doesn't work with Reticulum) + //try { + // response = peer.getResponseWithTimeout(getArbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT); + //} catch (InterruptedException e) { + // // Will return below due to null response + //} + arbitraryDataFileRequests.remove(hash58); + LOGGER.trace(String.format("Removed hash %.8s from arbitraryDataFileRequests", hash58)); + + // We may need to remove the file list request, if we have all the files for this transaction + this.handleFileListRequests(signature); + + if (response == null) { + LOGGER.debug("Received null response from peer {}", peer); + return null; + } + if (response.getType() != MessageType.ARBITRARY_DATA_FILE) { + LOGGER.debug("Received response with invalid type: {} from peer {}", response.getType(), peer); + return null; + } + + ArbitraryDataFileMessage peersArbitraryDataFileMessage = (ArbitraryDataFileMessage) response; + arbitraryDataFile = peersArbitraryDataFileMessage.getArbitraryDataFile(); + } else { + LOGGER.debug(String.format("File hash %s already exists, so skipping the request", hash58)); + 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); + + 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 if it exists. + // It shouldn't exist on the filesystem yet, but leaving this here just in case. + arbitraryDataFile.delete(10); + } + } + else { + arbitraryDataFile.save(); + } + + // If this is a metadata file then we need to update the cache + if (arbitraryTransactionData != null && arbitraryTransactionData.getMetadataHash() != null) { + if (Arrays.equals(arbitraryTransactionData.getMetadataHash(), hash)) { + ArbitraryDataCacheManager.getInstance().addToUpdateQueue(arbitraryTransactionData); + } + } + + return arbitraryDataFile; + } + + private void handleFileListRequests(byte[] signature) { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Fetch the transaction data + ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); + if (arbitraryTransactionData == null) { + return; + } + + boolean allChunksExist = ArbitraryTransactionUtils.allChunksExist(arbitraryTransactionData); + + if (allChunksExist) { + // Update requests map to reflect that we've received all chunks + RNSArbitraryDataFileListManager.getInstance().deleteFileListRequestsForSignature(signature); + } + + } catch (DataException e) { + LOGGER.debug("Unable to handle file list requests: {}", e.getMessage()); + } + } + + public void handleArbitraryDataFileForwarding(RNSPeer requestingPeer, Message message, Message originalMessage) { + // Return if there is no originally requesting peer to forward to + if (requestingPeer == null) { + return; + } + + // Return if we're not in relay mode or if this request doesn't need forwarding + if (!Settings.getInstance().isRelayModeEnabled()) { + return; + } + + LOGGER.debug("Received arbitrary data file - forwarding is needed"); + + // The ID needs to match that of the original request + message.setId(originalMessage.getId()); + + //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"); + //} + //else { + // LOGGER.debug("Forwarded arbitrary data file to peer {}", requestingPeer); + //} + requestingPeer.sendMessageWithTimeout(message, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT); + } + + + // Fetch data directly from peers + + private List getDirectConnectionInfoForSignature(byte[] signature) { + synchronized (directConnectionInfo) { + return directConnectionInfo.stream().filter(i -> Arrays.equals(i.getSignature(), signature)).collect(Collectors.toList()); + } + } + + /** + * Add an ArbitraryDirectConnectionInfo item, but only if one with this peer-signature combination + * doesn't already exist. + * @param connectionInfo - the direct connection info to add + */ + public void addDirectConnectionInfoIfUnique(RNSArbitraryDirectConnectionInfo connectionInfo) { + boolean peerAlreadyExists; + synchronized (directConnectionInfo) { + peerAlreadyExists = directConnectionInfo.stream() + .anyMatch(i -> Arrays.equals(i.getSignature(), connectionInfo.getSignature()) + && Objects.equals(i.getPeerAddress(), connectionInfo.getPeerAddress())); + } + if (!peerAlreadyExists) { + directConnectionInfo.add(connectionInfo); + } + } + + private void removeDirectConnectionInfo(RNSArbitraryDirectConnectionInfo connectionInfo) { + this.directConnectionInfo.remove(connectionInfo); + } + + public boolean fetchDataFilesFromPeersForSignature(byte[] signature) { + String signature58 = Base58.encode(signature); + + boolean success = false; + + try { + while (!success) { + if (isStopping) { + return false; + } + Thread.sleep(500L); + + // Firstly fetch peers that claim to be hosting files for this signature + List connectionInfoList = getDirectConnectionInfoForSignature(signature); + if (connectionInfoList == null || connectionInfoList.isEmpty()) { + LOGGER.debug("No remaining direct connection peers found for signature {}", signature58); + return false; + } + + LOGGER.debug("Attempting a direct peer connection for signature {}...", signature58); + + // Peers found, so pick one with the highest number of chunks + Comparator highestChunkCountFirstComparator = + Comparator.comparingInt(RNSArbitraryDirectConnectionInfo::getHashCount).reversed(); + RNSArbitraryDirectConnectionInfo directConnectionInfo = connectionInfoList.stream() + .sorted(highestChunkCountFirstComparator).findFirst().orElse(null); + + if (directConnectionInfo == null) { + return false; + } + + // Remove from the list so that a different peer is tried next time + removeDirectConnectionInfo(directConnectionInfo); + + //// TODO - rework this section (RNS network address?) + //String peerAddressString = directConnectionInfo.getPeerAddress(); + // + //// Parse the peer address to find the host and port + //String host = null; + //int port = -1; + //String[] parts = peerAddressString.split(":"); + //if (parts.length > 1) { + // host = parts[0]; + // port = Integer.parseInt(parts[1]); + //} else { + // // Assume no port included + // host = peerAddressString; + // // Use default listen port + // port = Settings.getInstance().getDefaultListenPort(); + //} + // + //String peerAddressStringWithPort = String.format("%s:%d", host, port); + //success = Network.getInstance().requestDataFromPeer(peerAddressStringWithPort, signature); + // + //int defaultPort = Settings.getInstance().getDefaultListenPort(); + // + //// If unsuccessful, and using a non-standard port, try a second connection with the default listen port, + //// since almost all nodes use that. This is a workaround to account for any ephemeral ports that may + //// have made it into the dataset. + //if (!success) { + // if (host != null && port > 0) { + // if (port != defaultPort) { + // String newPeerAddressString = String.format("%s:%d", host, defaultPort); + // success = Network.getInstance().requestDataFromPeer(newPeerAddressString, signature); + // } + // } + //} + // + //// If _still_ unsuccessful, try matching the peer's IP address with some known peers, and then connect + //// to each of those in turn until one succeeds. + //if (!success) { + // if (host != null) { + // final String finalHost = host; + // List knownPeers = Network.getInstance().getAllKnownPeers().stream() + // .filter(knownPeerData -> knownPeerData.getAddress().getHost().equals(finalHost)) + // .collect(Collectors.toList()); + // // Loop through each match and attempt a connection + // for (PeerData matchingPeer : knownPeers) { + // String matchingPeerAddress = matchingPeer.getAddress().toString(); + // int matchingPeerPort = matchingPeer.getAddress().getPort(); + // // Make sure that it's not a port we've already tried + // if (matchingPeerPort != port && matchingPeerPort != defaultPort) { + // success = Network.getInstance().requestDataFromPeer(matchingPeerAddress, signature); + // if (success) { + // // Successfully connected, so stop making connections + // break; + // } + // } + // } + // } + //} + + if (success) { + // We were able to connect with a peer, so track the request + RNSArbitraryDataFileListManager.getInstance().addToSignatureRequests(signature58, false, true); + } + + } + } catch (InterruptedException e) { + // Do nothing + } + + return success; + } + + + // Relays + + private List getRelayInfoListForHash(String hash58) { + synchronized (arbitraryRelayMap) { + return arbitraryRelayMap.stream() + .filter(relayInfo -> Objects.equals(relayInfo.getHash58(), hash58)) + .collect(Collectors.toList()); + } + } + + private RNSArbitraryRelayInfo getOptimalRelayInfoEntryForHash(String hash58) { + LOGGER.trace("Fetching relay info for hash: {}", hash58); + List relayInfoList = this.getRelayInfoListForHash(hash58); + if (relayInfoList != null && !relayInfoList.isEmpty()) { + + // Remove any with null requestHops + relayInfoList.removeIf(r -> r.getRequestHops() == null); + + // If list is now empty, then just return one at random + if (relayInfoList.isEmpty()) { + return this.getRandomRelayInfoEntryForHash(hash58); + } + + // Sort by number of hops (lowest first) + relayInfoList.sort(Comparator.comparingInt(RNSArbitraryRelayInfo::getRequestHops)); + + // FUTURE: secondary sort by requestTime? + + RNSArbitraryRelayInfo relayInfo = relayInfoList.get(0); + + LOGGER.trace("Returning optimal relay info for hash: {} (requestHops {})", hash58, relayInfo.getRequestHops()); + return relayInfo; + } + LOGGER.trace("No relay info exists for hash: {}", hash58); + return null; + } + + private RNSArbitraryRelayInfo getRandomRelayInfoEntryForHash(String hash58) { + LOGGER.trace("Fetching random relay info for hash: {}", hash58); + List relayInfoList = this.getRelayInfoListForHash(hash58); + if (relayInfoList != null && !relayInfoList.isEmpty()) { + + // Pick random item + int index = new SecureRandom().nextInt(relayInfoList.size()); + LOGGER.trace("Returning random relay info for hash: {} (index {})", hash58, index); + return relayInfoList.get(index); + } + LOGGER.trace("No relay info exists for hash: {}", hash58); + return null; + } + + public void addToRelayMap(RNSArbitraryRelayInfo newEntry) { + if (newEntry == null || !newEntry.isValid()) { + return; + } + + // Remove existing entry for this peer if it exists, to renew the timestamp + this.removeFromRelayMap(newEntry); + + // Re-add + arbitraryRelayMap.add(newEntry); + LOGGER.debug("Added entry to relay map: {}", newEntry); + } + + private void removeFromRelayMap(RNSArbitraryRelayInfo entry) { + arbitraryRelayMap.removeIf(relayInfo -> relayInfo.equals(entry)); + } + + + // Peers requesting QDN data from us + + /** + * Add an address string of a peer that is trying to request data from us. + * @param peerAddress + */ + public void addRecentDataRequest(String peerAddress) { + if (peerAddress == null) { + return; + } + + Long now = NTP.getTime(); + if (now == null) { + return; + } + + // Make sure to remove the port, since it isn't guaranteed to match next time + String[] parts = peerAddress.split(":"); + if (parts.length == 0) { + return; + } + String host = parts[0]; + if (!InetAddresses.isInetAddress(host)) { + // Invalid host + return; + } + + this.recentDataRequests.put(host, now); + } + + public boolean isPeerRequestingData(String peerAddressWithoutPort) { + return this.recentDataRequests.containsKey(peerAddressWithoutPort); + } + + public boolean hasPendingDataRequest() { + return !this.recentDataRequests.isEmpty(); + } + + + // Network handlers + + public void onNetworkGetArbitraryDataFileMessage(RNSPeer peer, Message message) { + // Don't respond if QDN is disabled + if (!Settings.getInstance().isQdnEnabled()) { + return; + } + + GetArbitraryDataFileMessage getArbitraryDataFileMessage = (GetArbitraryDataFileMessage) message; + byte[] hash = getArbitraryDataFileMessage.getHash(); + String hash58 = Base58.encode(hash); + byte[] signature = getArbitraryDataFileMessage.getSignature(); + Controller.getInstance().stats.getArbitraryDataFileMessageStats.requests.incrementAndGet(); + + LOGGER.debug("Received GetArbitraryDataFileMessage from peer {} for hash {}", peer, Base58.encode(hash)); + + try { + ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); + RNSArbitraryRelayInfo relayInfo = this.getOptimalRelayInfoEntryForHash(hash58); + + if (arbitraryDataFile.exists()) { + 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.sendMessageWithTimeout(arbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT)) { + // LOGGER.debug("Couldn't send file {}", arbitraryDataFile); + // peer.disconnect("failed to send file"); + //} + //else { + // LOGGER.debug("Sent file {}", arbitraryDataFile); + //} + peer.sendMessageWithTimeout(arbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT); + } + //// TODO: rework (doesn't work with Reticulum) + //else if (relayInfo != null) { + // LOGGER.debug("We have relay info for hash {}", Base58.encode(hash)); + // // We need to ask this peer for the file + // Peer peerToAsk = relayInfo.getPeer(); + // if (peerToAsk != null) { + // + // // Forward the message to this peer + // LOGGER.debug("Asking peer {} for hash {}", peerToAsk, hash58); + // // No need to pass arbitraryTransactionData below because this is only used for metadata caching, + // // and metadata isn't retained when relaying. + // this.fetchArbitraryDataFile(peerToAsk, peer, null, signature, hash, message); + // } + // else { + // LOGGER.debug("Peer {} not found in relay info", peer); + // } + //} + else { + LOGGER.debug("Hash {} doesn't exist and we don't have relay info", hash58); + + // We don't have this file + Controller.getInstance().stats.getArbitraryDataFileMessageStats.unknownFiles.getAndIncrement(); + + // Send valid, yet unexpected message type in response, so peer's synchronizer doesn't have to wait for timeout + LOGGER.debug(String.format("Sending 'file unknown' response to peer %s for GET_FILE request for unknown file %s", peer, arbitraryDataFile)); + + //// Send generic 'unknown' message as it's very short + //Message fileUnknownMessage = peer.getPeersVersion() >= GenericUnknownMessage.MINIMUM_PEER_VERSION + // ? new GenericUnknownMessage() + // : new BlockSummariesMessage(Collections.emptyList()); + //fileUnknownMessage.setId(message.getId()); + //if (!peer.sendMessage(fileUnknownMessage)) { + // LOGGER.debug("Couldn't sent file-unknown response"); + // peer.disconnect("failed to send file-unknown response"); + //} + //else { + // LOGGER.debug("Sent file-unknown response for file {}", arbitraryDataFile); + //} + Message fileUnknownMessage = new GenericUnknownMessage(); + peer.sendMessage(fileUnknownMessage); + } + } + catch (DataException e) { + LOGGER.debug("Unable to handle request for arbitrary data file: {}", hash58); + } + } + +} diff --git a/src/main/java/org/qortal/controller/arbitrary/RNSArbitraryMetadataManager.java b/src/main/java/org/qortal/controller/arbitrary/RNSArbitraryMetadataManager.java new file mode 100644 index 00000000..45e674b7 --- /dev/null +++ b/src/main/java/org/qortal/controller/arbitrary/RNSArbitraryMetadataManager.java @@ -0,0 +1,481 @@ +package org.qortal.controller.arbitrary; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.arbitrary.ArbitraryDataFile; +import org.qortal.arbitrary.ArbitraryDataResource; +import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; +import org.qortal.controller.Controller; +import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.network.RNSNetwork; +import org.qortal.network.RNSPeer; +import org.qortal.network.message.ArbitraryMetadataMessage; +import org.qortal.network.message.GetArbitraryMetadataMessage; +import org.qortal.network.message.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.ListUtils; +import org.qortal.utils.NTP; +import org.qortal.utils.Triple; + +import java.io.IOException; +import java.util.*; + +import static org.qortal.controller.arbitrary.ArbitraryDataFileListManager.*; + +public class RNSArbitraryMetadataManager { + + private static final Logger LOGGER = LogManager.getLogger(ArbitraryMetadataManager.class); + + private static RNSArbitraryMetadataManager instance; + + /** + * Map of recent incoming requests for ARBITRARY transaction metadata. + *

+ * Key is original request's message ID
+ * Value is Triple<transaction signature in base58, first requesting peer, first request's timestamp> + *

+ * If peer is null then either:
+ *

    + *
  • we are the original requesting peer
  • + *
  • we have already sent data payload to original requesting peer.
  • + *
+ * If signature is null then we have already received the file list and either:
+ *
    + *
  • we are the original requesting peer and have processed it
  • + *
  • we have forwarded the metadata
  • + *
+ */ + public Map> arbitraryMetadataRequests = Collections.synchronizedMap(new HashMap<>()); + + /** + * Map to keep track of in progress arbitrary metadata requests + * Key: string - the signature encoded in base58 + * Value: Triple + */ + private Map> arbitraryMetadataSignatureRequests = Collections.synchronizedMap(new HashMap<>()); + + + private RNSArbitraryMetadataManager() { + } + + public static RNSArbitraryMetadataManager getInstance() { + if (instance == null) + instance = new RNSArbitraryMetadataManager(); + + return instance; + } + + public void cleanupRequestCache(Long now) { + if (now == null) { + return; + } + final long requestMinimumTimestamp = now - ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT; + arbitraryMetadataRequests.entrySet().removeIf(entry -> entry.getValue().getC() == null || entry.getValue().getC() < requestMinimumTimestamp); + } + + + public ArbitraryDataTransactionMetadata fetchMetadata(ArbitraryDataResource arbitraryDataResource, boolean useRateLimiter) { + try (final Repository repository = RepositoryManager.getRepository()) { + // Find latest transaction + ArbitraryTransactionData latestTransaction = repository.getArbitraryRepository() + .getLatestTransaction(arbitraryDataResource.getResourceId(), arbitraryDataResource.getService(), + null, arbitraryDataResource.getIdentifier()); + + if (latestTransaction != null) { + byte[] signature = latestTransaction.getSignature(); + byte[] metadataHash = latestTransaction.getMetadataHash(); + if (metadataHash == null) { + // This resource doesn't have metadata + throw new IllegalArgumentException("This resource doesn't have metadata"); + } + + ArbitraryDataFile metadataFile = ArbitraryDataFile.fromHash(metadataHash, signature); + if (!metadataFile.exists()) { + // Request from network + this.fetchArbitraryMetadata(latestTransaction, useRateLimiter); + } + + // Now check again as it may have been downloaded above + if (metadataFile.exists()) { + // Use local copy + ArbitraryDataTransactionMetadata transactionMetadata = new ArbitraryDataTransactionMetadata(metadataFile.getFilePath()); + 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; + } + } + + } catch (DataException | IOException e) { + LOGGER.error("Repository issue when fetching arbitrary transaction metadata", e); + } + + return null; + } + + + // Request metadata from network + + public byte[] fetchArbitraryMetadata(ArbitraryTransactionData arbitraryTransactionData, boolean useRateLimiter) { + byte[] metadataHash = arbitraryTransactionData.getMetadataHash(); + if (metadataHash == null) { + return null; + } + + byte[] signature = arbitraryTransactionData.getSignature(); + String signature58 = Base58.encode(signature); + + // Require an NTP sync + Long now = NTP.getTime(); + if (now == null) { + return null; + } + + // If we've already tried too many times in a short space of time, make sure to give up + if (useRateLimiter && !this.shouldMakeMetadataRequestForSignature(signature58)) { + LOGGER.trace("Skipping metadata request for signature {} due to rate limit", signature58); + return null; + } + this.addToSignatureRequests(signature58, true, false); + + //List handshakedPeers = Network.getInstance().getImmutableHandshakedPeers(); + List handshakedPeers = RNSNetwork.getInstance().getLinkedPeers(); + LOGGER.debug(String.format("Sending metadata request for signature %s to %d peers...", signature58, handshakedPeers.size())); + + // Build request + Message getArbitraryMetadataMessage = new GetArbitraryMetadataMessage(signature, now, 0); + + // Save our request into requests map + Triple requestEntry = new Triple<>(signature58, null, NTP.getTime()); + + // Assign random ID to this message + int id; + do { + id = new Random().nextInt(Integer.MAX_VALUE - 1) + 1; + + // Put queue into map (keyed by message ID) so we can poll for a response + // If putIfAbsent() doesn't return null, then this ID is already taken + } while (arbitraryMetadataRequests.put(id, requestEntry) != null); + getArbitraryMetadataMessage.setId(id); + + // Broadcast request + RNSNetwork.getInstance().broadcast(peer -> getArbitraryMetadataMessage); + + // Poll to see if data has arrived + final long singleWait = 100; + long totalWait = 0; + while (totalWait < ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT) { + try { + Thread.sleep(singleWait); + } catch (InterruptedException e) { + break; + } + + requestEntry = arbitraryMetadataRequests.get(id); + if (requestEntry == null) + return null; + + if (requestEntry.getA() == null) + break; + + totalWait += singleWait; + } + + try { + ArbitraryDataFile metadataFile = ArbitraryDataFile.fromHash(metadataHash, signature); + if (metadataFile.exists()) { + return metadataFile.getBytes(); + } + } catch (DataException e) { + // Do nothing + } + + return null; + } + + + // Track metadata lookups by signature + + private boolean shouldMakeMetadataRequestForSignature(String signature58) { + Triple request = arbitraryMetadataSignatureRequests.get(signature58); + + if (request == null) { + // Not attempted yet + return true; + } + + // Extract the components + Integer networkBroadcastCount = request.getA(); + // Integer directPeerRequestCount = request.getB(); + Long lastAttemptTimestamp = request.getC(); + + if (lastAttemptTimestamp == null) { + // Not attempted yet + return true; + } + + long timeSinceLastAttempt = NTP.getTime() - lastAttemptTimestamp; + + // Allow a second attempt after 60 seconds + if (timeSinceLastAttempt > 60 * 1000L) { + // We haven't tried for at least 60 seconds + + if (networkBroadcastCount < 2) { + // We've made less than 2 total attempts + return true; + } + } + + // Then allow another attempt after 60 minutes + if (timeSinceLastAttempt > 60 * 60 * 1000L) { + // We haven't tried for at least 60 minutes + + if (networkBroadcastCount < 3) { + // We've made less than 3 total attempts + return true; + } + } + + return false; + } + + public boolean isSignatureRateLimited(byte[] signature) { + String signature58 = Base58.encode(signature); + return !this.shouldMakeMetadataRequestForSignature(signature58); + } + + public long lastRequestForSignature(byte[] signature) { + String signature58 = Base58.encode(signature); + Triple request = arbitraryMetadataSignatureRequests.get(signature58); + + if (request == null) { + // Not attempted yet + return 0; + } + + // Extract the components + Long lastAttemptTimestamp = request.getC(); + if (lastAttemptTimestamp != null) { + return lastAttemptTimestamp; + } + return 0; + } + + public void addToSignatureRequests(String signature58, boolean incrementNetworkRequests, boolean incrementPeerRequests) { + Triple request = arbitraryMetadataSignatureRequests.get(signature58); + Long now = NTP.getTime(); + + if (request == null) { + // No entry yet + Triple newRequest = new Triple<>(0, 0, now); + arbitraryMetadataSignatureRequests.put(signature58, newRequest); + } + else { + // There is an existing entry + if (incrementNetworkRequests) { + request.setA(request.getA() + 1); + } + if (incrementPeerRequests) { + request.setB(request.getB() + 1); + } + request.setC(now); + arbitraryMetadataSignatureRequests.put(signature58, request); + } + } + + public void removeFromSignatureRequests(String signature58) { + arbitraryMetadataSignatureRequests.remove(signature58); + } + + + // Network handlers + + public void onNetworkArbitraryMetadataMessage(RNSPeer peer, Message message) { + // Don't process if QDN is disabled + if (!Settings.getInstance().isQdnEnabled()) { + return; + } + + ArbitraryMetadataMessage arbitraryMetadataMessage = (ArbitraryMetadataMessage) message; + LOGGER.debug("Received metadata from peer {}", peer); + + // Do we have a pending request for this data? + Triple request = arbitraryMetadataRequests.get(message.getId()); + if (request == null || request.getA() == null) { + return; + } + boolean isRelayRequest = (request.getB() != null); + + // Does this message's signature match what we're expecting? + byte[] signature = arbitraryMetadataMessage.getSignature(); + String signature58 = Base58.encode(signature); + if (!request.getA().equals(signature58)) { + return; + } + + // Update requests map to reflect that we've received this metadata + Triple newEntry = new Triple<>(null, null, request.getC()); + arbitraryMetadataRequests.put(message.getId(), newEntry); + + // Get transaction info + try (final Repository repository = RepositoryManager.getRepository()) { + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + if (!(transactionData instanceof ArbitraryTransactionData)) { + return; + } + ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) transactionData; + + // Check if the name is blocked + boolean isBlocked = (arbitraryTransactionData == null || ListUtils.isNameBlocked(arbitraryTransactionData.getName())); + + // Save if not blocked + ArbitraryDataFile arbitraryMetadataFile = arbitraryMetadataMessage.getArbitraryMetadataFile(); + if (!isBlocked && arbitraryMetadataFile != null) { + arbitraryMetadataFile.save(); + } + + // Forwarding + if (isRelayRequest && Settings.getInstance().isRelayModeEnabled()) { + if (!isBlocked) { + RNSPeer requestingPeer = request.getB(); + if (requestingPeer != null) { + + ArbitraryMetadataMessage forwardArbitraryMetadataMessage = new ArbitraryMetadataMessage(signature, arbitraryMetadataMessage.getArbitraryMetadataFile()); + forwardArbitraryMetadataMessage.setId(arbitraryMetadataMessage.getId()); + + // Forward to requesting peer + LOGGER.debug("Forwarding metadata to requesting peer: {}", requestingPeer); + //if (!requestingPeer.sendMessage(forwardArbitraryMetadataMessage)) { + // requestingPeer.disconnect("failed to forward arbitrary metadata"); + //} + requestingPeer.sendMessage(forwardArbitraryMetadataMessage); + } + } + } + + // Add to resource queue to update arbitrary resource caches + if (arbitraryTransactionData != null) { + ArbitraryDataCacheManager.getInstance().addToUpdateQueue(arbitraryTransactionData); + } + + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while saving arbitrary transaction metadata from peer %s", peer), e); + } + } + + public void onNetworkGetArbitraryMetadataMessage(RNSPeer peer, Message message) { + // Don't respond if QDN is disabled + if (!Settings.getInstance().isQdnEnabled()) { + return; + } + + Controller.getInstance().stats.getArbitraryMetadataMessageStats.requests.incrementAndGet(); + + GetArbitraryMetadataMessage getArbitraryMetadataMessage = (GetArbitraryMetadataMessage) message; + byte[] signature = getArbitraryMetadataMessage.getSignature(); + String signature58 = Base58.encode(signature); + Long now = NTP.getTime(); + Triple newEntry = new Triple<>(signature58, peer, now); + + // If we've seen this request recently, then ignore + if (arbitraryMetadataRequests.putIfAbsent(message.getId(), newEntry) != null) { + LOGGER.debug("Ignoring metadata request from peer {} for signature {}", peer, signature58); + return; + } + + LOGGER.debug("Received metadata request from peer {} for signature {}", peer, signature58); + + ArbitraryTransactionData transactionData = null; + ArbitraryDataFile metadataFile = null; + + try (final Repository repository = RepositoryManager.getRepository()) { + + // Firstly we need to lookup this file on chain to get its metadata hash + transactionData = (ArbitraryTransactionData)repository.getTransactionRepository().fromSignature(signature); + if (transactionData instanceof ArbitraryTransactionData) { + + // Check if we're even allowed to serve metadata for this transaction + if (ArbitraryDataStorageManager.getInstance().canStoreData(transactionData)) { + + byte[] metadataHash = transactionData.getMetadataHash(); + if (metadataHash != null) { + + // Load metadata file + metadataFile = ArbitraryDataFile.fromHash(metadataHash, signature); + } + } + } + + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while fetching arbitrary metadata for peer %s", peer), e); + } + + // We should only respond if we have the metadata file + if (metadataFile != null && metadataFile.exists()) { + + // We have the metadata file, so update requests map to reflect that we've sent it + newEntry = new Triple<>(null, null, now); + arbitraryMetadataRequests.put(message.getId(), newEntry); + + ArbitraryMetadataMessage arbitraryMetadataMessage = new ArbitraryMetadataMessage(signature, metadataFile); + arbitraryMetadataMessage.setId(message.getId()); + //if (!peer.sendMessage(arbitraryMetadataMessage)) { + // LOGGER.debug("Couldn't send metadata"); + // peer.disconnect("failed to send metadata"); + // return; + //} + peer.sendMessage(arbitraryMetadataMessage); + LOGGER.debug("Sent metadata"); + + // Nothing left to do, so return to prevent any unnecessary forwarding from occurring + LOGGER.debug("No need for any forwarding because metadata request is fully served"); + return; + + } + + // We may need to forward this request on + boolean isBlocked = (transactionData == null || ListUtils.isNameBlocked(transactionData.getName())); + if (Settings.getInstance().isRelayModeEnabled() && !isBlocked) { + // In relay mode - so ask our other peers if they have it + + long requestTime = getArbitraryMetadataMessage.getRequestTime(); + int requestHops = getArbitraryMetadataMessage.getRequestHops() + 1; + long totalRequestTime = now - requestTime; + + if (totalRequestTime < RELAY_REQUEST_MAX_DURATION) { + // Relay request hasn't timed out yet, so can potentially be rebroadcast + if (requestHops < RELAY_REQUEST_MAX_HOPS) { + // Relay request hasn't reached the maximum number of hops yet, so can be rebroadcast + + Message relayGetArbitraryMetadataMessage = new GetArbitraryMetadataMessage(signature, requestTime, requestHops); + relayGetArbitraryMetadataMessage.setId(message.getId()); + + LOGGER.debug("Rebroadcasting metadata request from peer {} for signature {} to our other peers... totalRequestTime: {}, requestHops: {}", peer, Base58.encode(signature), totalRequestTime, requestHops); + //Network.getInstance().broadcast( + // broadcastPeer -> + // !broadcastPeer.isAtLeastVersion(RELAY_MIN_PEER_VERSION) ? null : + // broadcastPeer == peer || Objects.equals(broadcastPeer.getPeerData().getAddress().getHost(), peer.getPeerData().getAddress().getHost()) ? null : relayGetArbitraryMetadataMessage); + RNSNetwork.getInstance().broadcast(broadcastPeer -> relayGetArbitraryMetadataMessage); + + } + else { + // This relay request has reached the maximum number of allowed hops + } + } + else { + // This relay request has timed out + } + } + } + +} diff --git a/src/main/java/org/qortal/controller/arbitrary/RebuildArbitraryResourceCacheTask.java b/src/main/java/org/qortal/controller/arbitrary/RebuildArbitraryResourceCacheTask.java new file mode 100644 index 00000000..d7472325 --- /dev/null +++ b/src/main/java/org/qortal/controller/arbitrary/RebuildArbitraryResourceCacheTask.java @@ -0,0 +1,33 @@ +package org.qortal.controller.arbitrary; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; + +import java.util.TimerTask; + +public class RebuildArbitraryResourceCacheTask extends TimerTask { + + private static final Logger LOGGER = LogManager.getLogger(RebuildArbitraryResourceCacheTask.class); + + public static final long MILLIS_IN_HOUR = 60 * 60 * 1000; + + public static final long MILLIS_IN_MINUTE = 60 * 1000; + + private static final String REBUILD_ARBITRARY_RESOURCE_CACHE_TASK = "Rebuild Arbitrary Resource Cache Task"; + + @Override + public void run() { + + Thread.currentThread().setName(REBUILD_ARBITRARY_RESOURCE_CACHE_TASK); + + try (final Repository repository = RepositoryManager.getRepository()) { + ArbitraryDataCacheManager.getInstance().buildArbitraryResourcesCache(repository, true); + } + catch( DataException e ) { + LOGGER.error(e.getMessage(), e); + } + } +} diff --git a/src/main/java/org/qortal/controller/hsqldb/HSQLDBBalanceRecorder.java b/src/main/java/org/qortal/controller/hsqldb/HSQLDBBalanceRecorder.java new file mode 100644 index 00000000..43e7c542 --- /dev/null +++ b/src/main/java/org/qortal/controller/hsqldb/HSQLDBBalanceRecorder.java @@ -0,0 +1,139 @@ +package org.qortal.controller.hsqldb; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.util.PropertySource; +import org.qortal.data.account.AccountBalanceData; +import org.qortal.data.account.BlockHeightRange; +import org.qortal.data.account.BlockHeightRangeAddressAmounts; +import org.qortal.repository.hsqldb.HSQLDBCacheUtils; +import org.qortal.settings.Settings; +import org.qortal.utils.BalanceRecorderUtils; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +public class HSQLDBBalanceRecorder extends Thread{ + + private static final Logger LOGGER = LogManager.getLogger(HSQLDBBalanceRecorder.class); + + private static HSQLDBBalanceRecorder SINGLETON = null; + + private ConcurrentHashMap> balancesByHeight = new ConcurrentHashMap<>(); + + private ConcurrentHashMap> balancesByAddress = new ConcurrentHashMap<>(); + + private CopyOnWriteArrayList balanceDynamics = new CopyOnWriteArrayList<>(); + + private int priorityRequested; + private int frequency; + private int capacity; + + private HSQLDBBalanceRecorder( int priorityRequested, int frequency, int capacity) { + + super("Balance Recorder"); + + this.priorityRequested = priorityRequested; + this.frequency = frequency; + this.capacity = capacity; + } + + public static Optional getInstance() { + + if( SINGLETON == null ) { + + SINGLETON + = new HSQLDBBalanceRecorder( + Settings.getInstance().getBalanceRecorderPriority(), + Settings.getInstance().getBalanceRecorderFrequency(), + Settings.getInstance().getBalanceRecorderCapacity() + ); + + } + else if( SINGLETON == null ) { + + return Optional.empty(); + } + + return Optional.of(SINGLETON); + } + + @Override + public void run() { + + Thread.currentThread().setName("Balance Recorder"); + + HSQLDBCacheUtils.startRecordingBalances(this.balancesByHeight, this.balanceDynamics, this.priorityRequested, this.frequency, this.capacity); + } + + public List getLatestDynamics(int limit, long offset) { + + List latest = this.balanceDynamics.stream() + .sorted(BalanceRecorderUtils.BLOCK_HEIGHT_RANGE_ADDRESS_AMOUNTS_COMPARATOR.reversed()) + .skip(offset) + .limit(limit) + .collect(Collectors.toList()); + + return latest; + } + + public List getRanges(Integer offset, Integer limit, Boolean reverse) { + + if( reverse ) { + return this.balanceDynamics.stream() + .map(BlockHeightRangeAddressAmounts::getRange) + .sorted(BalanceRecorderUtils.BLOCK_HEIGHT_RANGE_COMPARATOR.reversed()) + .skip(offset) + .limit(limit) + .collect(Collectors.toList()); + } + else { + return this.balanceDynamics.stream() + .map(BlockHeightRangeAddressAmounts::getRange) + .sorted(BalanceRecorderUtils.BLOCK_HEIGHT_RANGE_COMPARATOR) + .skip(offset) + .limit(limit) + .collect(Collectors.toList()); + } + } + + public Optional getAddressAmounts(BlockHeightRange range) { + + return this.balanceDynamics.stream() + .filter( dynamic -> dynamic.getRange().equals(range)) + .findAny(); + } + + public Optional getRange( int height ) { + return this.balanceDynamics.stream() + .map(BlockHeightRangeAddressAmounts::getRange) + .filter( range -> range.getBegin() < height && range.getEnd() >= height ) + .findAny(); + } + + private Optional getLastHeight() { + return this.balancesByHeight.keySet().stream().sorted(Comparator.reverseOrder()).findFirst(); + } + + public List getBlocksRecorded() { + + return this.balancesByHeight.keySet().stream().collect(Collectors.toList()); + } + + public List getAccountBalanceRecordings(String address) { + return this.balancesByAddress.get(address); + } + + @Override + public String toString() { + return "HSQLDBBalanceRecorder{" + + "priorityRequested=" + priorityRequested + + ", frequency=" + frequency + + ", capacity=" + capacity + + '}'; + } +} diff --git a/src/main/java/org/qortal/controller/hsqldb/HSQLDBDataCacheManager.java b/src/main/java/org/qortal/controller/hsqldb/HSQLDBDataCacheManager.java new file mode 100644 index 00000000..434a67f1 --- /dev/null +++ b/src/main/java/org/qortal/controller/hsqldb/HSQLDBDataCacheManager.java @@ -0,0 +1,22 @@ +package org.qortal.controller.hsqldb; + +import org.qortal.data.arbitrary.ArbitraryResourceCache; +import org.qortal.repository.RepositoryManager; +import org.qortal.repository.hsqldb.HSQLDBCacheUtils; +import org.qortal.repository.hsqldb.HSQLDBRepository; +import org.qortal.settings.Settings; + +public class HSQLDBDataCacheManager extends Thread{ + + public HSQLDBDataCacheManager() {} + + @Override + public void run() { + Thread.currentThread().setName("HSQLDB Data Cache Manager"); + + HSQLDBCacheUtils.startCaching( + Settings.getInstance().getDbCacheThreadPriority(), + Settings.getInstance().getDbCacheFrequency() + ); + } +} diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index f06efdb8..3bc3db99 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -11,6 +11,8 @@ import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.utils.NTP; +import static java.lang.Thread.MIN_PRIORITY; + public class AtStatesPruner implements Runnable { private static final Logger LOGGER = LogManager.getLogger(AtStatesPruner.class); @@ -37,82 +39,97 @@ public class AtStatesPruner implements Runnable { } } + int pruneStartHeight; + int maxLatestAtStatesHeight; + try (final Repository repository = RepositoryManager.getRepository()) { - int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); - int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + pruneStartHeight = repository.getATRepository().getAtPruneHeight(); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); + } catch (Exception e) { + LOGGER.error("AT States Pruning is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); + return; + } - while (!Controller.isStopping()) { - repository.discardChanges(); + while (!Controller.isStopping()) { + try (final Repository repository = RepositoryManager.getRepository()) { - Thread.sleep(Settings.getInstance().getAtStatesPruneInterval()); + try { + repository.discardChanges(); - BlockData chainTip = Controller.getInstance().getChainTip(); - if (chainTip == null || NTP.getTime() == null) - continue; + Thread.sleep(Settings.getInstance().getAtStatesPruneInterval()); - // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages - if (Synchronizer.getInstance().isSynchronizing()) - continue; + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; - // Prune AT states for all blocks up until our latest minus pruneBlockLimit - final int ourLatestHeight = chainTip.getHeight(); - int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Synchronizer.getInstance().isSynchronizing()) + continue; - // In archive mode we are only allowed to trim blocks that have already been archived - if (archiveMode) { - upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + // Prune AT states for all blocks up until our latest minus pruneBlockLimit + final int ourLatestHeight = chainTip.getHeight(); + int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); - // TODO: validate that the actual archived data exists before pruning it? - } + // In archive mode we are only allowed to trim blocks that have already been archived + if (archiveMode) { + upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; - int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize(); - int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + // TODO: validate that the actual archived data exists before pruning it? + } - if (pruneStartHeight >= upperPruneHeight) - continue; + int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize(); + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); - LOGGER.debug(String.format("Pruning AT states between blocks %d and %d...", pruneStartHeight, upperPruneHeight)); + if (pruneStartHeight >= upperPruneHeight) + continue; - int numAtStatesPruned = repository.getATRepository().pruneAtStates(pruneStartHeight, upperPruneHeight); - repository.saveChanges(); - int numAtStateDataRowsTrimmed = repository.getATRepository().trimAtStates( - pruneStartHeight, upperPruneHeight, Settings.getInstance().getAtStatesTrimLimit()); - repository.saveChanges(); + LOGGER.info(String.format("Pruning AT states between blocks %d and %d...", pruneStartHeight, upperPruneHeight)); - if (numAtStatesPruned > 0 || numAtStateDataRowsTrimmed > 0) { - final int finalPruneStartHeight = pruneStartHeight; - LOGGER.debug(() -> String.format("Pruned %d AT state%s between blocks %d and %d", - numAtStatesPruned, (numAtStatesPruned != 1 ? "s" : ""), - finalPruneStartHeight, upperPruneHeight)); - } else { - // Can we move onto next batch? - if (upperPrunableHeight > upperBatchHeight) { - pruneStartHeight = upperBatchHeight; - repository.getATRepository().setAtPruneHeight(pruneStartHeight); - maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); - repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); - repository.saveChanges(); + int numAtStatesPruned = repository.getATRepository().pruneAtStates(pruneStartHeight, upperPruneHeight); + repository.saveChanges(); + int numAtStateDataRowsTrimmed = repository.getATRepository().trimAtStates( + pruneStartHeight, upperPruneHeight, Settings.getInstance().getAtStatesTrimLimit()); + repository.saveChanges(); + if (numAtStatesPruned > 0 || numAtStateDataRowsTrimmed > 0) { final int finalPruneStartHeight = pruneStartHeight; - LOGGER.debug(() -> String.format("Bumping AT state base prune height to %d", finalPruneStartHeight)); + LOGGER.info(() -> String.format("Pruned %d AT state%s between blocks %d and %d", + numAtStatesPruned, (numAtStatesPruned != 1 ? "s" : ""), + finalPruneStartHeight, upperPruneHeight)); + } else { + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + repository.getATRepository().setAtPruneHeight(pruneStartHeight); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); + repository.saveChanges(); + + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.info(() -> String.format("Bumping AT state base prune height to %d", finalPruneStartHeight)); + } else { + // We've pruned up to the upper prunable height + // Back off for a while to save CPU for syncing + repository.discardChanges(); + Thread.sleep(5 * 60 * 1000L); + } } - else { - // We've pruned up to the upper prunable height - // Back off for a while to save CPU for syncing - repository.discardChanges(); - Thread.sleep(5*60*1000L); + } catch (InterruptedException e) { + if (Controller.isStopping()) { + LOGGER.info("AT States Pruning Shutting Down"); + } else { + LOGGER.warn("AT States Pruning interrupted. Trying again. Report this error immediately to the developers.", e); } + } catch (Exception e) { + LOGGER.warn("AT States Pruning stopped working. Trying again. Report this error immediately to the developers.", e); } + } catch(Exception e){ + LOGGER.error("AT States Pruning is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); } - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue trying to prune AT states: %s", e.getMessage())); - } catch (InterruptedException e) { - // Time to exit } } - } diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index 125628f1..d188f81a 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -11,6 +11,8 @@ import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.utils.NTP; +import static java.lang.Thread.MIN_PRIORITY; + public class AtStatesTrimmer implements Runnable { private static final Logger LOGGER = LogManager.getLogger(AtStatesTrimmer.class); @@ -24,66 +26,83 @@ public class AtStatesTrimmer implements Runnable { return; } + int trimStartHeight; + int maxLatestAtStatesHeight; + try (final Repository repository = RepositoryManager.getRepository()) { - int trimStartHeight = repository.getATRepository().getAtTrimHeight(); - int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + trimStartHeight = repository.getATRepository().getAtTrimHeight(); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); repository.saveChanges(); + } catch (Exception e) { + LOGGER.error("AT States Trimming is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); + return; + } - while (!Controller.isStopping()) { - repository.discardChanges(); + while (!Controller.isStopping()) { + try (final Repository repository = RepositoryManager.getRepository()) { + try { + repository.discardChanges(); - Thread.sleep(Settings.getInstance().getAtStatesTrimInterval()); + Thread.sleep(Settings.getInstance().getAtStatesTrimInterval()); - BlockData chainTip = Controller.getInstance().getChainTip(); - if (chainTip == null || NTP.getTime() == null) - continue; + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; - // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages - if (Synchronizer.getInstance().isSynchronizing()) - continue; + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Synchronizer.getInstance().isSynchronizing()) + continue; - long currentTrimmableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); - // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks - long chainTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); + long currentTrimmableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); + // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks + long chainTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); - long upperTrimmableTimestamp = Math.min(currentTrimmableTimestamp, chainTrimmableTimestamp); - int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); + long upperTrimmableTimestamp = Math.min(currentTrimmableTimestamp, chainTrimmableTimestamp); + int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); - int upperBatchHeight = trimStartHeight + Settings.getInstance().getAtStatesTrimBatchSize(); - int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); + int upperBatchHeight = trimStartHeight + Settings.getInstance().getAtStatesTrimBatchSize(); + int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); - if (trimStartHeight >= upperTrimHeight) - continue; + if (trimStartHeight >= upperTrimHeight) + continue; - int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimStartHeight, upperTrimHeight, Settings.getInstance().getAtStatesTrimLimit()); - repository.saveChanges(); - - if (numAtStatesTrimmed > 0) { - final int finalTrimStartHeight = trimStartHeight; - LOGGER.debug(() -> String.format("Trimmed %d AT state%s between blocks %d and %d", - numAtStatesTrimmed, (numAtStatesTrimmed != 1 ? "s" : ""), - finalTrimStartHeight, upperTrimHeight)); - } else { - // Can we move onto next batch? - if (upperTrimmableHeight > upperBatchHeight) { - trimStartHeight = upperBatchHeight; - repository.getATRepository().setAtTrimHeight(trimStartHeight); - maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); - repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); - repository.saveChanges(); + int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimStartHeight, upperTrimHeight, Settings.getInstance().getAtStatesTrimLimit()); + repository.saveChanges(); + if (numAtStatesTrimmed > 0) { final int finalTrimStartHeight = trimStartHeight; - LOGGER.debug(() -> String.format("Bumping AT state base trim height to %d", finalTrimStartHeight)); + LOGGER.info(() -> String.format("Trimmed %d AT state%s between blocks %d and %d", + numAtStatesTrimmed, (numAtStatesTrimmed != 1 ? "s" : ""), + finalTrimStartHeight, upperTrimHeight)); + } else { + // Can we move onto next batch? + if (upperTrimmableHeight > upperBatchHeight) { + trimStartHeight = upperBatchHeight; + repository.getATRepository().setAtTrimHeight(trimStartHeight); + maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository); + repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight); + repository.saveChanges(); + + final int finalTrimStartHeight = trimStartHeight; + LOGGER.info(() -> String.format("Bumping AT state base trim height to %d", finalTrimStartHeight)); + } } + } catch (InterruptedException e) { + if(Controller.isStopping()) { + LOGGER.info("AT States Trimming Shutting Down"); + } + else { + LOGGER.warn("AT States Trimming interrupted. Trying again. Report this error immediately to the developers.", e); + } + } catch (Exception e) { + LOGGER.warn("AT States Trimming stopped working. Trying again. Report this error immediately to the developers.", e); } + } catch (Exception e) { + LOGGER.error("AT States Trimming is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); } - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue trying to trim AT states: %s", e.getMessage())); - } catch (InterruptedException e) { - // Time to exit } } diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index a643d9b9..01cf40ed 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -15,11 +15,13 @@ import org.qortal.utils.NTP; import java.io.IOException; +import static java.lang.Thread.NORM_PRIORITY; + public class BlockArchiver implements Runnable { private static final Logger LOGGER = LogManager.getLogger(BlockArchiver.class); - private static final long INITIAL_SLEEP_PERIOD = 5 * 60 * 1000L + 1234L; // ms + private static final long INITIAL_SLEEP_PERIOD = 15 * 60 * 1000L; // ms public void run() { Thread.currentThread().setName("Block archiver"); @@ -28,11 +30,13 @@ public class BlockArchiver implements Runnable { return; } + int startHeight; + try (final Repository repository = RepositoryManager.getRepository()) { // Don't even start building until initial rush has ended Thread.sleep(INITIAL_SLEEP_PERIOD); - int startHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + startHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); // Don't attempt to archive if we have no ATStatesHeightIndex, as it will be too slow boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex(); @@ -41,77 +45,87 @@ public class BlockArchiver implements Runnable { repository.discardChanges(); return; } - - LOGGER.info("Starting block archiver from height {}...", startHeight); - - while (!Controller.isStopping()) { - repository.discardChanges(); - - Thread.sleep(Settings.getInstance().getArchiveInterval()); - - BlockData chainTip = Controller.getInstance().getChainTip(); - if (chainTip == null || NTP.getTime() == null) { - continue; - } - - // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages - if (Synchronizer.getInstance().isSynchronizing()) { - continue; - } - - // Don't attempt to archive if we're not synced yet - final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); - if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { - continue; - } - - - // Build cache of blocks - try { - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); - BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); - BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); - switch (result) { - case OK: - // Increment block archive height - startHeight += writer.getWrittenCount(); - repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); - 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: - // We didn't reach our file size target, so that must mean that we don't have enough blocks - // yet or something went wrong. Sleep for a while and then try again. - repository.discardChanges(); - Thread.sleep(60 * 60 * 1000L); // 1 hour - break; - - 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 building archive. If this error persists, " + - "a bootstrap or re-sync may be needed."); - repository.discardChanges(); - Thread.sleep( 60 * 1000L); // 1 minute - break; - } - - } catch (IOException | TransformationException e) { - LOGGER.info("Caught exception when creating block cache", e); - } - - } - } catch (DataException e) { - LOGGER.info("Caught exception when creating block cache", e); - } catch (InterruptedException e) { - // Do nothing + } catch (Exception e) { + LOGGER.error("Block Archiving is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); + return; } - } + LOGGER.info("Starting block archiver from height {}...", startHeight); + while (!Controller.isStopping()) { + try (final Repository repository = RepositoryManager.getRepository()) { + + try { + repository.discardChanges(); + + Thread.sleep(Settings.getInstance().getArchiveInterval()); + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) { + continue; + } + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Synchronizer.getInstance().isSynchronizing()) { + continue; + } + + // Don't attempt to archive if we're not synced yet + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { + continue; + } + + // Build cache of blocks + try { + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + switch (result) { + case OK: + // Increment block archive height + startHeight += writer.getWrittenCount(); + repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); + 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: + // We didn't reach our file size target, so that must mean that we don't have enough blocks + // yet or something went wrong. Sleep for a while and then try again. + repository.discardChanges(); + Thread.sleep(2 * 60 * 60 * 1000L); // 1 hour + break; + + 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 building archive. If this error persists, " + + "a bootstrap or re-sync may be needed."); + repository.discardChanges(); + Thread.sleep(60 * 1000L); // 1 minute + break; + } + + } catch (IOException | TransformationException e) { + LOGGER.info("Caught exception when creating block cache", e); + } + } catch (InterruptedException e) { + if (Controller.isStopping()) { + LOGGER.info("Block Archiving Shutting Down"); + } else { + LOGGER.warn("Block Archiving interrupted. Trying again. Report this error immediately to the developers.", e); + } + } catch (Exception e) { + LOGGER.warn("Block Archiving stopped working. Trying again. Report this error immediately to the developers.", e); + } + } catch(Exception e){ + LOGGER.error("Block Archiving is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); + } + } + } } diff --git a/src/main/java/org/qortal/controller/repository/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java index 23e3a45a..7801f284 100644 --- a/src/main/java/org/qortal/controller/repository/BlockPruner.java +++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java @@ -11,6 +11,8 @@ import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.utils.NTP; +import static java.lang.Thread.NORM_PRIORITY; + public class BlockPruner implements Runnable { private static final Logger LOGGER = LogManager.getLogger(BlockPruner.class); @@ -37,8 +39,10 @@ public class BlockPruner implements Runnable { } } + int pruneStartHeight; + try (final Repository repository = RepositoryManager.getRepository()) { - int pruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); + pruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); // Don't attempt to prune if we have no ATStatesHeightIndex, as it will be too slow boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex(); @@ -46,75 +50,90 @@ public class BlockPruner implements Runnable { LOGGER.info("Unable to start block pruner due to missing ATStatesHeightIndex. Bootstrapping is recommended."); return; } + } catch (Exception e) { + LOGGER.error("Block Pruning is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); + return; + } - while (!Controller.isStopping()) { - repository.discardChanges(); + while (!Controller.isStopping()) { - Thread.sleep(Settings.getInstance().getBlockPruneInterval()); + try (final Repository repository = RepositoryManager.getRepository()) { - BlockData chainTip = Controller.getInstance().getChainTip(); - if (chainTip == null || NTP.getTime() == null) - continue; + try { + repository.discardChanges(); - // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages - if (Synchronizer.getInstance().isSynchronizing()) { - continue; - } + Thread.sleep(Settings.getInstance().getBlockPruneInterval()); - // Don't attempt to prune if we're not synced yet - final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); - if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { - continue; - } + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; - // Prune all blocks up until our latest minus pruneBlockLimit - final int ourLatestHeight = chainTip.getHeight(); - int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Synchronizer.getInstance().isSynchronizing()) { + continue; + } - // In archive mode we are only allowed to trim blocks that have already been archived - if (archiveMode) { - upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; - } + // Don't attempt to prune if we're not synced yet + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { + continue; + } - int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); - int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + // Prune all blocks up until our latest minus pruneBlockLimit + final int ourLatestHeight = chainTip.getHeight(); + int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); - if (pruneStartHeight >= upperPruneHeight) { - continue; - } + // In archive mode we are only allowed to trim blocks that have already been archived + if (archiveMode) { + upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + } - LOGGER.debug(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); + int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); - int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); - repository.saveChanges(); + if (pruneStartHeight >= upperPruneHeight) { + continue; + } - if (numBlocksPruned > 0) { - LOGGER.debug(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); + LOGGER.info(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); repository.saveChanges(); - LOGGER.debug(String.format("Bumping block base prune height to %d", pruneStartHeight)); - // Can we move onto next batch? - if (upperPrunableHeight > nextPruneHeight) { - pruneStartHeight = nextPruneHeight; + 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.info(String.format("Bumping block base prune height to %d", pruneStartHeight)); + + // Can we move onto next batch? + if (upperPrunableHeight > nextPruneHeight) { + pruneStartHeight = nextPruneHeight; + } + else { + // We've pruned up to the upper prunable height + // Back off for a while to save CPU for syncing + repository.discardChanges(); + Thread.sleep(10*60*1000L); + } + } + } catch (InterruptedException e) { + if(Controller.isStopping()) { + LOGGER.info("Block Pruning Shutting Down"); } else { - // We've pruned up to the upper prunable height - // Back off for a while to save CPU for syncing - repository.discardChanges(); - Thread.sleep(10*60*1000L); + LOGGER.warn("Block Pruning interrupted. Trying again. Report this error immediately to the developers.", e); } + } catch (Exception e) { + LOGGER.warn("Block Pruning stopped working. Trying again. Report this error immediately to the developers.", e); } + } catch(Exception e){ + LOGGER.error("Block Pruning is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); } - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue trying to prune blocks: %s", e.getMessage())); - } catch (InterruptedException e) { - // Time to exit } } - } diff --git a/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java index d74df4b5..c2d37e14 100644 --- a/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java @@ -12,6 +12,8 @@ import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.utils.NTP; +import static java.lang.Thread.NORM_PRIORITY; + public class OnlineAccountsSignaturesTrimmer implements Runnable { private static final Logger LOGGER = LogManager.getLogger(OnlineAccountsSignaturesTrimmer.class); @@ -26,61 +28,77 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { return; } + int trimStartHeight; + try (final Repository repository = RepositoryManager.getRepository()) { // Don't even start trimming until initial rush has ended Thread.sleep(INITIAL_SLEEP_PERIOD); - int trimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + trimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + } catch (Exception e) { + LOGGER.error("Online Accounts Signatures Trimming is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); + return; + } - while (!Controller.isStopping()) { - repository.discardChanges(); + while (!Controller.isStopping()) { + try (final Repository repository = RepositoryManager.getRepository()) { - Thread.sleep(Settings.getInstance().getOnlineSignaturesTrimInterval()); + try { + repository.discardChanges(); - BlockData chainTip = Controller.getInstance().getChainTip(); - if (chainTip == null || NTP.getTime() == null) - continue; + Thread.sleep(Settings.getInstance().getOnlineSignaturesTrimInterval()); - // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages - if (Synchronizer.getInstance().isSynchronizing()) - continue; + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; - // Trim blockchain by removing 'old' online accounts signatures - long upperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); - int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Synchronizer.getInstance().isSynchronizing()) + continue; - int upperBatchHeight = trimStartHeight + Settings.getInstance().getOnlineSignaturesTrimBatchSize(); - int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); + // Trim blockchain by removing 'old' online accounts signatures + long upperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); + int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); - if (trimStartHeight >= upperTrimHeight) - continue; + int upperBatchHeight = trimStartHeight + Settings.getInstance().getOnlineSignaturesTrimBatchSize(); + int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); - int numSigsTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(trimStartHeight, upperTrimHeight); - repository.saveChanges(); + if (trimStartHeight >= upperTrimHeight) + continue; - if (numSigsTrimmed > 0) { - final int finalTrimStartHeight = trimStartHeight; - LOGGER.debug(() -> String.format("Trimmed %d online accounts signature%s between blocks %d and %d", - numSigsTrimmed, (numSigsTrimmed != 1 ? "s" : ""), - finalTrimStartHeight, upperTrimHeight)); - } else { - // Can we move onto next batch? - if (upperTrimmableHeight > upperBatchHeight) { - trimStartHeight = upperBatchHeight; - - repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(trimStartHeight); - repository.saveChanges(); + int numSigsTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(trimStartHeight, upperTrimHeight); + repository.saveChanges(); + if (numSigsTrimmed > 0) { final int finalTrimStartHeight = trimStartHeight; - LOGGER.debug(() -> String.format("Bumping online accounts signatures base trim height to %d", finalTrimStartHeight)); + LOGGER.info(() -> String.format("Trimmed %d online accounts signature%s between blocks %d and %d", + numSigsTrimmed, (numSigsTrimmed != 1 ? "s" : ""), + finalTrimStartHeight, upperTrimHeight)); + } else { + // Can we move onto next batch? + if (upperTrimmableHeight > upperBatchHeight) { + trimStartHeight = upperBatchHeight; + + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(trimStartHeight); + repository.saveChanges(); + + final int finalTrimStartHeight = trimStartHeight; + LOGGER.info(() -> String.format("Bumping online accounts signatures base trim height to %d", finalTrimStartHeight)); + } } + } catch (InterruptedException e) { + if(Controller.isStopping()) { + LOGGER.info("Online Accounts Signatures Trimming Shutting Down"); + } + else { + LOGGER.warn("Online Accounts Signatures Trimming interrupted. Trying again. Report this error immediately to the developers.", e); + } + } catch (Exception e) { + LOGGER.warn("Online Accounts Signatures Trimming stopped working. Trying again. Report this error immediately to the developers.", e); } + } catch (Exception e) { + LOGGER.error("Online Accounts Signatures Trimming is not working! Not trying again. Restart ASAP. Report this error immediately to the developers.", e); } - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue trying to trim online accounts signatures: %s", e.getMessage())); - } catch (InterruptedException e) { - // Time to exit } } - } diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index d48f85f7..8865668b 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -40,7 +40,7 @@ public class PruneManager { } public void start() { - this.executorService = Executors.newCachedThreadPool(new DaemonThreadFactory()); + this.executorService = Executors.newCachedThreadPool(new DaemonThreadFactory(Settings.getInstance().getPruningThreadPriority())); if (Settings.getInstance().isTopOnly()) { // Top-only-sync diff --git a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java index 259a16b8..e7cb0fb8 100644 --- a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java @@ -7,6 +7,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.CrossChainUtils; import org.qortal.asset.Asset; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; @@ -527,7 +528,7 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot { // P2SH-A funding confirmed // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = BitcoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -893,7 +894,7 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot { // Redeem P2SH-B using secret-B Coin redeemAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. The real funds are in P2SH-A. ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB, false); byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, @@ -1063,7 +1064,7 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA, false); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); @@ -1135,7 +1136,7 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot { case FUNDED:{ Coin refundAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB, false); // Determine receive address for refund String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); @@ -1201,7 +1202,7 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot { case FUNDED:{ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA, false); // Determine receive address for refund String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); diff --git a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java index 9ab97be9..18f79b81 100644 --- a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv3TradeBot.java @@ -7,7 +7,9 @@ import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.CrossChainUtils; import org.qortal.asset.Asset; +import org.qortal.controller.tradebot.TradeStates.State; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -30,12 +32,8 @@ import org.qortal.utils.NTP; import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; - /** * Performing cross-chain trading steps on behalf of user. *

@@ -50,45 +48,6 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { private static final Logger LOGGER = LogManager.getLogger(BitcoinACCTv3TradeBot.class); - public enum State implements TradeBot.StateNameAndValueSupplier { - BOB_WAITING_FOR_AT_CONFIRM(10, false, false), - BOB_WAITING_FOR_MESSAGE(15, true, true), - BOB_WAITING_FOR_AT_REDEEM(25, true, true), - BOB_DONE(30, false, false), - BOB_REFUNDED(35, false, false), - - ALICE_WAITING_FOR_AT_LOCK(85, true, true), - ALICE_DONE(95, false, false), - ALICE_REFUNDING_A(105, true, true), - ALICE_REFUNDED(110, false, false); - - private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); - - public final int value; - public final boolean requiresAtData; - public final boolean requiresTradeData; - - State(int value, boolean requiresAtData, boolean requiresTradeData) { - this.value = value; - this.requiresAtData = requiresAtData; - this.requiresTradeData = requiresTradeData; - } - - public static State valueOf(int value) { - return map.get(value); - } - - @Override - public String getState() { - return this.name(); - } - - @Override - public int getStateValue() { - return this.value; - } - } - /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms @@ -313,7 +272,7 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { } // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = BitcoinACCTv3.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -793,7 +752,7 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA, false); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); @@ -857,7 +816,7 @@ public class BitcoinACCTv3TradeBot implements AcctTradeBot { case FUNDED:{ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA, false); // Determine receive address for refund String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); diff --git a/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java index 4b1ba7bb..5b65c9a1 100644 --- a/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DigibyteACCTv3TradeBot.java @@ -7,6 +7,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.CrossChainUtils; import org.qortal.asset.Asset; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; @@ -30,11 +31,9 @@ import org.qortal.utils.NTP; import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; +import org.qortal.controller.tradebot.TradeStates.State; /** * Performing cross-chain trading steps on behalf of user. @@ -50,45 +49,6 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { private static final Logger LOGGER = LogManager.getLogger(DigibyteACCTv3TradeBot.class); - public enum State implements TradeBot.StateNameAndValueSupplier { - BOB_WAITING_FOR_AT_CONFIRM(10, false, false), - BOB_WAITING_FOR_MESSAGE(15, true, true), - BOB_WAITING_FOR_AT_REDEEM(25, true, true), - BOB_DONE(30, false, false), - BOB_REFUNDED(35, false, false), - - ALICE_WAITING_FOR_AT_LOCK(85, true, true), - ALICE_DONE(95, false, false), - ALICE_REFUNDING_A(105, true, true), - ALICE_REFUNDED(110, false, false); - - private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); - - public final int value; - public final boolean requiresAtData; - public final boolean requiresTradeData; - - State(int value, boolean requiresAtData, boolean requiresTradeData) { - this.value = value; - this.requiresAtData = requiresAtData; - this.requiresTradeData = requiresTradeData; - } - - public static State valueOf(int value) { - return map.get(value); - } - - @Override - public String getState() { - return this.name(); - } - - @Override - public int getStateValue() { - return this.value; - } - } - /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms @@ -313,7 +273,7 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { } // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = DigibyteACCTv3.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -793,7 +753,7 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = digibyte.getUnspentOutputs(p2shAddressA); + List fundingOutputs = digibyte.getUnspentOutputs(p2shAddressA, false); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(digibyte.getNetworkParameters(), redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); @@ -857,7 +817,7 @@ public class DigibyteACCTv3TradeBot implements AcctTradeBot { case FUNDED:{ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = digibyte.getUnspentOutputs(p2shAddressA); + List fundingOutputs = digibyte.getUnspentOutputs(p2shAddressA, false); // Determine receive address for refund String receiveAddress = digibyte.getUnusedReceiveAddress(tradeBotData.getForeignKey()); diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java index 52e7bb24..6c9f5a29 100644 --- a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java @@ -7,6 +7,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.CrossChainUtils; import org.qortal.asset.Asset; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; @@ -313,7 +314,7 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { } // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = DogecoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -793,7 +794,7 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA, false); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(dogecoin.getNetworkParameters(), redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); @@ -857,7 +858,7 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { case FUNDED:{ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA, false); // Determine receive address for refund String receiveAddress = dogecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java index b57b9354..6a2ef700 100644 --- a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv3TradeBot.java @@ -7,6 +7,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.CrossChainUtils; import org.qortal.asset.Asset; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; @@ -30,11 +31,9 @@ import org.qortal.utils.NTP; import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; +import org.qortal.controller.tradebot.TradeStates.State; /** * Performing cross-chain trading steps on behalf of user. @@ -50,45 +49,6 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv3TradeBot.class); - public enum State implements TradeBot.StateNameAndValueSupplier { - BOB_WAITING_FOR_AT_CONFIRM(10, false, false), - BOB_WAITING_FOR_MESSAGE(15, true, true), - BOB_WAITING_FOR_AT_REDEEM(25, true, true), - BOB_DONE(30, false, false), - BOB_REFUNDED(35, false, false), - - ALICE_WAITING_FOR_AT_LOCK(85, true, true), - ALICE_DONE(95, false, false), - ALICE_REFUNDING_A(105, true, true), - ALICE_REFUNDED(110, false, false); - - private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); - - public final int value; - public final boolean requiresAtData; - public final boolean requiresTradeData; - - State(int value, boolean requiresAtData, boolean requiresTradeData) { - this.value = value; - this.requiresAtData = requiresAtData; - this.requiresTradeData = requiresTradeData; - } - - public static State valueOf(int value) { - return map.get(value); - } - - @Override - public String getState() { - return this.name(); - } - - @Override - public int getStateValue() { - return this.value; - } - } - /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms @@ -313,7 +273,7 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { } // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = DogecoinACCTv3.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -793,7 +753,7 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA, false); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(dogecoin.getNetworkParameters(), redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); @@ -857,7 +817,7 @@ public class DogecoinACCTv3TradeBot implements AcctTradeBot { case FUNDED:{ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA, false); // Determine receive address for refund String receiveAddress = dogecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java index 0b612d11..cef93d12 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java @@ -7,6 +7,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.CrossChainUtils; import org.qortal.asset.Asset; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; @@ -312,7 +313,7 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot { } // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = LitecoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -756,7 +757,7 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA, false); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); @@ -820,7 +821,7 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot { case FUNDED:{ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA, false); // Determine receive address for refund String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java index b5631f0b..aa791e96 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java @@ -7,6 +7,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.CrossChainUtils; import org.qortal.asset.Asset; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; @@ -30,12 +31,9 @@ import org.qortal.utils.NTP; import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; - +import org.qortal.controller.tradebot.TradeStates.State; /** * Performing cross-chain trading steps on behalf of user. *

@@ -50,45 +48,6 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { private static final Logger LOGGER = LogManager.getLogger(LitecoinACCTv3TradeBot.class); - public enum State implements TradeBot.StateNameAndValueSupplier { - BOB_WAITING_FOR_AT_CONFIRM(10, false, false), - BOB_WAITING_FOR_MESSAGE(15, true, true), - BOB_WAITING_FOR_AT_REDEEM(25, true, true), - BOB_DONE(30, false, false), - BOB_REFUNDED(35, false, false), - - ALICE_WAITING_FOR_AT_LOCK(85, true, true), - ALICE_DONE(95, false, false), - ALICE_REFUNDING_A(105, true, true), - ALICE_REFUNDED(110, false, false); - - private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); - - public final int value; - public final boolean requiresAtData; - public final boolean requiresTradeData; - - State(int value, boolean requiresAtData, boolean requiresTradeData) { - this.value = value; - this.requiresAtData = requiresAtData; - this.requiresTradeData = requiresTradeData; - } - - public static State valueOf(int value) { - return map.get(value); - } - - @Override - public String getState() { - return this.name(); - } - - @Override - public int getStateValue() { - return this.value; - } - } - /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms @@ -313,7 +272,7 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { } // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = LitecoinACCTv3.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -793,7 +752,7 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA, false); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); @@ -857,7 +816,7 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { case FUNDED:{ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA, false); // Determine receive address for refund String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); diff --git a/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java index c48f23e2..70ee8705 100644 --- a/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/PirateChainACCTv3TradeBot.java @@ -9,6 +9,7 @@ import org.bitcoinj.core.Coin; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.CrossChainUtils; import org.qortal.asset.Asset; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; @@ -32,11 +33,9 @@ import org.qortal.utils.NTP; import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; +import org.qortal.controller.tradebot.TradeStates.State; /** * Performing cross-chain trading steps on behalf of user. @@ -52,45 +51,6 @@ public class PirateChainACCTv3TradeBot implements AcctTradeBot { private static final Logger LOGGER = LogManager.getLogger(PirateChainACCTv3TradeBot.class); - public enum State implements TradeBot.StateNameAndValueSupplier { - BOB_WAITING_FOR_AT_CONFIRM(10, false, false), - BOB_WAITING_FOR_MESSAGE(15, true, true), - BOB_WAITING_FOR_AT_REDEEM(25, true, true), - BOB_DONE(30, false, false), - BOB_REFUNDED(35, false, false), - - ALICE_WAITING_FOR_AT_LOCK(85, true, true), - ALICE_DONE(95, false, false), - ALICE_REFUNDING_A(105, true, true), - ALICE_REFUNDED(110, false, false); - - private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); - - public final int value; - public final boolean requiresAtData; - public final boolean requiresTradeData; - - State(int value, boolean requiresAtData, boolean requiresTradeData) { - this.value = value; - this.requiresAtData = requiresAtData; - this.requiresTradeData = requiresTradeData; - } - - public static State valueOf(int value) { - return map.get(value); - } - - @Override - public String getState() { - return this.name(); - } - - @Override - public int getStateValue() { - return this.value; - } - } - /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms @@ -317,7 +277,7 @@ public class PirateChainACCTv3TradeBot implements AcctTradeBot { } // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = PirateChainACCTv3.buildOfferMessage(tradeBotData.getTradeForeignPublicKey(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKey(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); diff --git a/src/main/java/org/qortal/controller/tradebot/RNSTradeBot.java b/src/main/java/org/qortal/controller/tradebot/RNSTradeBot.java new file mode 100644 index 00000000..c648cbbd --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/RNSTradeBot.java @@ -0,0 +1,778 @@ +package org.qortal.controller.tradebot; + +import com.google.common.primitives.Longs; +import org.apache.logging.log4j.LogManager; +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.controller.Controller; +import org.qortal.controller.Synchronizer; +import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult; +import org.qortal.crosschain.*; +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.network.TradePresenceData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.event.Event; +import org.qortal.event.EventBus; +import org.qortal.event.Listener; +import org.qortal.gui.SysTray; +import org.qortal.network.RNSNetwork; +import org.qortal.network.RNSPeer; +import org.qortal.network.message.GetTradePresencesMessage; +import org.qortal.network.message.Message; +import org.qortal.network.message.TradePresencesMessage; +import org.qortal.repository.DataException; +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; + +import java.awt.TrayIcon.MessageType; +import java.security.SecureRandom; +import java.util.*; +import java.util.function.Supplier; + +/** + * Performing cross-chain trading steps on behalf of user. + *

+ * We deal with three different independent state-spaces here: + *

    + *
  • Qortal blockchain
  • + *
  • Foreign blockchain
  • + *
  • Trade-bot entries
  • + *
+ */ +public class RNSTradeBot implements Listener { + + private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); + private static final Random RANDOM = new SecureRandom(); + + /** Maximum lifetime of trade presence timestamp. 30 mins in ms. */ + private static final long PRESENCE_LIFETIME = 30 * 60 * 1000L; + /** How soon before expiry of our own trade presence timestamp that we want to trigger renewal. 5 mins in ms. */ + private static final long EARLY_RENEWAL_PERIOD = 5 * 60 * 1000L; + /** Trade presence timestamps are rounded up to this nearest interval. Bigger values improve grouping of entries in [GET_]TRADE_PRESENCES network messages. 15 mins in ms. */ + private static final long EXPIRY_ROUNDING = 15 * 60 * 1000L; + /** How often we want to broadcast our list of all known trade presences to peers. 5 mins in ms. */ + private static final long PRESENCE_BROADCAST_INTERVAL = 5 * 60 * 1000L; + + public interface StateNameAndValueSupplier { + public String getState(); + public int getStateValue(); + } + + public static class StateChangeEvent implements Event { + private final TradeBotData tradeBotData; + + public StateChangeEvent(TradeBotData tradeBotData) { + this.tradeBotData = tradeBotData; + } + + public TradeBotData getTradeBotData() { + return this.tradeBotData; + } + } + + public static class TradePresenceEvent implements Event { + private final TradePresenceData tradePresenceData; + + public TradePresenceEvent(TradePresenceData tradePresenceData) { + this.tradePresenceData = tradePresenceData; + } + + public TradePresenceData getTradePresenceData() { + return this.tradePresenceData; + } + } + + private static final Map, Supplier> acctTradeBotSuppliers = new HashMap<>(); + static { + acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance); + acctTradeBotSuppliers.put(BitcoinACCTv3.class, BitcoinACCTv3TradeBot::getInstance); + acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance); + acctTradeBotSuppliers.put(LitecoinACCTv3.class, LitecoinACCTv3TradeBot::getInstance); + acctTradeBotSuppliers.put(DogecoinACCTv1.class, DogecoinACCTv1TradeBot::getInstance); + acctTradeBotSuppliers.put(DogecoinACCTv3.class, DogecoinACCTv3TradeBot::getInstance); + acctTradeBotSuppliers.put(DigibyteACCTv3.class, DigibyteACCTv3TradeBot::getInstance); + acctTradeBotSuppliers.put(RavencoinACCTv3.class, RavencoinACCTv3TradeBot::getInstance); + acctTradeBotSuppliers.put(PirateChainACCTv3.class, PirateChainACCTv3TradeBot::getInstance); + } + + private static RNSTradeBot instance; + + private final Map ourTradePresenceTimestampsByPubkey = Collections.synchronizedMap(new HashMap<>()); + private final List pendingTradePresences = Collections.synchronizedList(new ArrayList<>()); + + private final Map allTradePresencesByPubkey = Collections.synchronizedMap(new HashMap<>()); + private Map safeAllTradePresencesByPubkey = Collections.emptyMap(); + private long nextTradePresenceBroadcastTimestamp = 0L; + + private Map failedTrades = new HashMap<>(); + private Map validTrades = new HashMap<>(); + + private RNSTradeBot() { + EventBus.INSTANCE.addListener(event -> RNSTradeBot.getInstance().listen(event)); + } + + public static synchronized RNSTradeBot getInstance() { + if (instance == null) + instance = new RNSTradeBot(); + + return instance; + } + + public ACCT getAcctUsingAtData(ATData atData) { + byte[] codeHash = atData.getCodeHash(); + if (codeHash == null) + return null; + + return SupportedBlockchain.getAcctByCodeHash(codeHash); + } + + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { + ACCT acct = this.getAcctUsingAtData(atData); + if (acct == null) + return null; + + return acct.populateTradeData(repository, atData); + } + + /** + * Creates a new trade-bot entry from the "Bob" viewpoint, + * i.e. OFFERing QORT in exchange for foreign blockchain currency. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
  • secret(s)
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' public key, public key hash
  • + *
  • hash(es) of secret(s)
  • + *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment': + *
    + *
  • 'native' (Qortal) 'trade' address - used to MESSAGE AT
  • + *
  • 'foreign' public key hash - used by Alice's to allow redeem of currency on foreign blockchain
  • + *
  • hash(es) of secret(s) - used by AT (optional) and foreign blockchain as needed
  • + *
  • QORT amount on offer by Bob
  • + *
  • foreign currency amount expected in return by Bob (from Alice)
  • + *
  • trading timeout, in case things go wrong and everyone needs to refund
  • + *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network. + *

+ * Trade-bot will wait for Bob's AT to be deployed before taking next step. + *

+ * @param repository + * @param tradeBotCreateRequest + * @return raw, unsigned DEPLOY_AT transaction + * @throws DataException + */ + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { + // Fetch latest ACCT version for requested foreign blockchain + ACCT acct = tradeBotCreateRequest.foreignBlockchain.getLatestAcct(); + + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) + return null; + + return acctTradeBot.createTrade(repository, tradeBotCreateRequest); + } + + /** + * Creates a trade-bot entry from the 'Alice' viewpoint, + * i.e. matching foreign blockchain currency to an existing QORT offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a foreign blockchain wallet via foreignKey. + *

+ * @param repository + * @param crossChainTradeData chosen trade OFFER that Alice wants to match + * @param foreignKey foreign blockchain wallet key + * @throws DataException + */ + public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, + CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException { + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for AT %s", atData.getATAddress())); + return ResponseResult.NETWORK_ISSUE; + } + + // Check Alice doesn't already have an existing, on-going trade-bot entry for this AT. + if (repository.getCrossChainRepository().existsTradeWithAtExcludingStates(atData.getATAddress(), acctTradeBot.getEndStates())) + return ResponseResult.TRADE_ALREADY_EXISTS; + + return acctTradeBot.startResponse(repository, atData, acct, crossChainTradeData, foreignKey, receivingAddress); + } + + /** + * Creates a trade-bot entries from the 'Alice' viewpoint, + * i.e. matching foreign blockchain currency to existing QORT offers. + *

+ * Requires chosen trade offers from Bob, passed by crossChainTradeData + * and access to a foreign blockchain wallet via foreignKey. + *

+ * @param repository + * @param crossChainTradeDataList chosen trade OFFERs that Alice wants to match + * @param receiveAddress Alice's Qortal address to receive her QORT + * @param foreignKey foreign blockchain wallet key + * @param bitcoiny + * @throws DataException + */ + public ResponseResult startResponseMultiple( + Repository repository, + ACCT acct, + List crossChainTradeDataList, + String receiveAddress, + String foreignKey, + Bitcoiny bitcoiny) throws DataException { + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for %s", acct.getBlockchain())); + return ResponseResult.NETWORK_ISSUE; + } + + for( CrossChainTradeData tradeData : crossChainTradeDataList) { + // Check Alice doesn't already have an existing, on-going trade-bot entry for this AT. + if (repository.getCrossChainRepository().existsTradeWithAtExcludingStates(tradeData.qortalAtAddress, acctTradeBot.getEndStates())) + return ResponseResult.TRADE_ALREADY_EXISTS; + } + return TradeBotUtils.startResponseMultiple(repository, acct, crossChainTradeDataList, receiveAddress, foreignKey, bitcoiny); + } + + public boolean deleteEntry(Repository repository, byte[] tradePrivateKey) throws DataException { + TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + if (tradeBotData == null) + // Can't delete what we don't have! + return false; + + boolean canDelete = false; + + ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName()); + if (acct == null) + // We can't/no longer support this ACCT + canDelete = true; + else { + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + canDelete = acctTradeBot == null || acctTradeBot.canDelete(repository, tradeBotData); + } + + if (canDelete) { + repository.getCrossChainRepository().delete(tradePrivateKey); + repository.saveChanges(); + } + + return canDelete; + } + + @Override + public void listen(Event event) { + if (!(event instanceof Synchronizer.NewChainTipEvent)) + return; + + // Don't process trade bots or broadcast presence timestamps if our chain is more than 60 minutes old + final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L); + if (!Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) + return; + + synchronized (this) { + expireOldPresenceTimestamps(); + + List allTradeBotData; + + try (final Repository repository = RepositoryManager.getRepository()) { + allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + } catch (DataException e) { + LOGGER.error("Couldn't run trade bot due to repository issue", e); + return; + } + + for (TradeBotData tradeBotData : allTradeBotData) + try (final Repository repository = RepositoryManager.getRepository()) { + // Find ACCT-specific trade-bot for this entry + ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName()); + if (acct == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT matching name %s", tradeBotData.getAcctName())); + continue; + } + + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot matching name %s", tradeBotData.getAcctName())); + continue; + } + + acctTradeBot.progress(repository, tradeBotData); + } catch (DataException e) { + LOGGER.error("Couldn't run trade bot due to repository issue", e); + } catch (ForeignBlockchainException e) { + LOGGER.warn(() -> String.format("Foreign blockchain issue processing trade-bot entry for AT %s: %s", tradeBotData.getAtAddress(), e.getMessage())); + } + + broadcastPresenceTimestamps(); + } + } + + public static byte[] generateTradePrivateKey() { + // The private key is used for both Curve25519 and secp256k1 so needs to be valid for both. + // Curve25519 accepts any seed, so generate a valid secp256k1 key and use that. + return new ECKey().getPrivKeyBytes(); + } + + public static byte[] deriveTradeNativePublicKey(byte[] privateKey) { + return Crypto.toPublicKey(privateKey); + } + + public static byte[] deriveTradeForeignPublicKey(byte[] privateKey) { + return ECKey.fromPrivate(privateKey).getPubKey(); + } + + /*package*/ public static byte[] generateSecret() { + byte[] secret = new byte[32]; + RANDOM.nextBytes(secret); + return secret; + } + + /*package*/ static void backupTradeBotData(Repository repository, List additional) { + // Attempt to backup the trade bot data. This an optional step and doesn't impact trading, so don't throw an exception on failure + try { + LOGGER.info("About to backup trade bot data..."); + HSQLDBImportExport.backupTradeBotStates(repository, additional); + } catch (DataException e) { + LOGGER.info(String.format("Repository issue when exporting trade bot data: %s", e.getMessage())); + } + } + + /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ + /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, + String newState, int newStateValue, Supplier logMessageSupplier) throws DataException { + tradeBotData.setState(newState); + tradeBotData.setStateValue(newStateValue); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + if (Settings.getInstance().isTradebotSystrayEnabled()) + SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState), MessageType.INFO); + + if (logMessageSupplier != null) + LOGGER.info(logMessageSupplier.get()); + + LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState)); + + notifyStateChange(tradeBotData); + } + + /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ + /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, StateNameAndValueSupplier newStateSupplier, Supplier logMessageSupplier) throws DataException { + updateTradeBotState(repository, tradeBotData, newStateSupplier.getState(), newStateSupplier.getStateValue(), logMessageSupplier); + } + + /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ + /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, Supplier logMessageSupplier) throws DataException { + updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), tradeBotData.getStateValue(), logMessageSupplier); + } + + /*package*/ static void notifyStateChange(TradeBotData tradeBotData) { + StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData); + EventBus.INSTANCE.notify(stateChangeEvent); + } + + /*package*/ static AcctTradeBot findTradeBotForAcct(ACCT acct) { + Supplier acctTradeBotSupplier = acctTradeBotSuppliers.get(acct.getClass()); + if (acctTradeBotSupplier == null) + return null; + + return acctTradeBotSupplier.get(); + } + + // PRESENCE-related + + public Collection getAllTradePresences() { + return this.safeAllTradePresencesByPubkey.values(); + } + + /** Trade presence timestamps expire in the 'future' so any that reach 'now' have expired and are removed. */ + private void expireOldPresenceTimestamps() { + long now = NTP.getTime(); + + int allRemovedCount = 0; + synchronized (this.allTradePresencesByPubkey) { + int preRemoveCount = this.allTradePresencesByPubkey.size(); + this.allTradePresencesByPubkey.values().removeIf(tradePresenceData -> tradePresenceData.getTimestamp() <= now); + allRemovedCount = this.allTradePresencesByPubkey.size() - preRemoveCount; + } + + int ourRemovedCount = 0; + synchronized (this.ourTradePresenceTimestampsByPubkey) { + int preRemoveCount = this.ourTradePresenceTimestampsByPubkey.size(); + this.ourTradePresenceTimestampsByPubkey.values().removeIf(timestamp -> timestamp < now); + ourRemovedCount = this.ourTradePresenceTimestampsByPubkey.size() - preRemoveCount; + } + + if (allRemovedCount > 0) + LOGGER.debug("Removed {} expired trade presences, of which {} ours", allRemovedCount, ourRemovedCount); + } + + /*package*/ void updatePresence(Repository repository, TradeBotData tradeBotData, CrossChainTradeData tradeData) + throws DataException { + String atAddress = tradeBotData.getAtAddress(); + + PrivateKeyAccount tradeNativeAccount = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + String signerAddress = tradeNativeAccount.getAddress(); + + /* + * There's no point in Alice trying to broadcast presence for an AT that isn't locked to her, + * as other peers won't be able to verify as signing public key isn't yet in the AT's data segment. + */ + if (!signerAddress.equals(tradeData.qortalCreatorTradeAddress) && !signerAddress.equals(tradeData.qortalPartnerAddress)) { + // Signer is neither Bob, nor trade locked to Alice + LOGGER.trace("Can't provide trade presence for our AT {} as it's not yet locked to Alice", atAddress); + return; + } + + long now = NTP.getTime(); + long newExpiry = generateExpiry(now); + ByteArray pubkeyByteArray = ByteArray.wrap(tradeNativeAccount.getPublicKey()); + + // If map entry's timestamp is missing, or within early renewal period, use the new expiry - otherwise use existing timestamp. + synchronized (this.ourTradePresenceTimestampsByPubkey) { + Long currentTimestamp = this.ourTradePresenceTimestampsByPubkey.get(pubkeyByteArray); + + if (currentTimestamp != null && currentTimestamp - now > EARLY_RENEWAL_PERIOD) { + // timestamp still good + LOGGER.trace("Current trade presence timestamp {} still good for our trade {}", currentTimestamp, atAddress); + return; + } + + this.ourTradePresenceTimestampsByPubkey.put(pubkeyByteArray, newExpiry); + } + + // Create signature + byte[] signature = tradeNativeAccount.sign(Longs.toByteArray(newExpiry)); + + // Add new trade presence to queue to be broadcast around network + TradePresenceData tradePresenceData = new TradePresenceData(newExpiry, tradeNativeAccount.getPublicKey(), signature, atAddress); + this.pendingTradePresences.add(tradePresenceData); + + this.allTradePresencesByPubkey.put(pubkeyByteArray, tradePresenceData); + rebuildSafeAllTradePresences(); + + LOGGER.trace("New trade presence timestamp {} for our trade {}", newExpiry, atAddress); + + EventBus.INSTANCE.notify(new TradePresenceEvent(tradePresenceData)); + } + + private void rebuildSafeAllTradePresences() { + synchronized (this.allTradePresencesByPubkey) { + // Collect into a *new* unmodifiable map. + this.safeAllTradePresencesByPubkey = Map.copyOf(this.allTradePresencesByPubkey); + } + } + + private void broadcastPresenceTimestamps() { + // If we have new trade presences that are pending broadcast, send those as a priority + if (!this.pendingTradePresences.isEmpty()) { + // Create a copy for Network to safely use in another thread + List safeTradePresences; + synchronized (this.pendingTradePresences) { + safeTradePresences = List.copyOf(this.pendingTradePresences); + this.pendingTradePresences.clear(); + } + + LOGGER.debug("Broadcasting {} new trade presences", safeTradePresences.size()); + + TradePresencesMessage tradePresencesMessage = new TradePresencesMessage(safeTradePresences); + RNSNetwork.getInstance().broadcast(peer -> tradePresencesMessage); + + return; + } + + // As we have no new trade presences, check whether it's time to do a general broadcast + Long now = NTP.getTime(); + if (now == null || now < nextTradePresenceBroadcastTimestamp) + return; + + nextTradePresenceBroadcastTimestamp = now + PRESENCE_BROADCAST_INTERVAL; + + List safeTradePresences = List.copyOf(this.safeAllTradePresencesByPubkey.values()); + + LOGGER.debug("Broadcasting all {} known trade presences. Next broadcast timestamp: {}", + safeTradePresences.size(), nextTradePresenceBroadcastTimestamp + ); + + GetTradePresencesMessage getTradePresencesMessage = new GetTradePresencesMessage(safeTradePresences); + RNSNetwork.getInstance().broadcast(peer -> getTradePresencesMessage); + } + + // Network message processing + + public void onGetTradePresencesMessage(RNSPeer peer, Message message) { + GetTradePresencesMessage getTradePresencesMessage = (GetTradePresencesMessage) message; + + List peersTradePresences = getTradePresencesMessage.getTradePresences(); + + // Create mutable copy from safe snapshot + Map entriesUnknownToPeer = new HashMap<>(this.safeAllTradePresencesByPubkey); + int knownCount = entriesUnknownToPeer.size(); + + for (TradePresenceData peersTradePresence : peersTradePresences) { + ByteArray pubkeyByteArray = ByteArray.wrap(peersTradePresence.getPublicKey()); + + TradePresenceData ourEntry = entriesUnknownToPeer.get(pubkeyByteArray); + + if (ourEntry != null && ourEntry.getTimestamp() == peersTradePresence.getTimestamp()) + entriesUnknownToPeer.remove(pubkeyByteArray); + } + + if (entriesUnknownToPeer.isEmpty()) + return; + + LOGGER.debug("Sending {} trade presences to peer {} after excluding their {} from known {}", + entriesUnknownToPeer.size(), peer, peersTradePresences.size(), knownCount + ); + + // Send complement to peer + List safeTradePresences = List.copyOf(entriesUnknownToPeer.values()); + Message responseMessage = new TradePresencesMessage(safeTradePresences); + //if (!peer.sendMessage(responseMessage)) { + // peer.disconnect("failed to send TRADE_PRESENCES response"); + // return; + //} + peer.sendMessage(responseMessage); + } + + public void onTradePresencesMessage(RNSPeer peer, Message message) { + TradePresencesMessage tradePresencesMessage = (TradePresencesMessage) message; + + List peersTradePresences = tradePresencesMessage.getTradePresences(); + + long now = NTP.getTime(); + // Timestamps before this are too far into the past + long pastThreshold = now; + // Timestamps after this are too far into the future + long futureThreshold = now + PRESENCE_LIFETIME; + + Map> acctSuppliersByCodeHash = SupportedBlockchain.getAcctMap(); + + int newCount = 0; + + try (final Repository repository = RepositoryManager.getRepository()) { + for (TradePresenceData peersTradePresence : peersTradePresences) { + long timestamp = peersTradePresence.getTimestamp(); + + // Ignore if timestamp is out of bounds + if (timestamp < pastThreshold || timestamp > futureThreshold) { + if (timestamp < pastThreshold) + LOGGER.trace("Ignoring trade presence {} from peer {} as timestamp {} is too old vs {}", + peersTradePresence.getAtAddress(), peer, timestamp, pastThreshold + ); + else + LOGGER.trace("Ignoring trade presence {} from peer {} as timestamp {} is too new vs {}", + peersTradePresence.getAtAddress(), peer, timestamp, pastThreshold + ); + + continue; + } + + ByteArray pubkeyByteArray = ByteArray.wrap(peersTradePresence.getPublicKey()); + + // Ignore if we've previously verified this timestamp+publickey combo or sent timestamp is older + TradePresenceData existingTradeData = this.safeAllTradePresencesByPubkey.get(pubkeyByteArray); + if (existingTradeData != null && timestamp <= existingTradeData.getTimestamp()) { + if (timestamp == existingTradeData.getTimestamp()) + LOGGER.trace("Ignoring trade presence {} from peer {} as we have verified timestamp {} before", + peersTradePresence.getAtAddress(), peer, timestamp + ); + else + LOGGER.trace("Ignoring trade presence {} from peer {} as timestamp {} is older than latest {}", + peersTradePresence.getAtAddress(), peer, timestamp, existingTradeData.getTimestamp() + ); + + continue; + } + + // Check timestamp signature + byte[] timestampSignature = peersTradePresence.getSignature(); + byte[] timestampBytes = Longs.toByteArray(timestamp); + byte[] publicKey = peersTradePresence.getPublicKey(); + if (!Crypto.verify(publicKey, timestampSignature, timestampBytes)) { + LOGGER.trace("Ignoring trade presence {} from peer {} as signature failed to verify", + peersTradePresence.getAtAddress(), peer + ); + + continue; + } + + ATData atData = repository.getATRepository().fromATAddress(peersTradePresence.getAtAddress()); + if (atData == null || atData.getIsFrozen() || atData.getIsFinished()) { + if (atData == null) + LOGGER.trace("Ignoring trade presence {} from peer {} as AT doesn't exist", + peersTradePresence.getAtAddress(), peer + ); + else + LOGGER.trace("Ignoring trade presence {} from peer {} as AT is frozen or finished", + peersTradePresence.getAtAddress(), peer + ); + + continue; + } + + ByteArray atCodeHash = ByteArray.wrap(atData.getCodeHash()); + Supplier acctSupplier = acctSuppliersByCodeHash.get(atCodeHash); + if (acctSupplier == null) { + LOGGER.trace("Ignoring trade presence {} from peer {} as AT isn't a known ACCT?", + peersTradePresence.getAtAddress(), peer + ); + + continue; + } + + CrossChainTradeData tradeData = acctSupplier.get().populateTradeData(repository, atData); + if (tradeData == null) { + LOGGER.trace("Ignoring trade presence {} from peer {} as trade data not found?", + peersTradePresence.getAtAddress(), peer + ); + + continue; + } + + // Convert signer's public key to address form + String signerAddress = peersTradePresence.getTradeAddress(); + + // Signer's public key (in address form) must match Bob's / Alice's trade public key (in address form) + if (!signerAddress.equals(tradeData.qortalCreatorTradeAddress) && !signerAddress.equals(tradeData.qortalPartnerAddress)) { + LOGGER.trace("Ignoring trade presence {} from peer {} as signer isn't Alice or Bob?", + peersTradePresence.getAtAddress(), peer + ); + + continue; + } + + // This is new to us + this.allTradePresencesByPubkey.put(pubkeyByteArray, peersTradePresence); + ++newCount; + + LOGGER.trace("Added trade presence {} from peer {} with timestamp {}", + peersTradePresence.getAtAddress(), peer, timestamp + ); + + EventBus.INSTANCE.notify(new TradePresenceEvent(peersTradePresence)); + } + } catch (DataException e) { + LOGGER.error("Couldn't process TRADE_PRESENCES message due to repository issue", e); + } + + if (newCount > 0) { + LOGGER.debug("New trade presences: {}, all trade presences: {}", newCount, allTradePresencesByPubkey.size()); + rebuildSafeAllTradePresences(); + } + } + + public void bridgePresence(long timestamp, byte[] publicKey, byte[] signature, String atAddress) { + long expiry = generateExpiry(timestamp); + ByteArray pubkeyByteArray = ByteArray.wrap(publicKey); + + TradePresenceData fakeTradePresenceData = new TradePresenceData(expiry, publicKey, signature, atAddress); + + // Only bridge if trade presence expiry timestamp is newer + TradePresenceData computedTradePresenceData = this.allTradePresencesByPubkey.compute(pubkeyByteArray, (k, v) -> + v == null || v.getTimestamp() < expiry ? fakeTradePresenceData : v + ); + + if (computedTradePresenceData == fakeTradePresenceData) { + LOGGER.trace("Bridged PRESENCE transaction for trade {} with timestamp {}", atAddress, expiry); + rebuildSafeAllTradePresences(); + + EventBus.INSTANCE.notify(new TradePresenceEvent(fakeTradePresenceData)); + } + } + + /** Decorates a CrossChainTradeData object with Alice / Bob trade-bot presence timestamp, if available. */ + public void decorateTradeDataWithPresence(CrossChainTradeData crossChainTradeData) { + // Match by AT address, then check for Bob vs Alice + this.safeAllTradePresencesByPubkey.values().stream() + .filter(tradePresenceData -> tradePresenceData.getAtAddress().equals(crossChainTradeData.qortalAtAddress)) + .forEach(tradePresenceData -> { + String signerAddress = tradePresenceData.getTradeAddress(); + + // Signer's public key (in address form) must match Bob's / Alice's trade public key (in address form) + if (signerAddress.equals(crossChainTradeData.qortalCreatorTradeAddress)) + crossChainTradeData.creatorPresenceExpiry = tradePresenceData.getTimestamp(); + else if (signerAddress.equals(crossChainTradeData.qortalPartnerAddress)) + crossChainTradeData.partnerPresenceExpiry = tradePresenceData.getTimestamp(); + }); + } + + /** 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 transactions = repository.getTransactionRepository().getUnconfirmedTransactions(Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, null, null); + + for (TransactionData transactionData : transactions) { + // Treat as failed if buy attempt was more than 60 mins ago (as it's still in the OFFERING state) + if (transactionData.getRecipient().equals(crossChainTradeData.qortalCreatorTradeAddress) && now - transactionData.getTimestamp() > 60*60*1000L) { + 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); + } + } + + 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/controller/tradebot/RavencoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java index ed71d0e3..a383dfd8 100644 --- a/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/RavencoinACCTv3TradeBot.java @@ -7,6 +7,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.resource.CrossChainUtils; import org.qortal.asset.Asset; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; @@ -30,11 +31,9 @@ import org.qortal.utils.NTP; import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; +import org.qortal.controller.tradebot.TradeStates.State; /** * Performing cross-chain trading steps on behalf of user. @@ -50,45 +49,6 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { private static final Logger LOGGER = LogManager.getLogger(RavencoinACCTv3TradeBot.class); - public enum State implements TradeBot.StateNameAndValueSupplier { - BOB_WAITING_FOR_AT_CONFIRM(10, false, false), - BOB_WAITING_FOR_MESSAGE(15, true, true), - BOB_WAITING_FOR_AT_REDEEM(25, true, true), - BOB_DONE(30, false, false), - BOB_REFUNDED(35, false, false), - - ALICE_WAITING_FOR_AT_LOCK(85, true, true), - ALICE_DONE(95, false, false), - ALICE_REFUNDING_A(105, true, true), - ALICE_REFUNDED(110, false, false); - - private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); - - public final int value; - public final boolean requiresAtData; - public final boolean requiresTradeData; - - State(int value, boolean requiresAtData, boolean requiresTradeData) { - this.value = value; - this.requiresAtData = requiresAtData; - this.requiresTradeData = requiresTradeData; - } - - public static State valueOf(int value) { - return map.get(value); - } - - @Override - public String getState() { - return this.name(); - } - - @Override - public int getStateValue() { - return this.value; - } - } - /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms @@ -313,7 +273,7 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { } // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = RavencoinACCTv3.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -793,7 +753,7 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { case FUNDED: { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = ravencoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = ravencoin.getUnspentOutputs(p2shAddressA, false); Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(ravencoin.getNetworkParameters(), redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); @@ -857,7 +817,7 @@ public class RavencoinACCTv3TradeBot implements AcctTradeBot { case FUNDED:{ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = ravencoin.getUnspentOutputs(p2shAddressA); + List fundingOutputs = ravencoin.getUnspentOutputs(p2shAddressA, false); // Determine receive address for refund String receiveAddress = ravencoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 3699bd2a..654513f2 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -215,6 +215,41 @@ public class TradeBot implements Listener { return acctTradeBot.startResponse(repository, atData, acct, crossChainTradeData, foreignKey, receivingAddress); } + /** + * Creates a trade-bot entries from the 'Alice' viewpoint, + * i.e. matching foreign blockchain currency to existing QORT offers. + *

+ * Requires chosen trade offers from Bob, passed by crossChainTradeData + * and access to a foreign blockchain wallet via foreignKey. + *

+ * @param repository + * @param crossChainTradeDataList chosen trade OFFERs that Alice wants to match + * @param receiveAddress Alice's Qortal address to receive her QORT + * @param foreignKey foreign blockchain wallet key + * @param bitcoiny + * @throws DataException + */ + public ResponseResult startResponseMultiple( + Repository repository, + ACCT acct, + List crossChainTradeDataList, + String receiveAddress, + String foreignKey, + Bitcoiny bitcoiny) throws DataException { + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for %s", acct.getBlockchain())); + return ResponseResult.NETWORK_ISSUE; + } + + for( CrossChainTradeData tradeData : crossChainTradeDataList) { + // Check Alice doesn't already have an existing, on-going trade-bot entry for this AT. + if (repository.getCrossChainRepository().existsTradeWithAtExcludingStates(tradeData.qortalAtAddress, acctTradeBot.getEndStates())) + return ResponseResult.TRADE_ALREADY_EXISTS; + } + return TradeBotUtils.startResponseMultiple(repository, acct, crossChainTradeDataList, receiveAddress, foreignKey, bitcoiny); + } + public boolean deleteEntry(Repository repository, byte[] tradePrivateKey) throws DataException { TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); if (tradeBotData == null) diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBotUtils.java b/src/main/java/org/qortal/controller/tradebot/TradeBotUtils.java new file mode 100644 index 00000000..67a262fc --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/TradeBotUtils.java @@ -0,0 +1,217 @@ +package org.qortal.controller.tradebot; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Transaction; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.api.resource.CrossChainUtils; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crypto.Crypto; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +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.MessageTransaction; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; +import org.qortal.transaction.Transaction.ValidationResult; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.qortal.controller.tradebot.TradeStates.State; + +public class TradeBotUtils { + + private static final Logger LOGGER = LogManager.getLogger(TradeBotUtils.class); + /** + * Creates trade-bot entries from the 'Alice' viewpoint, i.e. matching Bitcoiny coin to existing offers. + *

+ * Requires chosen trade offers from Bob, passed by crossChainTradeData + * and access to a Blockchain wallet via foreignKey. + *

+ * The crossChainTradeData contains the current trade offers state + * as extracted from the AT's data segment. + *

+ * Access to a funded wallet is via a Blockchain BIP32 hierarchical deterministic key, + * passed via foreignKey. + * This key will be stored in your node's database + * to allow trade-bot to create/fund the necessary P2SH transactions! + * However, due to the nature of BIP32 keys, it is possible to give the trade-bot + * only a subset of wallet access (see BIP32 for more details). + *

+ * As an example, the foreignKey can be extract from a legacy, password-less + * Electrum wallet by going to the console tab and entering:
+ * wallet.keystore.xprv
+ * which should result in a base58 string starting with either 'xprv' (for Blockchain main-net) + * or 'tprv' for (Blockchain test-net). + *

+ * It is envisaged that the value in foreignKey will actually come from a Qortal-UI-managed wallet. + *

+ * If sufficient funds are available, this method will actually fund the P2SH-A + * with the Blockchain amount expected by 'Bob'. + *

+ * If the Blockchain transaction is successfully broadcast to the network then + * we also send a MESSAGE to Bob's trade-bot to let them know; one message for each trade. + *

+ * The trade-bot entries are saved to the repository and the cross-chain trading process commences. + *

+ * + * @param repository for backing up the trade bot data + * @param crossChainTradeDataList chosen trade OFFERs that Alice wants to match + * @param receiveAddress Alice's Qortal address + * @param foreignKey funded wallet xprv in base58 + * @param bitcoiny the bitcoiny chain to match the sell offer with + * @return true if P2SH-A funding transaction successfully broadcast to Blockchain network, false otherwise + * @throws DataException + */ + public static AcctTradeBot.ResponseResult startResponseMultiple( + Repository repository, + ACCT acct, + List crossChainTradeDataList, + String receiveAddress, + String foreignKey, + Bitcoiny bitcoiny) throws DataException { + + // Check we have enough funds via foreignKey to fund P2SH to cover expectedForeignAmount + long now = NTP.getTime(); + long p2shFee; + try { + p2shFee = bitcoiny.getP2shFee(now); + } catch (ForeignBlockchainException e) { + LOGGER.debug("Couldn't estimate blockchain transaction fees?"); + return AcctTradeBot.ResponseResult.NETWORK_ISSUE; + } + + Map valueByP2shAddress = new HashMap<>(crossChainTradeDataList.size()); + + class DataCombiner{ + CrossChainTradeData crossChainTradeData; + TradeBotData tradeBotData; + String p2shAddress; + + public DataCombiner(CrossChainTradeData crossChainTradeData, TradeBotData tradeBotData, String p2shAddress) { + this.crossChainTradeData = crossChainTradeData; + this.tradeBotData = tradeBotData; + this.p2shAddress = p2shAddress; + } + } + + List dataToProcess = new ArrayList<>(); + + for(CrossChainTradeData crossChainTradeData : crossChainTradeDataList) { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + byte[] secretA = TradeBot.generateSecret(); + byte[] hashOfSecretA = Crypto.hash160(secretA); + + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + // We need to generate lockTime-A: add tradeTimeout to now + int lockTimeA = (crossChainTradeData.tradeTimeout * 60) + (int) (now / 1000L); + byte[] receivingPublicKeyHash = Base58.decode(receiveAddress); // Actually the whole address, not just PKH + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, acct.getClass().getSimpleName(), + State.ALICE_WAITING_FOR_AT_LOCK.name(), State.ALICE_WAITING_FOR_AT_LOCK.value, + receiveAddress, + crossChainTradeData.qortalAtAddress, + now, + crossChainTradeData.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretA, hashOfSecretA, + crossChainTradeData.foreignBlockchain, + tradeForeignPublicKey, tradeForeignPublicKeyHash, + crossChainTradeData.expectedForeignAmount, + foreignKey, null, lockTimeA, receivingPublicKeyHash); + + // Attempt to backup the trade bot data + // Include tradeBotData as an additional parameter, since it's not in the repository yet + TradeBot.backupTradeBotData(repository, Arrays.asList(tradeBotData)); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + // Do not include fee for funding transaction as this is covered by buildSpend() + long amountA = crossChainTradeData.expectedForeignAmount + p2shFee /*redeeming/refunding P2SH-A*/; + + // P2SH-A to be funded + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA); + String p2shAddress = bitcoiny.deriveP2shAddress(redeemScriptBytes); + + valueByP2shAddress.put(p2shAddress, amountA); + + dataToProcess.add(new DataCombiner(crossChainTradeData, tradeBotData, p2shAddress)); + } + + // Build transaction for funding P2SH-A + Transaction p2shFundingTransaction = bitcoiny.buildSpendMultiple(foreignKey, valueByP2shAddress, null); + if (p2shFundingTransaction == null) { + LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); + return AcctTradeBot.ResponseResult.BALANCE_ISSUE; + } + + try { + bitcoiny.broadcastTransaction(p2shFundingTransaction); + } catch (ForeignBlockchainException e) { + // We couldn't fund P2SH-A at this time + LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?"); + return AcctTradeBot.ResponseResult.NETWORK_ISSUE; + } + + for(DataCombiner datumToProcess : dataToProcess ) { + // Attempt to send MESSAGE to Bob's Qortal trade address + TradeBotData tradeBotData = datumToProcess.tradeBotData; + + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + CrossChainTradeData crossChainTradeData = datumToProcess.crossChainTradeData; + String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); + if (!isMessageAlreadySent) { + // 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); + + 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(); + + 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", datumToProcess.p2shAddress)); + } + + return AcctTradeBot.ResponseResult.OK; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/controller/tradebot/TradeStates.java b/src/main/java/org/qortal/controller/tradebot/TradeStates.java new file mode 100644 index 00000000..a1dbb081 --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/TradeStates.java @@ -0,0 +1,47 @@ +package org.qortal.controller.tradebot; + +import java.util.Map; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +public class TradeStates { + public enum State implements TradeBot.StateNameAndValueSupplier { + BOB_WAITING_FOR_AT_CONFIRM(10, false, false), + BOB_WAITING_FOR_MESSAGE(15, true, true), + BOB_WAITING_FOR_AT_REDEEM(25, true, true), + BOB_DONE(30, false, false), + BOB_REFUNDED(35, false, false), + + ALICE_WAITING_FOR_AT_LOCK(85, true, true), + ALICE_DONE(95, false, false), + ALICE_REFUNDING_A(105, true, true), + ALICE_REFUNDED(110, false, false); + + private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); + + public final int value; + public final boolean requiresAtData; + public final boolean requiresTradeData; + + State(int value, boolean requiresAtData, boolean requiresTradeData) { + this.value = value; + this.requiresAtData = requiresAtData; + this.requiresTradeData = requiresTradeData; + } + + public static State valueOf(int value) { + return map.get(value); + } + + @Override + public String getState() { + return this.name(); + } + + @Override + public int getStateValue() { + return this.value; + } + } +} diff --git a/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java index 9de95c17..cb855466 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java +++ b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java @@ -802,12 +802,6 @@ public class BitcoinACCTv1 implements ACCT { return tradeData; } - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); - } - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) diff --git a/src/main/java/org/qortal/crosschain/BitcoinACCTv3.java b/src/main/java/org/qortal/crosschain/BitcoinACCTv3.java index ad5984c1..ecf768ed 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinACCTv3.java +++ b/src/main/java/org/qortal/crosschain/BitcoinACCTv3.java @@ -751,12 +751,6 @@ public class BitcoinACCTv3 implements ACCT { return tradeData; } - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); - } - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index 7f624e20..d93fa65f 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -55,6 +55,13 @@ public abstract class Bitcoiny implements ForeignBlockchain { protected Coin feePerKb; + /** + * Blockchain Cache + * + * To store blockchain data and reduce redundant RPCs to the ElectrumX servers + */ + private final BlockchainCache blockchainCache = new BlockchainCache(); + // Constructors and instance protected Bitcoiny(BitcoinyBlockchainProvider blockchainProvider, Context bitcoinjContext, String currencyCode, Coin feePerKb) { @@ -76,6 +83,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { return this.bitcoinjContext; } + @Override public String getCurrencyCode() { return this.currencyCode; } @@ -208,8 +216,8 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @throws ForeignBlockchainException if there was an error. */ // TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead - public List getUnspentOutputs(String base58Address) throws ForeignBlockchainException { - List unspentOutputs = this.blockchainProvider.getUnspentOutputs(addressToScriptPubKey(base58Address), false); + public List getUnspentOutputs(String base58Address, boolean includeUnconfirmed) throws ForeignBlockchainException { + List unspentOutputs = this.blockchainProvider.getUnspentOutputs(addressToScriptPubKey(base58Address), includeUnconfirmed); List unspentTransactionOutputs = new ArrayList<>(); for (UnspentOutput unspentOutput : unspentOutputs) { @@ -343,6 +351,45 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } + /** + * Returns bitcoinj transaction sending the recipient's amount to each recipient given. + * + * + * @param xprv58 the private master key + * @param amountByRecipient each amount to send indexed by the recipient to send to + * @param feePerByte the satoshis per byte + * + * @return the completed transaction, ready to broadcast + */ + public Transaction buildSpendMultiple(String xprv58, Map amountByRecipient, Long feePerByte) { + Context.propagate(bitcoinjContext); + + Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); + + Transaction transaction = new Transaction(this.params); + + for(Map.Entry amountForRecipient : amountByRecipient.entrySet()) { + Address destination = Address.fromString(this.params, amountForRecipient.getKey()); + transaction.addOutput(Coin.valueOf(amountForRecipient.getValue()), destination); + } + + SendRequest sendRequest = SendRequest.forTx(transaction); + + if (feePerByte != null) + sendRequest.feePerKb = Coin.valueOf(feePerByte * 1000L); // Note: 1000 not 1024 + else + // Allow override of default for TestNet3, etc. + sendRequest.feePerKb = this.getFeePerKb(); + + try { + wallet.completeTx(sendRequest); + return sendRequest.tx; + } catch (InsufficientMoneyException e) { + return null; + } + } + /** * Get Spending Candidate Addresses * @@ -391,7 +438,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { List allUnspentOutputs = new ArrayList<>(); Set walletAddresses = this.getWalletAddresses(key58); for (String address : walletAddresses) { - allUnspentOutputs.addAll(this.getUnspentOutputs(address)); + allUnspentOutputs.addAll(this.getUnspentOutputs(address, true)); } for (TransactionOutput output : allUnspentOutputs) { if (!output.isAvailableForSpending()) { @@ -465,13 +512,27 @@ public abstract class Bitcoiny implements ForeignBlockchain { 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); + List historicTransactionHashes = this.getAddressTransactions(script, true); if (!historicTransactionHashes.isEmpty()) { areAllKeysUnused = false; - for (TransactionHash transactionHash : historicTransactionHashes) - walletTransactions.add(this.getTransaction(transactionHash.txHash)); + for (TransactionHash transactionHash : historicTransactionHashes) { + + Optional walletTransaction + = this.blockchainCache.getTransactionByHash( transactionHash.txHash ); + + // if the wallet transaction is already cached + if(walletTransaction.isPresent() ) { + walletTransactions.add( walletTransaction.get() ); + } + // otherwise get the transaction from the blockchain server + else { + BitcoinyTransaction transaction = getTransaction(transactionHash.txHash); + walletTransactions.add( transaction ); + this.blockchainCache.addTransactionByHash(transactionHash.txHash, transaction); + } + } } } @@ -563,17 +624,25 @@ public abstract class Bitcoiny implements ForeignBlockchain { 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()) { + // if the key already has a verified transaction history + if( this.blockchainCache.keyHasHistory( dKey ) ){ areAllKeysUnused = false; } + else { + // Check for transactions + 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, true); + + if (!historicTransactionHashes.isEmpty()) { + areAllKeysUnused = false; + this.blockchainCache.addKeyWithHistory(dKey); + } + } } if (areAllKeysUnused) { @@ -628,19 +697,26 @@ public abstract class Bitcoiny implements ForeignBlockchain { do { boolean areAllKeysUnused = true; - for (; ki < keys.size(); ++ki) { + for (; areAllKeysUnused && ki < keys.size(); ++ki) { DeterministicKey dKey = keys.get(ki); - // Check for transactions - Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); - 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()) { + // if the key already has a verified transaction history + if( this.blockchainCache.keyHasHistory(dKey)) { areAllKeysUnused = false; } + else { + // Check for transactions + Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); + 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, true); + + if (!historicTransactionHashes.isEmpty()) { + areAllKeysUnused = false; + this.blockchainCache.addKeyWithHistory(dKey); + } + } } if (areAllKeysUnused) { @@ -803,7 +879,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { List unspentOutputs; try { - unspentOutputs = this.bitcoiny.blockchainProvider.getUnspentOutputs(script, false); + unspentOutputs = this.bitcoiny.blockchainProvider.getUnspentOutputs(script, true); } catch (ForeignBlockchainException e) { throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address)); } @@ -893,7 +969,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { } private Long summingUnspentOutputs(String walletAddress) throws ForeignBlockchainException { - return this.getUnspentOutputs(walletAddress).stream() + return this.getUnspentOutputs(walletAddress, true).stream() .map(TransactionOutput::getValue) .mapToLong(Coin::longValue) .sum(); diff --git a/src/main/java/org/qortal/crosschain/BitcoinyTBD.java b/src/main/java/org/qortal/crosschain/BitcoinyTBD.java new file mode 100644 index 00000000..c25d2094 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinyTBD.java @@ -0,0 +1,151 @@ +package org.qortal.crosschain; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Context; +import org.bitcoinj.core.NetworkParameters; +import org.qortal.api.model.crosschain.BitcoinyTBDRequest; +import org.qortal.crosschain.ChainableServer.ConnectionType; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class BitcoinyTBD extends Bitcoiny { + + private static HashMap requestsById = new HashMap<>(); + + private long minimumOrderAmount; + + private static Map instanceByCode = new HashMap<>(); + + private final NetTBD netTBD; + + /** + * Default ElectrumX Ports + * + * These are the defualts for all Bitcoin forks. + */ + private static final Map DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ConnectionType.class); + static { + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001); + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002); + } + + /** + * Constructor + * + * @param netTBD network access to the blockchain provider + * @param blockchain blockchain provider + * @param bitcoinjContext + * @param currencyCode the trading symbol, ie LTC + * @param minimumOrderAmount web search, LTC minimumOrderAmount = 1000000, 0.01 LTC minimum order to avoid dust errors + * @param feePerKb web search, LTC feePerKb = 10000, 0.0001 LTC per 1000 bytes + */ + private BitcoinyTBD( + NetTBD netTBD, + BitcoinyBlockchainProvider blockchain, + Context bitcoinjContext, + String currencyCode, + long minimumOrderAmount, + long feePerKb) { + + super(blockchain, bitcoinjContext, currencyCode, Coin.valueOf( feePerKb)); + + this.netTBD = netTBD; + this.minimumOrderAmount = minimumOrderAmount; + + LOGGER.info(() -> String.format("Starting BitcoinyTBD support using %s", this.netTBD.getName())); + } + + /** + * Get Instance + * + * @param currencyCode the trading symbol, ie LTC + * + * @return the instance + */ + public static synchronized Optional getInstance(String currencyCode) { + + return Optional.ofNullable(instanceByCode.get(currencyCode)); + } + + /** + * Build Instance + * + * @param bitcoinyTBDRequest + * @param networkParams + * @return the instance + */ + public static synchronized BitcoinyTBD buildInstance( + BitcoinyTBDRequest bitcoinyTBDRequest, + NetworkParameters networkParams + ) { + + NetTBD netTBD + = new NetTBD( + bitcoinyTBDRequest.getNetworkName(), + bitcoinyTBDRequest.getFeeCeiling(), + networkParams, + Collections.emptyList(), + bitcoinyTBDRequest.getExpectedGenesisHash() + ); + + BitcoinyBlockchainProvider electrumX = new ElectrumX(netTBD.getName(), netTBD.getGenesisHash(), netTBD.getServers(), DEFAULT_ELECTRUMX_PORTS); + Context bitcoinjContext = new Context(netTBD.getParams()); + + BitcoinyTBD instance + = new BitcoinyTBD( + netTBD, + electrumX, + bitcoinjContext, + bitcoinyTBDRequest.getCurrencyCode(), + bitcoinyTBDRequest.getMinimumOrderAmount(), + bitcoinyTBDRequest.getFeePerKb()); + electrumX.setBlockchain(instance); + + instanceByCode.put(bitcoinyTBDRequest.getCurrencyCode(), instance); + requestsById.put(bitcoinyTBDRequest.getId(), bitcoinyTBDRequest); + + return instance; + } + + public static List getRequests() { + + Collection requests = requestsById.values(); + + List list = new ArrayList<>( requests.size() ); + + list.addAll( requests ); + + return list; + } + + @Override + public long getMinimumOrderAmount() { + + return minimumOrderAmount; + } + + @Override + public long getP2shFee(Long timestamp) throws ForeignBlockchainException { + + return this.netTBD.getFeeCeiling(); + } + + @Override + public long getFeeCeiling() { + + return this.netTBD.getFeeCeiling(); + } + + @Override + public void setFeeCeiling(long fee) { + + this.netTBD.setFeeCeiling( fee ); + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/BitcoinyUTXOProvider.java b/src/main/java/org/qortal/crosschain/BitcoinyUTXOProvider.java index df596de4..2fcd7cee 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinyUTXOProvider.java +++ b/src/main/java/org/qortal/crosschain/BitcoinyUTXOProvider.java @@ -30,7 +30,7 @@ public class BitcoinyUTXOProvider implements UTXOProvider { byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); // collection UTXO's for all confirmed unspent outputs - for (UnspentOutput output : this.bitcoiny.blockchainProvider.getUnspentOutputs(script, false)) { + for (UnspentOutput output : this.bitcoiny.blockchainProvider.getUnspentOutputs(script, true)) { utxos.add(toUTXO(output)); } } diff --git a/src/main/java/org/qortal/crosschain/BlockchainCache.java b/src/main/java/org/qortal/crosschain/BlockchainCache.java new file mode 100644 index 00000000..f6a1acf6 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BlockchainCache.java @@ -0,0 +1,89 @@ +package org.qortal.crosschain; + +import org.bitcoinj.crypto.DeterministicKey; +import org.qortal.settings.Settings; + +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; + +/** + * Class BlockchainCache + * + * Cache blockchain information to reduce redundant RPCs to the ElectrumX servers. + */ +public class BlockchainCache { + + /** + * Keys With History + * + * Deterministic Keys with any transaction history. + */ + private Queue keysWithHistory = new ConcurrentLinkedDeque<>(); + + /** + * Transactions By Hash + * + * Transaction Hash -> Transaction + */ + private ConcurrentHashMap transactionByHash = new ConcurrentHashMap<>(); + + /** + * Cache Limit + * + * If this limit is reached, the cache will be cleared or reduced. + */ + private static final int CACHE_LIMIT = Settings.getInstance().getBlockchainCacheLimit(); + + /** + * Add Key With History + * + * @param key a deterministic key with a verified history + */ + public void addKeyWithHistory(DeterministicKey key) { + + if( this.keysWithHistory.size() > CACHE_LIMIT ) { + this.keysWithHistory.remove(); + } + + this.keysWithHistory.add(key); + } + + /** + * Key Has History? + * + * @param key the deterministic key + * + * @return true if the key has a history, otherwise false + */ + public boolean keyHasHistory( DeterministicKey key ) { + return this.keysWithHistory.contains(key); + } + + /** + * Add Transaction By Hash + * + * @param hash the transaction hash + * @param transaction the transaction + */ + public void addTransactionByHash( String hash, BitcoinyTransaction transaction ) { + + if( this.transactionByHash.size() > CACHE_LIMIT ) { + this.transactionByHash.clear(); + } + + this.transactionByHash.put(hash, transaction); + } + + /** + * Get Transaction By Hash + * + * @param hash the transaction hash + * + * @return the transaction, empty if the hash is not in the cache + */ + public Optional getTransactionByHash( String hash ) { + return Optional.ofNullable( this.transactionByHash.get(hash) ); + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/DeterminedNetworkParams.java b/src/main/java/org/qortal/crosschain/DeterminedNetworkParams.java new file mode 100644 index 00000000..af7d19ac --- /dev/null +++ b/src/main/java/org/qortal/crosschain/DeterminedNetworkParams.java @@ -0,0 +1,387 @@ +package org.qortal.crosschain; + +import org.apache.logging.log4j.LogManager; +import org.bitcoinj.core.*; +import org.bitcoinj.store.BlockStore; +import org.bitcoinj.store.BlockStoreException; +import org.bitcoinj.utils.MonetaryFormat; +import org.bouncycastle.util.encoders.Hex; +import org.libdohj.core.AltcoinNetworkParameters; +import org.libdohj.core.AltcoinSerializer; +import org.qortal.api.model.crosschain.BitcoinyTBDRequest; +import org.qortal.repository.DataException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.math.BigInteger; + +import static org.bitcoinj.core.Coin.COIN; + +/** + * Common parameters Bitcoin fork networks. + */ +public class DeterminedNetworkParams extends NetworkParameters implements AltcoinNetworkParameters { + + private static final org.apache.logging.log4j.Logger LOGGER = LogManager.getLogger(DeterminedNetworkParams.class); + + public static final long MAX_TARGET_COMPACT_BITS = 0x1e0fffffL; + /** + * Standard format for the LITE denomination. + */ + private MonetaryFormat fullUnit; + + /** + * Standard format for the mLITE denomination. + * */ + private MonetaryFormat mUnit; + + /** + * Base Unit + * + * The equivalent for Satoshi for Bitcoin + */ + private MonetaryFormat baseUnit; + + /** + * The maximum money to be generated + */ + public final Coin maxMoney; + + /** + * Currency code for full unit. + * */ + private String code = "LITE"; + + /** + * Currency code for milli Unit. + * */ + private String mCode = "mLITE"; + + /** + * Currency code for base unit. + * */ + private String baseCode = "Liteoshi"; + + + private int protocolVersionMinimum; + private int protocolVersionCurrent; + + private static final Coin BASE_SUBSIDY = COIN.multiply(50); + + protected Logger log = LoggerFactory.getLogger(DeterminedNetworkParams.class); + + private int minNonDustOutput; + + private String uriScheme; + + private boolean hasMaxMoney; + + public DeterminedNetworkParams( BitcoinyTBDRequest request ) throws DataException { + super(); + + if( request.getTargetTimespan() > 0 && request.getTargetSpacing() > 0 ) + this.interval = request.getTargetTimespan() / request.getTargetSpacing(); + + this.targetTimespan = request.getTargetTimespan(); + + // this compact value is used for every Bitcoin fork for no documented reason + this.maxTarget = Utils.decodeCompactBits(MAX_TARGET_COMPACT_BITS); + + this.packetMagic = request.getPacketMagic(); + + this.id = request.getId(); + this.port = request.getPort(); + this.addressHeader = request.getAddressHeader(); + this.p2shHeader = request.getP2shHeader(); + this.segwitAddressHrp = request.getSegwitAddressHrp(); + + this.dumpedPrivateKeyHeader = request.getDumpedPrivateKeyHeader(); + + LOGGER.info( "Creating Genesis Block ..."); + + //this.genesisBlock = CoinParamsUtil.createGenesisBlockFromRequest(this, request); + + LOGGER.info("Created Genesis Block: genesisBlock = " + genesisBlock ); + + // this is 100 for each coin from what I can tell + this.spendableCoinbaseDepth = 100; + + this.subsidyDecreaseBlockCount = request.getSubsidyDecreaseBlockCount(); + +// String genesisHash = genesisBlock.getHashAsString(); +// +// LOGGER.info("genesisHash = " + genesisHash); +// +// LOGGER.info("request = " + request); +// +// checkState(genesisHash.equals(request.getExpectedGenesisHash())); + this.alertSigningKey = Hex.decode(request.getPubKey()); + + this.majorityEnforceBlockUpgrade = request.getMajorityEnforceBlockUpgrade(); + this.majorityRejectBlockOutdated = request.getMajorityRejectBlockOutdated(); + this.majorityWindow = request.getMajorityWindow(); + + this.dnsSeeds = request.getDnsSeeds(); + + this.bip32HeaderP2PKHpub = request.getBip32HeaderP2PKHpub(); + this.bip32HeaderP2PKHpriv = request.getBip32HeaderP2PKHpriv(); + + this.code = request.getCode(); + this.mCode = request.getmCode(); + this.baseCode = request.getBaseCode(); + + this.fullUnit = MonetaryFormat.BTC.noCode() + .code(0, this.code) + .code(3, this.mCode) + .code(7, this.baseCode); + this.mUnit = fullUnit.shift(3).minDecimals(2).optionalDecimals(2); + this.baseUnit = fullUnit.shift(7).minDecimals(0).optionalDecimals(2); + + this.protocolVersionMinimum = request.getProtocolVersionMinimum(); + this.protocolVersionCurrent = request.getProtocolVersionCurrent(); + + this.minNonDustOutput = request.getMinNonDustOutput(); + + this.uriScheme = request.getUriScheme(); + + this.hasMaxMoney = request.isHasMaxMoney(); + + this.maxMoney = COIN.multiply(request.getMaxMoney()); + } + + @Override + public Coin getBlockSubsidy(final int height) { + // return BASE_SUBSIDY.shiftRight(height / getSubsidyDecreaseBlockCount()); + // return something concerning Digishield for Dogecoin + // return something different for Digibyte validation.cpp::GetBlockSubsidy + // we may not need to support this + throw new UnsupportedOperationException(); + } + + /** + * Get the hash to use for a block. + */ + @Override + public Sha256Hash getBlockDifficultyHash(Block block) { + + return ((AltcoinBlock) block).getScryptHash(); + } + + @Override + public boolean isTestNet() { + return false; + } + + public MonetaryFormat getMonetaryFormat() { + + return this.fullUnit; + } + + @Override + public Coin getMaxMoney() { + + return this.maxMoney; + } + + @Override + public Coin getMinNonDustOutput() { + + return Coin.valueOf(this.minNonDustOutput); + } + + @Override + public String getUriScheme() { + + return this.uriScheme; + } + + @Override + public boolean hasMaxMoney() { + + return this.hasMaxMoney; + } + + + @Override + public String getPaymentProtocolId() { + return this.id; + } + + @Override + public void checkDifficultyTransitions(StoredBlock storedPrev, Block nextBlock, BlockStore blockStore) + throws VerificationException, BlockStoreException { + try { + final long newTargetCompact = calculateNewDifficultyTarget(storedPrev, nextBlock, blockStore); + final long receivedTargetCompact = nextBlock.getDifficultyTarget(); + + if (newTargetCompact != receivedTargetCompact) + throw new VerificationException("Network provided difficulty bits do not match what was calculated: " + + newTargetCompact + " vs " + receivedTargetCompact); + } catch (CheckpointEncounteredException ex) { + // Just have to take it on trust then + } + } + + /** + * Get the difficulty target expected for the next block. This includes all + * the weird cases for Litecoin such as testnet blocks which can be maximum + * difficulty if the block interval is high enough. + * + * @throws CheckpointEncounteredException if a checkpoint is encountered while + * calculating difficulty target, and therefore no conclusive answer can + * be provided. + */ + public long calculateNewDifficultyTarget(StoredBlock storedPrev, Block nextBlock, BlockStore blockStore) + throws VerificationException, BlockStoreException, CheckpointEncounteredException { + final Block prev = storedPrev.getHeader(); + final int previousHeight = storedPrev.getHeight(); + final int retargetInterval = this.getInterval(); + + // Is this supposed to be a difficulty transition point? + if ((storedPrev.getHeight() + 1) % retargetInterval != 0) { + if (this.allowMinDifficultyBlocks()) { + // Special difficulty rule for testnet: + // If the new block's timestamp is more than 5 minutes + // then allow mining of a min-difficulty block. + if (nextBlock.getTimeSeconds() > prev.getTimeSeconds() + getTargetSpacing() * 2) { + return Utils.encodeCompactBits(maxTarget); + } else { + // Return the last non-special-min-difficulty-rules-block + StoredBlock cursor = storedPrev; + + while (cursor.getHeight() % retargetInterval != 0 + && cursor.getHeader().getDifficultyTarget() == Utils.encodeCompactBits(this.getMaxTarget())) { + StoredBlock prevCursor = cursor.getPrev(blockStore); + if (prevCursor == null) { + break; + } + cursor = prevCursor; + } + + return cursor.getHeader().getDifficultyTarget(); + } + } + + // No ... so check the difficulty didn't actually change. + return prev.getDifficultyTarget(); + } + + // We need to find a block far back in the chain. It's OK that this is expensive because it only occurs every + // two weeks after the initial block chain download. + StoredBlock cursor = storedPrev; + int goBack = retargetInterval - 1; + + // Litecoin: This fixes an issue where a 51% attack can change difficulty at will. + // Go back the full period unless it's the first retarget after genesis. + // Code based on original by Art Forz + if (cursor.getHeight()+1 != retargetInterval) + goBack = retargetInterval; + + for (int i = 0; i < goBack; i++) { + if (cursor == null) { + // This should never happen. If it does, it means we are following an incorrect or busted chain. + throw new VerificationException( + "Difficulty transition point but we did not find a way back to the genesis block."); + } + cursor = blockStore.get(cursor.getHeader().getPrevBlockHash()); + } + + //We used checkpoints... + if (cursor == null) { + log.debug("Difficulty transition: Hit checkpoint!"); + throw new CheckpointEncounteredException(); + } + + Block blockIntervalAgo = cursor.getHeader(); + return this.calculateNewDifficultyTargetInner(previousHeight, prev.getTimeSeconds(), + prev.getDifficultyTarget(), blockIntervalAgo.getTimeSeconds(), + nextBlock.getDifficultyTarget()); + } + + /** + * Calculate the difficulty target expected for the next block after a normal + * recalculation interval. Does not handle special cases such as testnet blocks + * being setting the target to maximum for blocks after a long interval. + * + * @param previousHeight height of the block immediately before the retarget. + * @param prev the block immediately before the retarget block. + * @param nextBlock the block the retarget happens at. + * @param blockIntervalAgo The last retarget block. + * @return New difficulty target as compact bytes. + */ + protected long calculateNewDifficultyTargetInner(int previousHeight, final Block prev, + final Block nextBlock, final Block blockIntervalAgo) { + return this.calculateNewDifficultyTargetInner(previousHeight, prev.getTimeSeconds(), + prev.getDifficultyTarget(), blockIntervalAgo.getTimeSeconds(), + nextBlock.getDifficultyTarget()); + } + + /** + * + * @param previousHeight Height of the block immediately previous to the one we're calculating difficulty of. + * @param previousBlockTime Time of the block immediately previous to the one we're calculating difficulty of. + * @param lastDifficultyTarget Compact difficulty target of the last retarget block. + * @param lastRetargetTime Time of the last difficulty retarget. + * @param nextDifficultyTarget The expected difficulty target of the next + * block, used for determining precision of the result. + * @return New difficulty target as compact bytes. + */ + protected long calculateNewDifficultyTargetInner(int previousHeight, long previousBlockTime, + final long lastDifficultyTarget, final long lastRetargetTime, + final long nextDifficultyTarget) { + final int retargetTimespan = this.getTargetTimespan(); + int actualTime = (int) (previousBlockTime - lastRetargetTime); + final int minTimespan = retargetTimespan / 4; + final int maxTimespan = retargetTimespan * 4; + + actualTime = Math.min(maxTimespan, Math.max(minTimespan, actualTime)); + + BigInteger newTarget = Utils.decodeCompactBits(lastDifficultyTarget); + newTarget = newTarget.multiply(BigInteger.valueOf(actualTime)); + newTarget = newTarget.divide(BigInteger.valueOf(retargetTimespan)); + + if (newTarget.compareTo(this.getMaxTarget()) > 0) { + log.info("Difficulty hit proof of work limit: {}", newTarget.toString(16)); + newTarget = this.getMaxTarget(); + } + + int accuracyBytes = (int) (nextDifficultyTarget >>> 24) - 3; + + // The calculated difficulty is to a higher precision than received, so reduce here. + BigInteger mask = BigInteger.valueOf(0xFFFFFFL).shiftLeft(accuracyBytes * 8); + newTarget = newTarget.and(mask); + return Utils.encodeCompactBits(newTarget); + } + + @Override + public AltcoinSerializer getSerializer(boolean parseRetain) { + return new AltcoinSerializer(this, parseRetain); + } + + @Override + public int getProtocolVersionNum(final ProtocolVersion version) { + switch (version) { + case PONG: + case BLOOM_FILTER: + return version.getBitcoinProtocolVersion(); + case CURRENT: + return protocolVersionCurrent; + case MINIMUM: + default: + return protocolVersionMinimum; + } + } + + /** + * Whether this network has special rules to enable minimum difficulty blocks + * after a long interval between two blocks (i.e. testnet). + */ + public boolean allowMinDifficultyBlocks() { + return this.isTestNet(); + } + + public int getTargetSpacing() { + return this.getTargetTimespan() / this.getInterval(); + } + + private static class CheckpointEncounteredException extends Exception { } +} diff --git a/src/main/java/org/qortal/crosschain/DigibyteACCTv3.java b/src/main/java/org/qortal/crosschain/DigibyteACCTv3.java index e1e33862..9fa67592 100644 --- a/src/main/java/org/qortal/crosschain/DigibyteACCTv3.java +++ b/src/main/java/org/qortal/crosschain/DigibyteACCTv3.java @@ -751,12 +751,6 @@ public class DigibyteACCTv3 implements ACCT { return tradeData; } - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); - } - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) diff --git a/src/main/java/org/qortal/crosschain/DogecoinACCTv1.java b/src/main/java/org/qortal/crosschain/DogecoinACCTv1.java index 36ff7c5c..a5ec6f1f 100644 --- a/src/main/java/org/qortal/crosschain/DogecoinACCTv1.java +++ b/src/main/java/org/qortal/crosschain/DogecoinACCTv1.java @@ -748,12 +748,6 @@ public class DogecoinACCTv1 implements ACCT { return tradeData; } - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); - } - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) diff --git a/src/main/java/org/qortal/crosschain/DogecoinACCTv3.java b/src/main/java/org/qortal/crosschain/DogecoinACCTv3.java index 002a4448..06b04705 100644 --- a/src/main/java/org/qortal/crosschain/DogecoinACCTv3.java +++ b/src/main/java/org/qortal/crosschain/DogecoinACCTv3.java @@ -751,12 +751,6 @@ public class DogecoinACCTv3 implements ACCT { return tradeData; } - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); - } - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index 0e70f787..6c917659 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -46,7 +46,7 @@ public class ElectrumX extends BitcoinyBlockchainProvider { private static final int RESPONSE_TIME_READINGS = 5; private static final long MAX_AVG_RESPONSE_TIME = 2000L; // ms - public static final String MINIMUM_VERSION_ERROR = "MINIMUM VERSION ERROR"; + public static final String MISSING_FEATURES_ERROR = "MISSING FEATURES ERROR"; public static final String EXPECTED_GENESIS_ERROR = "EXPECTED GENESIS ERROR"; private ChainableServerConnectionRecorder recorder = new ChainableServerConnectionRecorder(100); @@ -721,8 +721,19 @@ public class ElectrumX extends BitcoinyBlockchainProvider { // Check connection is suitable by asking for server features, including genesis block hash JSONObject featuresJson = (JSONObject) this.connectedRpc("server.features"); - if (featuresJson == null || Double.parseDouble((String) featuresJson.get("protocol_min")) < MIN_PROTOCOL_VERSION) - return Optional.of( recorder.recordConnection(server, requestedBy, true, false, MINIMUM_VERSION_ERROR) ); + if (featuresJson == null ) + return Optional.of( recorder.recordConnection(server, requestedBy, true, false, MISSING_FEATURES_ERROR) ); + + try { + double protocol_min = CrossChainUtils.getVersionDecimal(featuresJson, "protocol_min"); + + if (protocol_min < MIN_PROTOCOL_VERSION) + return Optional.of( recorder.recordConnection(server, requestedBy, true, false, "old version: protocol_min = " + protocol_min + " < MIN_PROTOCOL_VERSION = " + MIN_PROTOCOL_VERSION) ); + } catch (NumberFormatException e) { + return Optional.of( recorder.recordConnection(server, requestedBy,true, false,featuresJson.get("protocol_min").toString() + " is not a valid version")); + } catch (NullPointerException e) { + return Optional.of( recorder.recordConnection(server, requestedBy,true, false,"server version not available: protocol_min")); + } if (this.expectedGenesisHash != null && !((String) featuresJson.get("genesis_hash")).equals(this.expectedGenesisHash)) return Optional.of( recorder.recordConnection(server, requestedBy, true, false, EXPECTED_GENESIS_ERROR) ); diff --git a/src/main/java/org/qortal/crosschain/ForeignBlockchain.java b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java index fe64ab83..c66f2719 100644 --- a/src/main/java/org/qortal/crosschain/ForeignBlockchain.java +++ b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java @@ -2,6 +2,8 @@ package org.qortal.crosschain; public interface ForeignBlockchain { + public String getCurrencyCode(); + public boolean isValidAddress(String address); public boolean isValidWalletKey(String walletKey); diff --git a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java index ea91501e..6a828981 100644 --- a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java +++ b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java @@ -741,12 +741,6 @@ public class LitecoinACCTv1 implements ACCT { return tradeData; } - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); - } - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) diff --git a/src/main/java/org/qortal/crosschain/LitecoinACCTv3.java b/src/main/java/org/qortal/crosschain/LitecoinACCTv3.java index a321a7dc..4a533b4b 100644 --- a/src/main/java/org/qortal/crosschain/LitecoinACCTv3.java +++ b/src/main/java/org/qortal/crosschain/LitecoinACCTv3.java @@ -744,12 +744,6 @@ public class LitecoinACCTv3 implements ACCT { return tradeData; } - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); - } - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) diff --git a/src/main/java/org/qortal/crosschain/NetTBD.java b/src/main/java/org/qortal/crosschain/NetTBD.java new file mode 100644 index 00000000..c52449b4 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/NetTBD.java @@ -0,0 +1,52 @@ +package org.qortal.crosschain; + +import org.bitcoinj.core.NetworkParameters; + +import java.util.Collection; + +public class NetTBD { + + private String name; + private long feeCeiling; + private NetworkParameters params; + private Collection servers; + private String genesisHash; + + public NetTBD(String name, long feeCeiling, NetworkParameters params, Collection servers, String genesisHash) { + this.name = name; + this.feeCeiling = feeCeiling; + this.params = params; + this.servers = servers; + this.genesisHash = genesisHash; + } + + public String getName() { + + return this.name; + } + + public long getFeeCeiling() { + + return feeCeiling; + } + + public void setFeeCeiling(long feeCeiling) { + + this.feeCeiling = feeCeiling; + } + + public NetworkParameters getParams() { + + return this.params; + } + + public Collection getServers() { + + return this.servers; + } + + public String getGenesisHash() { + + return this.genesisHash; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/PirateChainACCTv3.java b/src/main/java/org/qortal/crosschain/PirateChainACCTv3.java index f5addafe..8873eeab 100644 --- a/src/main/java/org/qortal/crosschain/PirateChainACCTv3.java +++ b/src/main/java/org/qortal/crosschain/PirateChainACCTv3.java @@ -768,12 +768,6 @@ public class PirateChainACCTv3 implements ACCT { return tradeData; } - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPublicKey, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPublicKey, hashOfSecretA, lockTimeABytes); - } - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) diff --git a/src/main/java/org/qortal/crosschain/RavencoinACCTv3.java b/src/main/java/org/qortal/crosschain/RavencoinACCTv3.java index 866e2d6b..f027e9ca 100644 --- a/src/main/java/org/qortal/crosschain/RavencoinACCTv3.java +++ b/src/main/java/org/qortal/crosschain/RavencoinACCTv3.java @@ -751,12 +751,6 @@ public class RavencoinACCTv3 implements ACCT { return tradeData; } - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); - } - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) diff --git a/src/main/java/org/qortal/data/account/AddressAmountData.java b/src/main/java/org/qortal/data/account/AddressAmountData.java new file mode 100644 index 00000000..d5a9f52b --- /dev/null +++ b/src/main/java/org/qortal/data/account/AddressAmountData.java @@ -0,0 +1,54 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import java.util.Objects; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class AddressAmountData { + + private String address; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long amount; + + public AddressAmountData() { + } + + public AddressAmountData(String address, long amount) { + + this.address = address; + this.amount = amount; + } + + public String getAddress() { + return address; + } + + public long getAmount() { + return amount; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AddressAmountData that = (AddressAmountData) o; + return amount == that.amount && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(address, amount); + } + + @Override + public String toString() { + return "AddressAmountData{" + + "address='" + address + '\'' + + ", amount=" + amount + + '}'; + } +} diff --git a/src/main/java/org/qortal/data/account/AddressLevelPairing.java b/src/main/java/org/qortal/data/account/AddressLevelPairing.java new file mode 100644 index 00000000..3b6fcf3f --- /dev/null +++ b/src/main/java/org/qortal/data/account/AddressLevelPairing.java @@ -0,0 +1,44 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.Arrays; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class AddressLevelPairing { + + private String address; + + private int level; + + // Constructors + + // For JAXB + protected AddressLevelPairing() { + } + + public AddressLevelPairing(String address, int level) { + this.address = address; + this.level = level; + } + + // Getters / setters + + + public String getAddress() { + return address; + } + + public int getLevel() { + return level; + } + + @Override + public String toString() { + return "AddressLevelPairing{" + + "address='" + address + '\'' + + ", level=" + level + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/data/account/BlockHeightRange.java b/src/main/java/org/qortal/data/account/BlockHeightRange.java new file mode 100644 index 00000000..0ddb60bf --- /dev/null +++ b/src/main/java/org/qortal/data/account/BlockHeightRange.java @@ -0,0 +1,59 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.Objects; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class BlockHeightRange { + + private int begin; + + private int end; + + private boolean isRewardDistribution; + + public BlockHeightRange() { + } + + public BlockHeightRange(int begin, int end, boolean isRewardDistribution) { + this.begin = begin; + this.end = end; + this.isRewardDistribution = isRewardDistribution; + } + + public int getBegin() { + return begin; + } + + public int getEnd() { + return end; + } + + public boolean isRewardDistribution() { + return isRewardDistribution; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BlockHeightRange that = (BlockHeightRange) o; + return begin == that.begin && end == that.end; + } + + @Override + public int hashCode() { + return Objects.hash(begin, end); + } + + @Override + public String toString() { + return "BlockHeightRange{" + + "begin=" + begin + + ", end=" + end + + ", isRewardDistribution=" + isRewardDistribution + + '}'; + } +} diff --git a/src/main/java/org/qortal/data/account/BlockHeightRangeAddressAmounts.java b/src/main/java/org/qortal/data/account/BlockHeightRangeAddressAmounts.java new file mode 100644 index 00000000..3e2b7042 --- /dev/null +++ b/src/main/java/org/qortal/data/account/BlockHeightRangeAddressAmounts.java @@ -0,0 +1,52 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.List; +import java.util.Objects; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class BlockHeightRangeAddressAmounts { + + private BlockHeightRange range; + + private List amounts; + + public BlockHeightRangeAddressAmounts() { + } + + public BlockHeightRangeAddressAmounts(BlockHeightRange range, List amounts) { + this.range = range; + this.amounts = amounts; + } + + public BlockHeightRange getRange() { + return range; + } + + public List getAmounts() { + return amounts; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BlockHeightRangeAddressAmounts that = (BlockHeightRangeAddressAmounts) o; + return Objects.equals(range, that.range) && Objects.equals(amounts, that.amounts); + } + + @Override + public int hashCode() { + return Objects.hash(range, amounts); + } + + @Override + public String toString() { + return "BlockHeightRangeAddressAmounts{" + + "range=" + range + + ", amounts=" + amounts + + '}'; + } +} diff --git a/src/main/java/org/qortal/data/account/MintershipReport.java b/src/main/java/org/qortal/data/account/MintershipReport.java new file mode 100644 index 00000000..e36a981b --- /dev/null +++ b/src/main/java/org/qortal/data/account/MintershipReport.java @@ -0,0 +1,156 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.Arrays; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class MintershipReport { + + private String address; + + private int level; + + private int blocksMinted; + + private int adjustments; + + private int penalties; + + private boolean transfer; + + private String name; + + private int sponseeCount; + + private int balance; + + private int arbitraryCount; + + private int transferAssetCount; + + private int transferPrivsCount; + + private int sellCount; + + private int sellAmount; + + private int buyCount; + + private int buyAmount; + + // Constructors + + // For JAXB + protected MintershipReport() { + } + + public MintershipReport(String address, int level, int blocksMinted, int adjustments, int penalties, boolean transfer, String name, int sponseeCount, int balance, int arbitraryCount, int transferAssetCount, int transferPrivsCount, int sellCount, int sellAmount, int buyCount, int buyAmount) { + this.address = address; + this.level = level; + this.blocksMinted = blocksMinted; + this.adjustments = adjustments; + this.penalties = penalties; + this.transfer = transfer; + this.name = name; + this.sponseeCount = sponseeCount; + this.balance = balance; + this.arbitraryCount = arbitraryCount; + this.transferAssetCount = transferAssetCount; + this.transferPrivsCount = transferPrivsCount; + this.sellCount = sellCount; + this.sellAmount = sellAmount; + this.buyCount = buyCount; + this.buyAmount = buyAmount; + } + + // Getters / setters + + + public String getAddress() { + return address; + } + + public int getLevel() { + return level; + } + + public int getBlocksMinted() { + return blocksMinted; + } + + public int getAdjustments() { + return adjustments; + } + + public int getPenalties() { + return penalties; + } + + public boolean isTransfer() { + return transfer; + } + + public String getName() { + return name; + } + + public int getSponseeCount() { + return sponseeCount; + } + + public int getBalance() { + return balance; + } + + public int getArbitraryCount() { + return arbitraryCount; + } + + public int getTransferAssetCount() { + return transferAssetCount; + } + + public int getTransferPrivsCount() { + return transferPrivsCount; + } + + public int getSellCount() { + return sellCount; + } + + public int getSellAmount() { + return sellAmount; + } + + public int getBuyCount() { + return buyCount; + } + + public int getBuyAmount() { + return buyAmount; + } + + @Override + public String toString() { + return "MintershipReport{" + + "address='" + address + '\'' + + ", level=" + level + + ", blocksMinted=" + blocksMinted + + ", adjustments=" + adjustments + + ", penalties=" + penalties + + ", transfer=" + transfer + + ", name='" + name + '\'' + + ", sponseeCount=" + sponseeCount + + ", balance=" + balance + + ", arbitraryCount=" + arbitraryCount + + ", transferAssetCount=" + transferAssetCount + + ", transferPrivsCount=" + transferPrivsCount + + ", sellCount=" + sellCount + + ", sellAmount=" + sellAmount + + ", buyCount=" + buyCount + + ", buyAmount=" + buyAmount + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/data/account/SponsorshipReport.java b/src/main/java/org/qortal/data/account/SponsorshipReport.java new file mode 100644 index 00000000..7b518363 --- /dev/null +++ b/src/main/java/org/qortal/data/account/SponsorshipReport.java @@ -0,0 +1,164 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.Arrays; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class SponsorshipReport { + + private String address; + + private int level; + + private int blocksMinted; + + private int adjustments; + + private int penalties; + + private boolean transfer; + + private String[] names; + + private int sponseeCount; + + private int nonRegisteredCount; + + private int avgBalance; + + private int arbitraryCount; + + private int transferAssetCount; + + private int transferPrivsCount; + + private int sellCount; + + private int sellAmount; + + private int buyCount; + + private int buyAmount; + + // Constructors + + // For JAXB + protected SponsorshipReport() { + } + + public SponsorshipReport(String address, int level, int blocksMinted, int adjustments, int penalties, boolean transfer, String[] names, int sponseeCount, int nonRegisteredCount, int avgBalance, int arbitraryCount, int transferAssetCount, int transferPrivsCount, int sellCount, int sellAmount, int buyCount, int buyAmount) { + this.address = address; + this.level = level; + this.blocksMinted = blocksMinted; + this.adjustments = adjustments; + this.penalties = penalties; + this.transfer = transfer; + this.names = names; + this.sponseeCount = sponseeCount; + this.nonRegisteredCount = nonRegisteredCount; + this.avgBalance = avgBalance; + this.arbitraryCount = arbitraryCount; + this.transferAssetCount = transferAssetCount; + this.transferPrivsCount = transferPrivsCount; + this.sellCount = sellCount; + this.sellAmount = sellAmount; + this.buyCount = buyCount; + this.buyAmount = buyAmount; + } + + // Getters / setters + + + public String getAddress() { + return address; + } + + public int getLevel() { + return level; + } + + public int getBlocksMinted() { + return blocksMinted; + } + + public int getAdjustments() { + return adjustments; + } + + public int getPenalties() { + return penalties; + } + + public boolean isTransfer() { + return transfer; + } + + public String[] getNames() { + return names; + } + + public int getSponseeCount() { + return sponseeCount; + } + + public int getNonRegisteredCount() { + return nonRegisteredCount; + } + + public int getAvgBalance() { + return avgBalance; + } + + public int getArbitraryCount() { + return arbitraryCount; + } + + public int getTransferAssetCount() { + return transferAssetCount; + } + + public int getTransferPrivsCount() { + return transferPrivsCount; + } + + public int getSellCount() { + return sellCount; + } + + public int getSellAmount() { + return sellAmount; + } + + public int getBuyCount() { + return buyCount; + } + + public int getBuyAmount() { + return buyAmount; + } + + @Override + public String toString() { + return "MintershipReport{" + + "address='" + address + '\'' + + ", level=" + level + + ", blocksMinted=" + blocksMinted + + ", adjustments=" + adjustments + + ", penalties=" + penalties + + ", transfer=" + transfer + + ", names=" + Arrays.toString(names) + + ", sponseeCount=" + sponseeCount + + ", nonRegisteredCount=" + nonRegisteredCount + + ", avgBalance=" + avgBalance + + ", arbitraryCount=" + arbitraryCount + + ", transferAssetCount=" + transferAssetCount + + ", transferPrivsCount=" + transferPrivsCount + + ", sellCount=" + sellCount + + ", sellAmount=" + sellAmount + + ", buyCount=" + buyCount + + ", buyAmount=" + buyAmount + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndex.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndex.java new file mode 100644 index 00000000..9b6bf415 --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndex.java @@ -0,0 +1,34 @@ +package org.qortal.data.arbitrary; + +import org.qortal.arbitrary.misc.Service; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class ArbitraryDataIndex { + + public String t; + public String n; + public int c; + public String l; + + public ArbitraryDataIndex() {} + + public ArbitraryDataIndex(String t, String n, int c, String l) { + this.t = t; + this.n = n; + this.c = c; + this.l = l; + } + + @Override + public String toString() { + return "ArbitraryDataIndex{" + + "t='" + t + '\'' + + ", n='" + n + '\'' + + ", c=" + c + + ", l='" + l + '\'' + + '}'; + } +} diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndexDetail.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndexDetail.java new file mode 100644 index 00000000..d073c736 --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndexDetail.java @@ -0,0 +1,41 @@ +package org.qortal.data.arbitrary; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class ArbitraryDataIndexDetail { + + public String issuer; + public int rank; + public String term; + public String name; + public int category; + public String link; + public String indexIdentifer; + + public ArbitraryDataIndexDetail() {} + + public ArbitraryDataIndexDetail(String issuer, int rank, ArbitraryDataIndex index, String indexIdentifer) { + this.issuer = issuer; + this.rank = rank; + this.term = index.t; + this.name = index.n; + this.category = index.c; + this.link = index.l; + this.indexIdentifer = indexIdentifer; + } + + @Override + public String toString() { + return "ArbitraryDataIndexDetail{" + + "issuer='" + issuer + '\'' + + ", rank=" + rank + + ", term='" + term + '\'' + + ", name='" + name + '\'' + + ", category=" + category + + ", link='" + link + '\'' + + ", indexIdentifer='" + indexIdentifer + '\'' + + '}'; + } +} diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndexScoreKey.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndexScoreKey.java new file mode 100644 index 00000000..46a661e5 --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndexScoreKey.java @@ -0,0 +1,38 @@ +package org.qortal.data.arbitrary; + +import org.qortal.arbitrary.misc.Service; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.Objects; + +@XmlAccessorType(XmlAccessType.FIELD) +public class ArbitraryDataIndexScoreKey { + + public String name; + public int category; + public String link; + + public ArbitraryDataIndexScoreKey() {} + + public ArbitraryDataIndexScoreKey(String name, int category, String link) { + this.name = name; + this.category = category; + this.link = link; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ArbitraryDataIndexScoreKey that = (ArbitraryDataIndexScoreKey) o; + return category == that.category && Objects.equals(name, that.name) && Objects.equals(link, that.link); + } + + @Override + public int hashCode() { + return Objects.hash(name, category, link); + } + + +} diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndexScorecard.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndexScorecard.java new file mode 100644 index 00000000..1888a4a5 --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryDataIndexScorecard.java @@ -0,0 +1,38 @@ +package org.qortal.data.arbitrary; + +import org.qortal.arbitrary.misc.Service; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class ArbitraryDataIndexScorecard { + + public double score; + public String name; + public int category; + public String link; + + public ArbitraryDataIndexScorecard() {} + + public ArbitraryDataIndexScorecard(double score, String name, int category, String link) { + this.score = score; + this.name = name; + this.category = category; + this.link = link; + } + + public double getScore() { + return score; + } + + @Override + public String toString() { + return "ArbitraryDataIndexScorecard{" + + "score=" + score + + ", name='" + name + '\'' + + ", category=" + category + + ", link='" + link + '\'' + + '}'; + } +} diff --git a/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceCache.java b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceCache.java new file mode 100644 index 00000000..f838cdd7 --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/ArbitraryResourceCache.java @@ -0,0 +1,26 @@ +package org.qortal.data.arbitrary; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class ArbitraryResourceCache { + private ConcurrentHashMap> dataByService = new ConcurrentHashMap<>(); + private ConcurrentHashMap levelByName = new ConcurrentHashMap<>(); + + private ArbitraryResourceCache() {} + + private static ArbitraryResourceCache SINGLETON = new ArbitraryResourceCache(); + + public static ArbitraryResourceCache getInstance(){ + return SINGLETON; + } + + public ConcurrentHashMap getLevelByName() { + return levelByName; + } + + public ConcurrentHashMap> getDataByService() { + return this.dataByService; + } +} diff --git a/src/main/java/org/qortal/data/arbitrary/DataMonitorInfo.java b/src/main/java/org/qortal/data/arbitrary/DataMonitorInfo.java new file mode 100644 index 00000000..5ee76c29 --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/DataMonitorInfo.java @@ -0,0 +1,57 @@ +package org.qortal.data.arbitrary; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class DataMonitorInfo { + private long timestamp; + private String identifier; + private String name; + private String service; + private String description; + private long transactionTimestamp; + private long latestPutTimestamp; + + public DataMonitorInfo() { + } + + public DataMonitorInfo(long timestamp, String identifier, String name, String service, String description, long transactionTimestamp, long latestPutTimestamp) { + + this.timestamp = timestamp; + this.identifier = identifier; + this.name = name; + this.service = service; + this.description = description; + this.transactionTimestamp = transactionTimestamp; + this.latestPutTimestamp = latestPutTimestamp; + } + + public long getTimestamp() { + return timestamp; + } + + public String getIdentifier() { + return identifier; + } + + public String getName() { + return name; + } + + public String getService() { + return service; + } + + public String getDescription() { + return description; + } + + public long getTransactionTimestamp() { + return transactionTimestamp; + } + + public long getLatestPutTimestamp() { + return latestPutTimestamp; + } +} diff --git a/src/main/java/org/qortal/data/arbitrary/IndexCache.java b/src/main/java/org/qortal/data/arbitrary/IndexCache.java new file mode 100644 index 00000000..dd5c12ab --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/IndexCache.java @@ -0,0 +1,23 @@ +package org.qortal.data.arbitrary; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +public class IndexCache { + + public static final IndexCache SINGLETON = new IndexCache(); + private ConcurrentHashMap> indicesByTerm = new ConcurrentHashMap<>(); + private ConcurrentHashMap> indicesByIssuer = new ConcurrentHashMap<>(); + + public static IndexCache getInstance() { + return SINGLETON; + } + + public ConcurrentHashMap> getIndicesByTerm() { + return indicesByTerm; + } + + public ConcurrentHashMap> getIndicesByIssuer() { + return indicesByIssuer; + } +} diff --git a/src/main/java/org/qortal/data/arbitrary/RNSArbitraryDirectConnectionInfo.java b/src/main/java/org/qortal/data/arbitrary/RNSArbitraryDirectConnectionInfo.java new file mode 100644 index 00000000..586bb7f4 --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/RNSArbitraryDirectConnectionInfo.java @@ -0,0 +1,59 @@ +package org.qortal.data.arbitrary; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +public class RNSArbitraryDirectConnectionInfo { + + private final byte[] signature; + private final String peerAddress; + private final List hashes; + private final long timestamp; + + public RNSArbitraryDirectConnectionInfo(byte[] signature, String peerAddress, List hashes, long timestamp) { + this.signature = signature; + this.peerAddress = peerAddress; + this.hashes = hashes; + this.timestamp = timestamp; + } + + public byte[] getSignature() { + return this.signature; + } + + public String getPeerAddress() { + return this.peerAddress; + } + + public List getHashes() { + return this.hashes; + } + + public long getTimestamp() { + return this.timestamp; + } + + public int getHashCount() { + if (this.hashes == null) { + return 0; + } + return this.hashes.size(); + } + + @Override + public boolean equals(Object other) { + if (other == this) + return true; + + if (!(other instanceof ArbitraryDirectConnectionInfo)) + return false; + + ArbitraryDirectConnectionInfo otherDirectConnectionInfo = (ArbitraryDirectConnectionInfo) other; + + return Arrays.equals(this.signature, otherDirectConnectionInfo.getSignature()) + && Objects.equals(this.peerAddress, otherDirectConnectionInfo.getPeerAddress()) + && Objects.equals(this.hashes, otherDirectConnectionInfo.getHashes()) + && Objects.equals(this.timestamp, otherDirectConnectionInfo.getTimestamp()); + } +} diff --git a/src/main/java/org/qortal/data/arbitrary/RNSArbitraryFileListResponseInfo.java b/src/main/java/org/qortal/data/arbitrary/RNSArbitraryFileListResponseInfo.java new file mode 100644 index 00000000..50620e1f --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/RNSArbitraryFileListResponseInfo.java @@ -0,0 +1,11 @@ +package org.qortal.data.arbitrary; + +import org.qortal.network.RNSPeer; + +public class RNSArbitraryFileListResponseInfo extends RNSArbitraryRelayInfo { + + public RNSArbitraryFileListResponseInfo(String hash58, String signature58, RNSPeer peer, Long timestamp, Long requestTime, Integer requestHops) { + super(hash58, signature58, peer, timestamp, requestTime, requestHops); + } + +} diff --git a/src/main/java/org/qortal/data/arbitrary/RNSArbitraryRelayInfo.java b/src/main/java/org/qortal/data/arbitrary/RNSArbitraryRelayInfo.java new file mode 100644 index 00000000..cc8a0f90 --- /dev/null +++ b/src/main/java/org/qortal/data/arbitrary/RNSArbitraryRelayInfo.java @@ -0,0 +1,73 @@ +package org.qortal.data.arbitrary; + +import org.qortal.network.RNSPeer; + +import java.util.Objects; + +public class RNSArbitraryRelayInfo { + + private final String hash58; + private final String signature58; + private final RNSPeer peer; + private final Long timestamp; + private final Long requestTime; + private final Integer requestHops; + + public RNSArbitraryRelayInfo(String hash58, String signature58, RNSPeer peer, Long timestamp, Long requestTime, Integer requestHops) { + this.hash58 = hash58; + this.signature58 = signature58; + this.peer = peer; + this.timestamp = timestamp; + this.requestTime = requestTime; + this.requestHops = requestHops; + } + + public boolean isValid() { + return this.getHash58() != null && this.getSignature58() != null + && this.getPeer() != null && this.getTimestamp() != null; + } + + public String getHash58() { + return this.hash58; + } + + public String getSignature58() { + return signature58; + } + + public RNSPeer getPeer() { + return peer; + } + + public Long getTimestamp() { + return timestamp; + } + + public Long getRequestTime() { + return this.requestTime; + } + + public Integer getRequestHops() { + return this.requestHops; + } + + @Override + public String toString() { + return String.format("%s = %s, %s, %d", this.hash58, this.signature58, this.peer, this.timestamp); + } + + @Override + public boolean equals(Object other) { + if (other == this) + return true; + + if (!(other instanceof ArbitraryRelayInfo)) + return false; + + RNSArbitraryRelayInfo otherRelayInfo = (RNSArbitraryRelayInfo) other; + + return this.peer == otherRelayInfo.getPeer() + && Objects.equals(this.hash58, otherRelayInfo.getHash58()) + && Objects.equals(this.signature58, otherRelayInfo.getSignature58()); + } +} diff --git a/src/main/java/org/qortal/data/block/BlockData.java b/src/main/java/org/qortal/data/block/BlockData.java index 34df0f9a..7e2a1872 100644 --- a/src/main/java/org/qortal/data/block/BlockData.java +++ b/src/main/java/org/qortal/data/block/BlockData.java @@ -1,8 +1,11 @@ package org.qortal.data.block; import com.google.common.primitives.Bytes; +import org.qortal.account.Account; import org.qortal.block.BlockChain; -import org.qortal.crypto.Crypto; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import org.qortal.utils.NTP; @@ -224,7 +227,7 @@ public class BlockData implements Serializable { } return 0; } - + public boolean isTrimmed() { long onlineAccountSignaturesTrimmedTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); long currentTrimmableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); @@ -232,11 +235,31 @@ public class BlockData implements Serializable { return blockTimestamp < onlineAccountSignaturesTrimmedTimestamp && blockTimestamp < currentTrimmableTimestamp; } + public String getMinterAddressFromPublicKey() { + try (final Repository repository = RepositoryManager.getRepository()) { + return Account.getRewardShareMintingAddress(repository, this.minterPublicKey); + } catch (DataException e) { + return "Unknown"; + } + } + + public int getMinterLevelFromPublicKey() { + try (final Repository repository = RepositoryManager.getRepository()) { + return Account.getRewardShareEffectiveMintingLevel(repository, this.minterPublicKey); + } catch (DataException e) { + return 0; + } + } + // JAXB special @XmlElement(name = "minterAddress") protected String getMinterAddress() { - return Crypto.toAddress(this.minterPublicKey); + return getMinterAddressFromPublicKey(); } + @XmlElement(name = "minterLevel") + protected int getMinterLevel() { + return getMinterLevelFromPublicKey(); + } } diff --git a/src/main/java/org/qortal/data/block/DecodedOnlineAccountData.java b/src/main/java/org/qortal/data/block/DecodedOnlineAccountData.java new file mode 100644 index 00000000..a2ecc1ca --- /dev/null +++ b/src/main/java/org/qortal/data/block/DecodedOnlineAccountData.java @@ -0,0 +1,85 @@ +package org.qortal.data.block; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.Objects; + +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) +public class DecodedOnlineAccountData { + + private long onlineTimestamp; + private String minter; + private String recipient; + private int sharePercent; + private boolean minterGroupMember; + private String name; + private int level; + + public DecodedOnlineAccountData() { + } + + public DecodedOnlineAccountData(long onlineTimestamp, String minter, String recipient, int sharePercent, boolean minterGroupMember, String name, int level) { + this.onlineTimestamp = onlineTimestamp; + this.minter = minter; + this.recipient = recipient; + this.sharePercent = sharePercent; + this.minterGroupMember = minterGroupMember; + this.name = name; + this.level = level; + } + + public long getOnlineTimestamp() { + return onlineTimestamp; + } + + public String getMinter() { + return minter; + } + + public String getRecipient() { + return recipient; + } + + public int getSharePercent() { + return sharePercent; + } + + public boolean isMinterGroupMember() { + return minterGroupMember; + } + + public String getName() { + return name; + } + + public int getLevel() { + return level; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DecodedOnlineAccountData that = (DecodedOnlineAccountData) o; + return onlineTimestamp == that.onlineTimestamp && sharePercent == that.sharePercent && minterGroupMember == that.minterGroupMember && level == that.level && Objects.equals(minter, that.minter) && Objects.equals(recipient, that.recipient) && Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(onlineTimestamp, minter, recipient, sharePercent, minterGroupMember, name, level); + } + + @Override + public String toString() { + return "DecodedOnlineAccountData{" + + "onlineTimestamp=" + onlineTimestamp + + ", minter='" + minter + '\'' + + ", recipient='" + recipient + '\'' + + ", sharePercent=" + sharePercent + + ", minterGroupMember=" + minterGroupMember + + ", name='" + name + '\'' + + ", level=" + level + + '}'; + } +} diff --git a/src/main/java/org/qortal/data/network/RNSPeerData.java b/src/main/java/org/qortal/data/network/RNSPeerData.java new file mode 100644 index 00000000..a552e214 --- /dev/null +++ b/src/main/java/org/qortal/data/network/RNSPeerData.java @@ -0,0 +1,117 @@ +package org.qortal.data.network; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.qortal.network.PeerAddress; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlTransient; +import static org.apache.commons.codec.binary.Hex.encodeHexString; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class RNSPeerData { + + //public static final int MAX_PEER_ADDRESS_SIZE = 255; + + // Properties + + //// Don't expose this via JAXB - use pretty getter instead + //@XmlTransient + //@Schema(hidden = true) + //private PeerAddress peerAddress; + private byte[] peerAddress; + + private Long lastAttempted; + private Long lastConnected; + private Long lastMisbehaved; + 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 + protected RNSPeerData() { + } + + public RNSPeerData(byte[] peerAddress, Long lastAttempted, Long lastConnected, Long lastMisbehaved, Long addedWhen, String addedBy) { + this.peerAddress = peerAddress; + this.lastAttempted = lastAttempted; + this.lastConnected = lastConnected; + this.lastMisbehaved = lastMisbehaved; + this.addedWhen = addedWhen; + this.addedBy = addedBy; + } + + public RNSPeerData(byte[] peerAddress, Long addedWhen, String addedBy) { + this(peerAddress, null, null, null, addedWhen, addedBy); + } + + public RNSPeerData(byte[] peerAddress) { + this(peerAddress, null, null, null, null, null); + } + + // Getters / setters + + // Don't let JAXB use this getter + @XmlTransient + @Schema(hidden = true) + public byte[] getAddress() { + return this.peerAddress; + } + + public Long getLastAttempted() { + return this.lastAttempted; + } + + public void setLastAttempted(Long lastAttempted) { + this.lastAttempted = lastAttempted; + } + + public Long getLastConnected() { + return this.lastConnected; + } + + public void setLastConnected(Long lastConnected) { + this.lastConnected = lastConnected; + } + + public Long getLastMisbehaved() { + return this.lastMisbehaved; + } + + public void setLastMisbehaved(Long lastMisbehaved) { + this.lastMisbehaved = lastMisbehaved; + } + + public Long getAddedWhen() { + return this.addedWhen; + } + + public String getAddedBy() { + 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() { + return encodeHexString(this.peerAddress); + } + +} diff --git a/src/main/java/org/qortal/data/system/DbConnectionInfo.java b/src/main/java/org/qortal/data/system/DbConnectionInfo.java new file mode 100644 index 00000000..0e42dc20 --- /dev/null +++ b/src/main/java/org/qortal/data/system/DbConnectionInfo.java @@ -0,0 +1,35 @@ +package org.qortal.data.system; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class DbConnectionInfo { + + private long updated; + + private String owner; + + private String state; + + public DbConnectionInfo() { + } + + public DbConnectionInfo(long timeOpened, String owner, String state) { + this.updated = timeOpened; + this.owner = owner; + this.state = state; + } + + public long getUpdated() { + return updated; + } + + public String getOwner() { + return owner; + } + + public String getState() { + return state; + } +} diff --git a/src/main/java/org/qortal/data/system/SystemInfo.java b/src/main/java/org/qortal/data/system/SystemInfo.java new file mode 100644 index 00000000..bf832194 --- /dev/null +++ b/src/main/java/org/qortal/data/system/SystemInfo.java @@ -0,0 +1,49 @@ +package org.qortal.data.system; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class SystemInfo { + + private long freeMemory; + + private long memoryInUse; + + private long totalMemory; + + private long maxMemory; + + private int availableProcessors; + + public SystemInfo() { + } + + public SystemInfo(long freeMemory, long memoryInUse, long totalMemory, long maxMemory, int availableProcessors) { + this.freeMemory = freeMemory; + this.memoryInUse = memoryInUse; + this.totalMemory = totalMemory; + this.maxMemory = maxMemory; + this.availableProcessors = availableProcessors; + } + + public long getFreeMemory() { + return freeMemory; + } + + public long getMemoryInUse() { + return memoryInUse; + } + + public long getTotalMemory() { + return totalMemory; + } + + public long getMaxMemory() { + return maxMemory; + } + + public int getAvailableProcessors() { + return availableProcessors; + } +} diff --git a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java index f3828de8..81cfaa68 100644 --- a/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ArbitraryTransactionData.java @@ -200,4 +200,26 @@ public class ArbitraryTransactionData extends TransactionData { return this.payments; } + @Override + public String toString() { + return "ArbitraryTransactionData{" + + "version=" + version + + ", service=" + service + + ", nonce=" + nonce + + ", size=" + size + + ", name='" + name + '\'' + + ", identifier='" + identifier + '\'' + + ", method=" + method + + ", compression=" + compression + + ", dataType=" + dataType + + ", type=" + type + + ", timestamp=" + timestamp + + ", fee=" + fee + + ", txGroupId=" + txGroupId + + ", blockHeight=" + blockHeight + + ", blockSequence=" + blockSequence + + ", approvalStatus=" + approvalStatus + + ", approvalHeight=" + approvalHeight + + '}'; + } } diff --git a/src/main/java/org/qortal/event/DataMonitorEvent.java b/src/main/java/org/qortal/event/DataMonitorEvent.java new file mode 100644 index 00000000..c62d9acf --- /dev/null +++ b/src/main/java/org/qortal/event/DataMonitorEvent.java @@ -0,0 +1,57 @@ +package org.qortal.event; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class DataMonitorEvent implements Event{ + private long timestamp; + private String identifier; + private String name; + private String service; + private String description; + private long transactionTimestamp; + private long latestPutTimestamp; + + public DataMonitorEvent() { + } + + public DataMonitorEvent(long timestamp, String identifier, String name, String service, String description, long transactionTimestamp, long latestPutTimestamp) { + + this.timestamp = timestamp; + this.identifier = identifier; + this.name = name; + this.service = service; + this.description = description; + this.transactionTimestamp = transactionTimestamp; + this.latestPutTimestamp = latestPutTimestamp; + } + + public long getTimestamp() { + return timestamp; + } + + public String getIdentifier() { + return identifier; + } + + public String getName() { + return name; + } + + public String getService() { + return service; + } + + public String getDescription() { + return description; + } + + public long getTransactionTimestamp() { + return transactionTimestamp; + } + + public long getLatestPutTimestamp() { + return latestPutTimestamp; + } +} diff --git a/src/main/java/org/qortal/group/Group.java b/src/main/java/org/qortal/group/Group.java index 59e32545..a6b4f3a6 100644 --- a/src/main/java/org/qortal/group/Group.java +++ b/src/main/java/org/qortal/group/Group.java @@ -2,6 +2,7 @@ package org.qortal.group; import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; +import org.qortal.block.BlockChain; import org.qortal.controller.Controller; import org.qortal.crypto.Crypto; import org.qortal.data.group.*; @@ -150,7 +151,12 @@ public class Group { // Adminship private GroupAdminData getAdmin(String admin) throws DataException { - return groupRepository.getAdmin(this.groupData.getGroupId(), admin); + if( repository.getBlockRepository().getBlockchainHeight() < BlockChain.getInstance().getAdminQueryFixHeight()) { + return groupRepository.getAdminFaulty(this.groupData.getGroupId(), admin); + } + else { + return groupRepository.getAdmin(this.groupData.getGroupId(), admin); + } } private boolean adminExists(String admin) throws DataException { @@ -668,8 +674,8 @@ public class Group { public void uninvite(GroupInviteTransactionData groupInviteTransactionData) throws DataException { String invitee = groupInviteTransactionData.getInvitee(); - // If member exists then they were added when invite matched join request - if (this.memberExists(invitee)) { + // If member exists and the join request is present then they were added when invite matched join request + if (this.memberExists(invitee) && groupInviteTransactionData.getJoinReference() != null) { // Rebuild join request using cached reference to transaction that created join request. this.rebuildJoinRequest(invitee, groupInviteTransactionData.getJoinReference()); diff --git a/src/main/java/org/qortal/network/Handshake.java b/src/main/java/org/qortal/network/Handshake.java index 782ca2b7..081e79e6 100644 --- a/src/main/java/org/qortal/network/Handshake.java +++ b/src/main/java/org/qortal/network/Handshake.java @@ -269,7 +269,7 @@ public enum Handshake { private static final int POW_DIFFICULTY_POST_131 = 2; // leading zero bits - private static final ExecutorService responseExecutor = Executors.newFixedThreadPool(Settings.getInstance().getNetworkPoWComputePoolSize(), new DaemonThreadFactory("Network-PoW")); + private static final ExecutorService responseExecutor = Executors.newFixedThreadPool(Settings.getInstance().getNetworkPoWComputePoolSize(), new DaemonThreadFactory("Network-PoW", Settings.getInstance().getHandshakeThreadPriority())); private static final byte[] ZERO_CHALLENGE = new byte[ChallengeMessage.CHALLENGE_LENGTH]; diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index 0e5885ad..f500b2e8 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -53,7 +53,7 @@ public class Network { /** * How long between informational broadcasts to all connected peers, in milliseconds. */ - private static final long BROADCAST_INTERVAL = 60 * 1000L; // ms + private static final long BROADCAST_INTERVAL = 30 * 1000L; // ms /** * Maximum time since last successful connection for peer info to be propagated, in milliseconds. */ @@ -83,12 +83,12 @@ public class Network { "node6.qortalnodes.live", "node7.qortalnodes.live", "node8.qortalnodes.live" }; - private static final long NETWORK_EPC_KEEPALIVE = 10L; // seconds + private static final long NETWORK_EPC_KEEPALIVE = 5L; // seconds public static final int MAX_SIGNATURES_PER_REPLY = 500; public static final int MAX_BLOCK_SUMMARIES_PER_REPLY = 500; - private static final long DISCONNECTION_CHECK_INTERVAL = 10 * 1000L; // milliseconds + private static final long DISCONNECTION_CHECK_INTERVAL = 20 * 1000L; // milliseconds private static final int BROADCAST_CHAIN_TIP_DEPTH = 7; // Just enough to fill a SINGLE TCP packet (~1440 bytes) @@ -164,11 +164,11 @@ public class Network { maxPeers = Settings.getInstance().getMaxPeers(); // We'll use a cached thread pool but with more aggressive timeout. - ExecutorService networkExecutor = new ThreadPoolExecutor(1, + ExecutorService networkExecutor = new ThreadPoolExecutor(2, Settings.getInstance().getMaxNetworkThreadPoolSize(), NETWORK_EPC_KEEPALIVE, TimeUnit.SECONDS, new SynchronousQueue(), - new NamedThreadFactory("Network-EPC")); + new NamedThreadFactory("Network-EPC", Settings.getInstance().getNetworkThreadPriority())); networkEPC = new NetworkProcessor(networkExecutor); } diff --git a/src/main/java/org/qortal/network/RNSCommon.java b/src/main/java/org/qortal/network/RNSCommon.java index a43afb2a..29395d5f 100644 --- a/src/main/java/org/qortal/network/RNSCommon.java +++ b/src/main/java/org/qortal/network/RNSCommon.java @@ -5,12 +5,20 @@ public class RNSCommon { /** * Destination application name */ - public static String APP_NAME = "qortal"; + public static String MAINNET_APP_NAME = "qortal"; // production + public static String TESTNET_APP_NAME = "qortaltest"; // test net /** * Configuration path relative to the Qortal launch directory */ - public static String defaultRNSConfigPath = new String(".reticulum"); + public static String defaultRNSConfigPath = ".reticulum"; + public static String defaultRNSConfigPathTestnet = ".reticulum_test"; + + /** + * Default config + */ + public static String defaultRNSConfig = "reticulum_default_config.yml"; + public static String defaultRNSConfigTestnet = "reticulum_default_testnet_config.yml"; ///** // * Qortal RNS Destinations diff --git a/src/main/java/org/qortal/network/RNSNetwork.java b/src/main/java/org/qortal/network/RNSNetwork.java index 35a6895c..83c35df1 100644 --- a/src/main/java/org/qortal/network/RNSNetwork.java +++ b/src/main/java/org/qortal/network/RNSNetwork.java @@ -11,19 +11,23 @@ import io.reticulum.identity.Identity; import io.reticulum.link.Link; import io.reticulum.link.LinkStatus; //import io.reticulum.constant.LinkConstant; +//import static io.reticulum.constant.ReticulumConstant.MTU; +import io.reticulum.buffer.Buffer; +import io.reticulum.buffer.BufferedRWPair; import io.reticulum.packet.Packet; import io.reticulum.packet.PacketReceipt; import io.reticulum.packet.PacketReceiptStatus; import io.reticulum.transport.AnnounceHandler; -import static io.reticulum.link.TeardownSession.DESTINATION_CLOSED; -import static io.reticulum.link.TeardownSession.INITIATOR_CLOSED; +//import static io.reticulum.link.TeardownSession.DESTINATION_CLOSED; +//import static io.reticulum.link.TeardownSession.INITIATOR_CLOSED; import static io.reticulum.link.TeardownSession.TIMEOUT; import static io.reticulum.link.LinkStatus.ACTIVE; import static io.reticulum.link.LinkStatus.STALE; -import static io.reticulum.link.LinkStatus.PENDING; +import static io.reticulum.link.LinkStatus.CLOSED; +//import static io.reticulum.link.LinkStatus.PENDING; import static io.reticulum.link.LinkStatus.HANDSHAKE; //import static io.reticulum.packet.PacketContextType.LINKCLOSE; -import static io.reticulum.identity.IdentityKnownDestination.recall; +//import static io.reticulum.identity.IdentityKnownDestination.recall; import static io.reticulum.utils.IdentityUtils.concatArrays; //import static io.reticulum.constant.ReticulumConstant.TRUNCATED_HASHLENGTH; import static io.reticulum.constant.ReticulumConstant.CONFIG_FILE_NAME; @@ -42,11 +46,13 @@ import static java.nio.file.StandardOpenOption.CREATE; import static java.nio.file.StandardOpenOption.WRITE; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.channels.SelectionKey; import static java.nio.charset.StandardCharsets.UTF_8; //import static java.util.Objects.isNull; //import static java.util.Objects.isNull; import static java.util.Objects.nonNull; +//import static org.apache.commons.lang3.BooleanUtils.isTrue; //import static org.apache.commons.lang3.BooleanUtils.isFalse; import java.io.File; @@ -54,11 +60,35 @@ import java.util.Arrays; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Iterator; //import java.util.Random; -import java.util.Scanner; +//import java.util.Scanner; import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +//import java.util.concurrent.locks.Lock; +//import java.util.concurrent.locks.ReentrantLock; +import java.util.Objects; +import java.util.function.Function; +import java.time.Instant; import org.apache.commons.codec.binary.Hex; +import org.qortal.utils.ExecuteProduceConsume; +import org.qortal.utils.ExecuteProduceConsume.StatsSnapshot; +import org.qortal.utils.NTP; +import org.qortal.utils.NamedThreadFactory; +import org.qortal.network.message.Message; +import org.qortal.network.message.BlockSummariesV2Message; +import org.qortal.network.message.TransactionSignaturesMessage; +import org.qortal.network.message.GetUnconfirmedTransactionsMessage; +import org.qortal.network.task.RNSBroadcastTask; +import org.qortal.network.task.RNSPrunePeersTask; +import org.qortal.controller.Controller; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.data.block.BlockData; +import org.qortal.data.block.BlockSummaryData; +import org.qortal.data.transaction.TransactionData; // logging import lombok.extern.slf4j.Slf4j; @@ -71,25 +101,49 @@ public class RNSNetwork { Reticulum reticulum; //private static final String APP_NAME = "qortal"; - static final String APP_NAME = RNSCommon.APP_NAME; - static final String defaultConfigPath = new String(".reticulum"); // if empty will look in Reticulums default paths - //static final String defaultConfigPath = RNSCommon.defaultRNSConfigPath; - //private final String defaultConfigPath = Settings.getInstance().getDefaultRNSConfigPathForReticulum(); - private static Integer MAX_PEERS = 12; - //private final Integer MAX_PEERS = Settings.getInstance().getMaxReticulumPeers(); - private static Integer MIN_DESIRED_PEERS = 3; - //private final Integer MIN_DESIRED_PEERS = Settings.getInstance().getMinDesiredPeers(); + static final String APP_NAME = Settings.getInstance().isTestNet() ? RNSCommon.TESTNET_APP_NAME: RNSCommon.MAINNET_APP_NAME; + //static final String defaultConfigPath = ".reticulum"; // if empty will look in Reticulums default paths + static final String defaultConfigPath = Settings.getInstance().isTestNet() ? RNSCommon.defaultRNSConfigPathTestnet: RNSCommon.defaultRNSConfigPath; + private final int MAX_PEERS = Settings.getInstance().getReticulumMaxPeers(); + private final int MIN_DESIRED_PEERS = Settings.getInstance().getReticulumMinDesiredPeers(); + // How long [ms] between pruning of peers + private long PRUNE_INTERVAL = 1 * 60 * 1000L; // ms; + Identity serverIdentity; public Destination baseDestination; private volatile boolean isShuttingDown = false; - private final List linkedPeers = Collections.synchronizedList(new ArrayList<>()); - private final List incomingLinks = Collections.synchronizedList(new ArrayList<>()); - ////private final ExecuteProduceConsume rnsNetworkEPC; - //private static final long NETWORK_EPC_KEEPALIVE = 1000L; // 1 second - //private volatile boolean isShuttingDown = false; - //private int totalThreadCount = 0; - //// TODO: settings - MaxReticulumPeers, MaxRNSNetworkThreadPoolSize (if needed) + /** + * Maintain two lists for each subset of peers + * => a synchronizedList, modified when peers are added/removed + * => an immutable List, automatically rebuild to mirror synchronizedList, served to consumers + * linkedPeers are "initiators" (containing initiator reticulum Link), actively doing work. + * incomimgPeers are "non-initiators", the passive end of bidirectional Reticulum Buffers. + */ + private final List linkedPeers = Collections.synchronizedList(new ArrayList<>()); + private List immutableLinkedPeers = Collections.emptyList(); + private final List incomingPeers = Collections.synchronizedList(new ArrayList<>()); + private List immutableIncomingPeers = Collections.emptyList(); + + private final ExecuteProduceConsume rnsNetworkEPC; + private static final long NETWORK_EPC_KEEPALIVE = 1000L; // 1 second + private int totalThreadCount = 0; + private final int reticulumMaxNetworkThreadPoolSize = Settings.getInstance().getReticulumMaxNetworkThreadPoolSize(); + + // replicating a feature from Network.class needed in for base Message.java, + // just in case the classic TCP/IP Networking is turned off. + private static final byte[] MAINNET_MESSAGE_MAGIC = new byte[]{0x51, 0x4f, 0x52, 0x54}; // QORT + private static final byte[] TESTNET_MESSAGE_MAGIC = new byte[]{0x71, 0x6f, 0x72, 0x54}; // qorT + private static final int BROADCAST_CHAIN_TIP_DEPTH = 7; // (~1440 bytes) + /** + * How long between informational broadcasts to all ACTIVE peers, in milliseconds. + */ + private static final long BROADCAST_INTERVAL = 30 * 1000L; // ms + /** + * Link low-level ping interval and timeout + */ + private static final long LINK_PING_INTERVAL = 55 * 1000L; // ms + private static final long LINK_UNREACHABLE_TIMEOUT = 3 * LINK_PING_INTERVAL; //private static final Logger logger = LoggerFactory.getLogger(RNSNetwork.class); @@ -113,12 +167,12 @@ public class RNSNetwork { log.info("reticulum instance created: {}", reticulum); // Settings.getInstance().getMaxRNSNetworkThreadPoolSize(), // statically set to 5 below - //ExecutorService RNSNetworkExecutor = new ThreadPoolExecutor(1, - // 5, - // NETWORK_EPC_KEEPALIVE, TimeUnit.SECONDS, - // new SynchronousQueue(), - // new NamedThreadFactory("RNSNetwork-EPC")); - //rnsNetworkEPC = new RNSNetworkProcessor(RNSNetworkExecutor); + ExecutorService RNSNetworkExecutor = new ThreadPoolExecutor(1, + reticulumMaxNetworkThreadPoolSize, + NETWORK_EPC_KEEPALIVE, TimeUnit.SECONDS, + new SynchronousQueue(), + new NamedThreadFactory("RNSNetwork-EPC", Settings.getInstance().getNetworkThreadPriority())); + rnsNetworkEPC = new RNSNetworkProcessor(RNSNetworkExecutor); } // Note: potentially create persistent serverIdentity (utility rnid) and load it from file @@ -128,7 +182,7 @@ public class RNSNetwork { var serverIdentityPath = reticulum.getStoragePath().resolve("identities/"+APP_NAME); if (Files.isReadable(serverIdentityPath)) { serverIdentity = Identity.fromFile(serverIdentityPath); - log.info("server identity loaded from file {}", serverIdentityPath.toString()); + log.info("server identity loaded from file {}", serverIdentityPath); } else { serverIdentity = new Identity(); log.info("APP_NAME: {}, storage path: {}", APP_NAME, serverIdentityPath); @@ -155,35 +209,32 @@ public class RNSNetwork { APP_NAME, "core" ); - //// idea for other entry point + //// idea for other entry point (needs AnnounceHandler with appropriate aspect) //dataDestination = new Destination( // serverIdentity, // Direction.IN, // DestinationType.SINGLE, // APP_NAME, - // "core", // "qdn" //); - log.info("Destination "+Hex.encodeHexString(baseDestination.getHash())+" "+baseDestination.getName()+" running."); + log.info("Destination {} {} running", Hex.encodeHexString(baseDestination.getHash()), baseDestination.getName()); baseDestination.setProofStrategy(ProofStrategy.PROVE_ALL); baseDestination.setAcceptLinkRequests(true); - - baseDestination.setLinkEstablishedCallback(this::clientConnected); + baseDestination.setLinkEstablishedCallback(this::clientConnected); Transport.getInstance().registerAnnounceHandler(new QAnnounceHandler()); log.debug("announceHandlers: {}", Transport.getInstance().getAnnounceHandlers()); - // do a first announce baseDestination.announce(); log.debug("Sent initial announce from {} ({})", Hex.encodeHexString(baseDestination.getHash()), baseDestination.getName()); - - // Start up first networking thread (the "server loop") - //rnsNetworkEPC.start(); + + // Start up first networking thread (the "server loop", the "Tasks engine") + rnsNetworkEPC.start(); } private void initConfig(String configDir) throws IOException { - File configDir1 = new File(defaultConfigPath); + File configDir1 = new File(configDir); if (!configDir1.exists()) { configDir1.mkdir(); } @@ -191,38 +242,89 @@ public class RNSNetwork { Path configFile = configPath.resolve(CONFIG_FILE_NAME); if (Files.notExists(configFile)) { - var defaultConfig = this.getClass().getClassLoader().getResourceAsStream("reticulum_default_config.yml"); + var defaultConfig = this.getClass().getClassLoader().getResourceAsStream(RNSCommon.defaultRNSConfig); + if (Settings.getInstance().isTestNet()) { + defaultConfig = this.getClass().getClassLoader().getResourceAsStream(RNSCommon.defaultRNSConfigTestnet); + } Files.copy(defaultConfig, configFile, StandardCopyOption.REPLACE_EXISTING); } } + public void broadcast(Function peerMessageBuilder) { + for (RNSPeer peer : getImmutableLinkedPeers()) { + if (this.isShuttingDown) { + return; + } + + Message message = peerMessageBuilder.apply(peer); + + if (message == null) { + continue; + } + + var pl = peer.getPeerLink(); + if (nonNull(pl) && (pl.getStatus() == ACTIVE)) { + peer.sendMessage(message); + } + } + } + + public void broadcastOurChain() { + BlockData latestBlockData = Controller.getInstance().getChainTip(); + int latestHeight = latestBlockData.getHeight(); + + try (final Repository repository = RepositoryManager.getRepository()) { + List latestBlockSummaries = repository.getBlockRepository().getBlockSummaries(latestHeight - BROADCAST_CHAIN_TIP_DEPTH, latestHeight); + Message latestBlockSummariesMessage = new BlockSummariesV2Message(latestBlockSummaries); + + broadcast(broadcastPeer -> latestBlockSummariesMessage); + } catch (DataException e) { + log.warn("Couldn't broadcast our chain tip info", e); + } + } + + public Message buildNewTransactionMessage(RNSPeer peer, TransactionData transactionData) { + // In V2 we send out transaction signature only and peers can decide whether to request the full transaction + return new TransactionSignaturesMessage(Collections.singletonList(transactionData.getSignature())); + } + + public Message buildGetUnconfirmedTransactionsMessage(RNSPeer peer) { + return new GetUnconfirmedTransactionsMessage(); + } + public void shutdown() { - isShuttingDown = true; + this.isShuttingDown = true; log.info("shutting down Reticulum"); - // Stop processing threads (the "server loop") - //try { - // if (!this.rnsNetworkEPC.shutdown(5000)) { - // logger.warn("RNSNetwork threads failed to terminate"); - // } - //} catch (InterruptedException e) { - // logger.warn("Interrupted while waiting for RNS networking threads to terminate"); - //} - + // gracefully close links of peers that point to us + for (RNSPeer p: incomingPeers) { + var pl = p.getPeerLink(); + if (nonNull(pl) & (pl.getStatus() == ACTIVE)) { + p.sendCloseToRemote(pl); + } + } // Disconnect peers gracefully and terminate Reticulum for (RNSPeer p: linkedPeers) { log.info("shutting down peer: {}", Hex.encodeHexString(p.getDestinationHash())); - log.debug("peer: {}", p); + //log.debug("peer: {}", p); p.shutdown(); try { TimeUnit.SECONDS.sleep(1); // allow for peers to disconnect gracefully } catch (InterruptedException e) { - log.error("exception: {}", e); + log.error("exception: ", e); } + //var pl = p.getPeerLink(); + //if (nonNull(pl) & (pl.getStatus() == ACTIVE)) { + // pl.teardown(); + //} } - // gracefully close links of peers that point to us - for (Link l: incomingLinks) { - sendCloseToRemote(l); + // Stop processing threads (the "server loop") + try { + if (!this.rnsNetworkEPC.shutdown(5000)) { + log.warn("RNSNetwork threads failed to terminate"); + } + } catch (InterruptedException e) { + log.warn("Interrupted while waiting for RNS networking threads to terminate"); } // Note: we still need to get the packet timeout callback to work... reticulum.exitHandler(); @@ -241,12 +343,12 @@ public class RNSNetwork { } public void closePacketDelivered(PacketReceipt receipt) { - var rttString = new String(""); + var rttString = ""; if (receipt.getStatus() == PacketReceiptStatus.DELIVERED) { var rtt = receipt.getRtt(); // rtt (Java) is in miliseconds //log.info("qqp - packetDelivered - rtt: {}", rtt); if (rtt >= 1000) { - rtt = Math.round(rtt / 1000); + rtt = Math.round((float) rtt / 1000); rttString = String.format("%d seconds", rtt); } else { rttString = String.format("%d miliseconds", rtt); @@ -261,73 +363,35 @@ public class RNSNetwork { } public void clientConnected(Link link) { - link.setLinkClosedCallback(this::clientDisconnected); - link.setPacketCallback(this::serverPacketReceived); - var peer = findPeerByLink(link); - if (nonNull(peer)) { - log.info("initiator peer {} opened link (link lookup: {}), link destination hash: {}", - Hex.encodeHexString(peer.getDestinationHash()), link, Hex.encodeHexString(link.getDestination().getHash())); - // make sure the peerLink is actvive. - peer.getOrInitPeerLink(); - } else { - log.info("non-initiator closed link (link lookup: {}), link destination hash (initiator): {}", - peer, link, Hex.encodeHexString(link.getDestination().getHash())); - } - incomingLinks.add(link); + //link.setLinkClosedCallback(this::clientDisconnected); + //link.setPacketCallback(this::serverPacketReceived); + log.info("clientConnected - link hash: {}, {}", link.getHash(), Hex.encodeHexString(link.getHash())); + RNSPeer newPeer = new RNSPeer(link); + newPeer.setPeerLinkHash(link.getHash()); + newPeer.setMessageMagic(getMessageMagic()); + // make sure the peer has a channel and buffer + newPeer.getOrInitPeerBuffer(); + incomingPeers.add(newPeer); log.info("***> Client connected, link: {}", link); } public void clientDisconnected(Link link) { - var peer = findPeerByLink(link); - if (nonNull(peer)) { - log.info("initiator peer {} closed link (link lookup: {}), link destination hash: {}", - Hex.encodeHexString(peer.getDestinationHash()), link, Hex.encodeHexString(link.getDestination().getHash())); - } else { - log.info("non-initiator closed link (link lookup: {}), link destination hash (initiator): {}", - peer, link, Hex.encodeHexString(link.getDestination().getHash())); - } - // if we have a peer pointing to that destination, we can close and remove it - peer = findPeerByDestinationHash(link.getDestination().getHash()); - if (nonNull(peer)) { - // Note: no shutdown as the remobe peer could be only rebooting. - // keep it to reopen link later if possible. - peer.getPeerLink().teardown(); - } - incomingLinks.remove(link); log.info("***> Client disconnected"); } public void serverPacketReceived(byte[] message, Packet packet) { var msgText = new String(message, StandardCharsets.UTF_8); log.info("Received data on link - message: {}, destinationHash: {}", msgText, Hex.encodeHexString(packet.getDestinationHash())); - //var peer = findPeerByDestinationHash(packet.getDestinationHash()); - //if (msgText.equals("ping")) { - // log.info("received ping"); - // //if (nonNull(peer)) { - // // String replyText = "pong"; - // // byte[] replyData = replyText.getBytes(StandardCharsets.UTF_8); - // // Packet reply = new Packet(peer.getPeerLink(), replyData); - // //} - //} - //if (msgText.equals("shutdown")) { - // log.info("shutdown packet received"); - // var link = recall(packet.getDestinationHash()); - // log.info("recalled destinationHash: {}", link); - // //... - //} - // TODO: process packet.... } //public void announceBaseDestination () { // getBaseDestination().announce(); //} - //@Slf4j private class QAnnounceHandler implements AnnounceHandler { @Override public String getAspectFilter() { - // handle all announces - return null; + return "qortal.core"; } @Override @@ -352,8 +416,6 @@ public class RNSNetwork { } } if (activePeerCount < MAX_PEERS) { - //if (!peerExists) { - //var peer = findPeerByDestinationHash(destinationHash); for (RNSPeer p: lps) { if (Arrays.equals(p.getDestinationHash(), destinationHash)) { log.info("QAnnounceHandler - peer exists - found peer matching destinationHash"); @@ -369,7 +431,7 @@ public class RNSNetwork { if (nonNull(p.getPeerLink())) { log.info("QAnnounceHandler - other peer - link: {}, status: {}", p.getPeerLink(), p.getPeerLink().getStatus()); } else { - log.info("QAnnounceHandler - null link peer - link: {}", p.getPeerLink()); + log.info("QAnnounceHandler - peer link is null"); } } } @@ -377,71 +439,147 @@ public class RNSNetwork { RNSPeer newPeer = new RNSPeer(destinationHash); newPeer.setServerIdentity(announcedIdentity); newPeer.setIsInitiator(true); - lps.add(newPeer); + newPeer.setMessageMagic(getMessageMagic()); + addLinkedPeer(newPeer); log.info("added new RNSPeer, destinationHash: {}", Hex.encodeHexString(destinationHash)); } } - //} + // Chance to announce instead of waiting for next pruning. + // Note: good in theory but leads to ping-pong of announces => not a good idea! + //maybeAnnounce(getBaseDestination()); } } // Main thread - //class RNSNetworkProcessor extends ExecuteProduceConsume { - // - // //private final Logger logger = LoggerFactory.getLogger(RNSNetworkProcessor.class); - // - // private final AtomicLong nextConnectTaskTimestamp = new AtomicLong(0L); // ms - try first connect once NTP syncs - // private final AtomicLong nextBroadcastTimestamp = new AtomicLong(0L); // ms - try first broadcast once NTP syncs - // - // private Iterator channelIterator = null; - // - // RNSNetworkProcessor(ExecutorService executor) { - // super(executor); - // } - // - // @Override - // protected void onSpawnFailure() { - // // For debugging: - // // ExecutorDumper.dump(this.executor, 3, ExecuteProduceConsume.class); - // } - // - // @Override - // protected Task produceTask(boolean canBlock) throws InterruptedException { - // Task task; - // - // //task = maybeProducePeerMessageTask(); - // //if (task != null) { - // // return task; - // //} - // // - // //final Long now = NTP.getTime(); - // // - // //task = maybeProducePeerPingTask(now); - // //if (task != null) { - // // return task; - // //} - // // - // //task = maybeProduceConnectPeerTask(now); - // //if (task != null) { - // // return task; - // //} - // // - // //task = maybeProduceBroadcastTask(now); - // //if (task != null) { - // // return task; - // //} - // // - // // Only this method can block to reduce CPU spin - // //return maybeProduceChannelTask(canBlock); - // - // // TODO: flesh out the tasks handled by Reticulum - // return null; - // } - // //...TODO: implement abstract methods... - //} + class RNSNetworkProcessor extends ExecuteProduceConsume { + //private final Logger logger = LoggerFactory.getLogger(RNSNetworkProcessor.class); + + private final AtomicLong nextConnectTaskTimestamp = new AtomicLong(0L); // ms - try first connect once NTP syncs + private final AtomicLong nextBroadcastTimestamp = new AtomicLong(0L); // ms - try first broadcast once NTP syncs + private final AtomicLong nextPingTimestamp = new AtomicLong(0L); // ms - try first low-level Ping + private final AtomicLong nextPruneTimestamp = new AtomicLong(0L); // ms - try first low-level Ping + + private Iterator channelIterator = null; + + RNSNetworkProcessor(ExecutorService executor) { + super(executor); + final Long now = NTP.getTime(); + nextPruneTimestamp.set(now + PRUNE_INTERVAL/2); + } + + @Override + protected void onSpawnFailure() { + // For debugging: + // ExecutorDumper.dump(this.executor, 3, ExecuteProduceConsume.class); + } + + @Override + protected Task produceTask(boolean canBlock) throws InterruptedException { + Task task; + + //// TODO: Needed? Figure out how to add pending messages in RNSPeer + //// (RNSPeer: pendingMessages.offer(message)) + //task = maybeProducePeerMessageTask(); + //if (task != null) { + // return task; + //} + + final Long now = NTP.getTime(); + + // Prune stuck/slow/old peers (moved from Controller) + task = maybeProduceRNSPrunePeersTask(now); + if (task != null) { + return task; + } + + // ping task (Link+Channel+Buffer) + task = maybeProducePeerPingTask(now); + if (task != null) { + return task; + } + + task = maybeProduceBroadcastTask(now); + if (task != null) { + return task; + } + + // Prune stuck/slow/old peers (moved from Controller) + task = maybeProduceRNSPrunePeersTask(now); + if (task != null) { + return task; + } + + return null; + } + + ////private Task maybeProducePeerMessageTask() { + //// return getImmutableConnectedPeers().stream() + //// .map(Peer::getMessageTask) + //// .filter(Objects::nonNull) + //// .findFirst() + //// .orElse(null); + ////} + ////private Task maybeProducePeerMessageTask() { + //// return getImmutableIncomingPeers().stream() + //// .map(RNSPeer::getMessageTask) + //// .filter(RNSPeer::isAvailable) + //// .findFirst() + //// .orElse(null); + ////} + //// Note: we might not need this. All messages handled asynchronously in Reticulum + //// (RNSPeer peerBufferReady callback) + //private Task maybeProducePeerMessageTask() { + // return getImmutableLinkedPeers().stream() + // .map(RNSPeer::getMessageTask) + // .filter(Objects::nonNull) + // .findFirst() + // .orElse(null); + //} + + //private Task maybeProducePeerPingTask(Long now) { + // return getImmutableHandshakedPeers().stream() + // .map(peer -> peer.getPingTask(now)) + // .filter(Objects::nonNull) + // .findFirst() + // .orElse(null); + //} + private Task maybeProducePeerPingTask(Long now) { + //var ilp = getImmutableLinkedPeers().stream() + // .map(peer -> peer.getPingTask(now)) + // .filter(Objects::nonNull) + // .findFirst() + // .orElse(null); + //if (nonNull(ilp)) { + // log.info("ilp - {}", ilp); + //} + //return ilp; + return getImmutableLinkedPeers().stream() + .map(peer -> peer.getPingTask(now)) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private Task maybeProduceBroadcastTask(Long now) { + if (now == null || now < nextBroadcastTimestamp.get()) { + return null; + } + + nextBroadcastTimestamp.set(now + BROADCAST_INTERVAL); + return new RNSBroadcastTask(); + } + + private Task maybeProduceRNSPrunePeersTask(Long now) { + if (now == null || now < nextPruneTimestamp.get()) { + return null; + } + + nextPruneTimestamp.set(now + PRUNE_INTERVAL); + return new RNSPrunePeersTask(); + } + } - // getter / setter private static class SingletonContainer { private static final RNSNetwork INSTANCE = new RNSNetwork(); } @@ -450,31 +588,56 @@ public class RNSNetwork { return SingletonContainer.INSTANCE; } - public Identity getServerIdentity() { - return this.serverIdentity; + public List getImmutableLinkedPeers() { + return this.immutableLinkedPeers; } - public Reticulum getReticulum() { - return this.reticulum; + public void addLinkedPeer(RNSPeer peer) { + this.linkedPeers.add(peer); + this.immutableLinkedPeers = List.copyOf(this.linkedPeers); // thread safe + } + + public void removeLinkedPeer(RNSPeer peer) { + if (nonNull(peer.getPeerBuffer())) { + peer.getPeerBuffer().close(); + } + if (nonNull(peer.getPeerLink())) { + peer.getPeerLink().teardown(); + } + this.linkedPeers.remove(peer); // thread safe + this.immutableLinkedPeers = List.copyOf(this.linkedPeers); } public List getLinkedPeers() { - synchronized(this.linkedPeers) { + //synchronized(this.linkedPeers) { //return new ArrayList<>(this.linkedPeers); return this.linkedPeers; - } + //} } - public Integer getTotalPeers() { - synchronized (this) { - return linkedPeers.size(); - } + public void addIncomingPeer(RNSPeer peer) { + this.incomingPeers.add(peer); + this.immutableIncomingPeers = List.copyOf(this.incomingPeers); } - public Destination getBaseDestination() { - return baseDestination; + public void removeIncomingPeer(RNSPeer peer) { + if (nonNull(peer.getPeerLink())) { + peer.getPeerLink().teardown(); + } + this.incomingPeers.remove(peer); + this.immutableIncomingPeers = List.copyOf(this.incomingPeers); } + public List getIncomingPeers() { + return this.incomingPeers; + } + + public List getImmutableIncomingPeers() { + return this.immutableIncomingPeers; + } + + // TODO, methods for: getAvailablePeer + // maintenance //public void removePeer(RNSPeer peer) { // synchronized(this) { @@ -492,71 +655,102 @@ public class RNSNetwork { // } //} - @Synchronized - public void prunePeers() throws DataException { - // run periodically (by the Controller) - //List linkList = getLinkedPeers(); - var peerList = getLinkedPeers(); - log.info("number of links (linkedPeers) before prunig: {}", peerList.size()); - Link pLink; - LinkStatus lStatus; - for (RNSPeer p: peerList) { - pLink = p.getPeerLink(); - log.info("prunePeers - pLink: {}, destinationHash: {}", - pLink, Hex.encodeHexString(p.getDestinationHash())); - log.debug("peer: {}", p); - if (nonNull(pLink)) { - if (p.getPeerTimedOut()) { - // close peer link for now - pLink.teardown(); - } - lStatus = pLink.getStatus(); - log.info("Link {} status: {}", pLink, lStatus); - // lStatus in: PENDING, HANDSHAKE, ACTIVE, STALE, CLOSED - if ((lStatus == STALE) || (pLink.getTeardownReason() == TIMEOUT) || (p.getDeleteMe())) { - p.shutdown(); - peerList.remove(p); - } else if (lStatus == HANDSHAKE) { - // stuck in handshake state (do we need to shutdown/remove it?) - log.info("peer status HANDSHAKE"); - p.shutdown(); - peerList.remove(p); - } - } else { - peerList.remove(p); - } + private Boolean isUnreachable(RNSPeer peer) { + var result = peer.getDeleteMe(); + var now = Instant.now(); + var peerLastAccessTimestamp = peer.getLastAccessTimestamp(); + if (peerLastAccessTimestamp.isBefore(now.minusMillis(LINK_UNREACHABLE_TIMEOUT))) { + result = true; } - //removeExpiredPeers(this.linkedPeers); - log.info("number of links (linkedPeers) after prunig: {}", peerList.size()); - //log.info("we have {} non-initiator links, list: {}", incomingLinks.size(), incomingLinks); - var activePeerCount = 0; - var lps = RNSNetwork.getInstance().getLinkedPeers(); - for (RNSPeer p: lps) { - pLink = p.getPeerLink(); - p.pingRemote(); - try { - TimeUnit.SECONDS.sleep(2); // allow for peers to disconnect gracefully - } catch (InterruptedException e) { - log.error("exception: {}", e); - } - if ((nonNull(pLink) && (pLink.getStatus() == ACTIVE))) { - activePeerCount = activePeerCount + 1; - } - } - log.info("we have {} active peers", activePeerCount); - maybeAnnounce(getBaseDestination()); + return result; } - //public void removeExpiredPeers(List peerList) { - // //List peerList = this.linkedPeers; - // for (RNSPeer p: peerList) { - // if (p.getPeerLink() == null) { - // peerList.remove(p); - // } else if (p.getPeerLink().getStatus() == STALE) { - // peerList.remove(p); - // } - // } - //} + public List incomingNonActivePeers() { + var ips = getIncomingPeers(); + List result = Collections.synchronizedList(new ArrayList<>()); + Link pl; + for (RNSPeer p: ips) { + pl = p.getPeerLink(); + if (nonNull(pl)) { + if (pl.getStatus() != ACTIVE) { + result.add(p); + } + } else { + result.add(p); + } + } + return result; + } + + //@Synchronized + public void prunePeers() throws DataException { + // run periodically (by the Controller) + var peerList = getLinkedPeers(); + var incomingPeerList = getIncomingPeers(); + log.info("number of links (linkedPeers / incomingPeers) before prunig: {}, {}", peerList.size(), + incomingPeerList.size()); + // prune initiator peers + List lps = getLinkedPeers(); + for (RNSPeer p : lps) { + var pLink = p.getPeerLink(); + if (nonNull(pLink)) { + log.info("peer link: {}, status: {}", pLink, pLink.getStatus()); + if (pLink.getStatus() == ACTIVE) { + p.pingRemote(); + } + if (p.getPeerTimedOut()) { + pLink.teardown(); + } + } + } + //Link pLink; + //LinkStatus lStatus; + //var now = Instant.now(); + //for (RNSPeer p: peerList) { + // pLink = p.getPeerLink(); + // var peerLastAccessTimestamp = p.getLastAccessTimestamp(); + // var peerLastPingResponseReceived = p.getLastPingResponseReceived(); + // log.info("peerLink: {}, status: {}", pLink, pLink.getStatus()); + // log.info("prunePeers - pLink: {}, destinationHash: {}", + // pLink, Hex.encodeHexString(p.getDestinationHash())); + // log.debug("peer: {}", p); + // if (nonNull(pLink)) { + // if ((p.getPeerTimedOut()) && (peerLastPingResponseReceived.isBefore(now.minusMillis(LINK_UNREACHABLE_TIMEOUT)))) { + // // close peer link for now + // pLink.teardown(); + // } + // lStatus = pLink.getStatus(); + // log.info("Link {} status: {}", pLink, lStatus); + // // lStatus in: PENDING, HANDSHAKE, ACTIVE, STALE, CLOSED + // if ((lStatus == STALE) || (pLink.getTeardownReason() == TIMEOUT) || (isUnreachable(p))) { + // //p.shutdown(); + // //peerList.remove(p); + // removeLinkedPeer(p); + // } else if (lStatus == HANDSHAKE) { + // // stuck in handshake state (do we need to shutdown/remove it?) + // log.info("peer status HANDSHAKE"); + // //p.shutdown(); + // //peerList.remove(p); + // removeLinkedPeer(p); + // } + // // either reach peer or disable link + // p.pingRemote(); + // } else { + // if (peerLastPingResponseReceived.isBefore(now.minusMillis(LINK_UNREACHABLE_TIMEOUT))) { + // //peerList.remove(p); + // removeLinkedPeer(p); + // } + // } + //} + List inaps = incomingNonActivePeers(); + //log.info("number of inactive incoming peers: {}", inaps.size()); + for (RNSPeer p: inaps) { + incomingPeerList.remove(incomingPeerList.indexOf(p)); + } + log.info("number of links (linkedPeers / incomingPeers) after prunig: {}, {}", peerList.size(), + incomingPeerList.size()); + maybeAnnounce(getBaseDestination()); + } public void maybeAnnounce(Destination d) { if (getLinkedPeers().size() < MIN_DESIRED_PEERS) { @@ -568,37 +762,9 @@ public class RNSNetwork { * Helper methods */ - //@Synchronized - //public RNSPeer getPeerIfExists(RNSPeer peer) { - // List lps = RNSNetwork.getInstance().getLinkedPeers(); - // RNSPeer result = null; - // for (RNSPeer p: lps) { - // if (nonNull(p.getDestinationHash()) && Arrays.equals(p.getDestinationHash(), peer.getDestinationHash())) { - // log.info("found match by destinationHash"); - // result = p; - // //break; - // } - // if (nonNull(p.getPeerDestinationHash()) && Arrays.equals(p.getPeerDestinationHash(), peer.getPeerDestinationHash())) { - // log.info("found match by peerDestinationHash"); - // result = p; - // //break; - // } - // if (nonNull(p.getPeerBaseDestinationHash()) && Arrays.equals(p.getPeerBaseDestinationHash(), peer.getPeerBaseDestinationHash())) { - // log.info("found match by peerBaseDestinationHash"); - // result = p; - // //break; - // } - // if (nonNull(p.getRemoteTestHash()) && Arrays.equals(p.getRemoteTestHash(), peer.getRemoteTestHash())) { - // log.info("found match by remoteTestHash"); - // result = p; - // //break; - // } - // } - // return result; - //} - public RNSPeer findPeerByLink(Link link) { - List lps = RNSNetwork.getInstance().getLinkedPeers(); + //List lps = RNSNetwork.getInstance().getLinkedPeers(); + List lps = RNSNetwork.getInstance().getImmutableLinkedPeers(); RNSPeer peer = null; for (RNSPeer p : lps) { var pLink = p.getPeerLink(); @@ -614,7 +780,8 @@ public class RNSNetwork { } public RNSPeer findPeerByDestinationHash(byte[] dhash) { - List lps = RNSNetwork.getInstance().getLinkedPeers(); + //List lps = RNSNetwork.getInstance().getLinkedPeers(); + List lps = RNSNetwork.getInstance().getImmutableLinkedPeers(); RNSPeer peer = null; for (RNSPeer p : lps) { if (Arrays.equals(p.getDestinationHash(), dhash)) { @@ -626,10 +793,32 @@ public class RNSNetwork { return peer; } - public void removePeer(RNSPeer peer) { - List peerList = this.linkedPeers; - if (nonNull(peer)) { - peerList.remove(peer); + //public void removePeer(RNSPeer peer) { + // List peerList = this.linkedPeers; + // if (nonNull(peer)) { + // peerList.remove(peer); + // } + //} + + public byte[] getMessageMagic() { + return Settings.getInstance().isTestNet() ? TESTNET_MESSAGE_MAGIC : MAINNET_MESSAGE_MAGIC; + } + + // Network methods Reticulum implementation + + /** Builds either (legacy) HeightV2Message or (newer) BlockSummariesV2Message, depending on peer version. + * + * @return Message, or null if DataException was thrown. + */ + public Message buildHeightOrChainTipInfo(RNSPeer peer) { + // peer only used for version check + int latestHeight = Controller.getInstance().getChainHeight(); + + try (final Repository repository = RepositoryManager.getRepository()) { + List latestBlockSummaries = repository.getBlockRepository().getBlockSummaries(latestHeight - BROADCAST_CHAIN_TIP_DEPTH, latestHeight); + return new BlockSummariesV2Message(latestBlockSummaries); + } catch (DataException e) { + return null; } } diff --git a/src/main/java/org/qortal/network/RNSPeer.java b/src/main/java/org/qortal/network/RNSPeer.java index 618cf865..d803e754 100644 --- a/src/main/java/org/qortal/network/RNSPeer.java +++ b/src/main/java/org/qortal/network/RNSPeer.java @@ -1,16 +1,18 @@ package org.qortal.network; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +//import org.slf4j.Logger; +//import org.slf4j.LoggerFactory; import static java.util.Objects.isNull; import static java.util.Objects.nonNull; -import java.io.IOException; +//import java.io.IOException; import java.time.Instant; import java.util.Arrays; +import java.util.List; +import java.util.Collections; -import io.reticulum.Reticulum; -import org.qortal.network.RNSNetwork; +//import io.reticulum.Reticulum; +//import org.qortal.network.RNSNetwork; import io.reticulum.link.Link; import io.reticulum.link.RequestReceipt; import io.reticulum.packet.PacketReceiptStatus; @@ -27,73 +29,203 @@ import static io.reticulum.link.TeardownSession.INITIATOR_CLOSED; import static io.reticulum.link.TeardownSession.DESTINATION_CLOSED; import static io.reticulum.link.TeardownSession.TIMEOUT; import static io.reticulum.link.LinkStatus.ACTIVE; -import static io.reticulum.link.LinkStatus.CLOSED; +//import static io.reticulum.link.LinkStatus.CLOSED; import static io.reticulum.identity.IdentityKnownDestination.recall; //import static io.reticulum.identity.IdentityKnownDestination.recallAppData; +import io.reticulum.buffer.Buffer; +import io.reticulum.buffer.BufferedRWPair; +import static io.reticulum.utils.IdentityUtils.concatArrays; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockSummaryData; +import org.qortal.data.block.CommonBlockData; +import org.qortal.data.network.RNSPeerData; +import org.qortal.network.message.Message; +import org.qortal.network.message.PingMessage; +import org.qortal.network.message.*; +import org.qortal.network.message.MessageException; +import org.qortal.network.task.RNSMessageTask; +import org.qortal.network.task.RNSPingTask; +import org.qortal.settings.Settings; +import org.qortal.utils.ExecuteProduceConsume.Task; +import org.qortal.utils.NTP; + +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.*; +import java.util.Arrays; + import static java.nio.charset.StandardCharsets.UTF_8; -import org.apache.commons.codec.binary.Hex; +import static org.apache.commons.codec.binary.Hex.encodeHexString; import static org.apache.commons.lang3.ArrayUtils.subarray; +import static org.apache.commons.lang3.BooleanUtils.isFalse; +import static org.apache.commons.lang3.BooleanUtils.isTrue; import lombok.extern.slf4j.Slf4j; import lombok.Setter; import lombok.Data; import lombok.AccessLevel; +//import lombok.Synchronized; +// +//import org.qortal.network.message.Message; +//import org.qortal.network.message.MessageException; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import java.lang.IllegalStateException; @Data @Slf4j public class RNSPeer { - //static final String APP_NAME = "qortal"; - static final String APP_NAME = RNSCommon.APP_NAME; + static final String APP_NAME = Settings.getInstance().isTestNet() ? RNSCommon.TESTNET_APP_NAME: RNSCommon.MAINNET_APP_NAME; //static final String defaultConfigPath = new String(".reticulum"); - static final String defaultConfigPath = RNSCommon.defaultRNSConfigPath; + //static final String defaultConfigPath = RNSCommon.defaultRNSConfigPath; private byte[] destinationHash; // remote destination hash Destination peerDestination; // OUT destination created for this private Identity serverIdentity; @Setter(AccessLevel.PACKAGE) private Instant creationTimestamp; - private Instant lastAccessTimestamp; + @Setter(AccessLevel.PACKAGE) private Instant lastAccessTimestamp; + @Setter(AccessLevel.PACKAGE) private Instant lastLinkProbeTimestamp; Link peerLink; + byte[] peerLinkHash; + BufferedRWPair peerBuffer; + int receiveStreamId = 0; + int sendStreamId = 0; private Boolean isInitiator; private Boolean deleteMe = false; + private Boolean isVacant = true; + private Long lastPacketRtt = null; + private byte[] emptyBuffer = {0,0,0,0,0}; private Double requestResponseProgress; @Setter(AccessLevel.PACKAGE) private Boolean peerTimedOut = false; + // for qortal networking + private static final int RESPONSE_TIMEOUT = 3000; // [ms] + private static final int PING_INTERVAL = 34_000; // [ms] + private static final long LINK_PING_INTERVAL = 34 * 1000L; // ms + private byte[] messageMagic; // set in message creating classes + private Long lastPing = null; // last (packet) ping roundtrip time [ms] + private Long lastPingSent = null; // time last (packet) ping was sent, or null if not started. + @Setter(AccessLevel.PACKAGE) private Instant lastPingResponseReceived = null; // time last (packet) ping succeeded + private Map> replyQueues; + private LinkedBlockingQueue pendingMessages; + // Versioning + public static final Pattern VERSION_PATTERN = Pattern.compile(Controller.VERSION_PREFIX + + "(\\d{1,3})\\.(\\d{1,5})\\.(\\d{1,5})"); + + private RNSPeerData peerData = null; + /** + * Latest block info as reported by peer. + */ + private List peersChainTipData = Collections.emptyList(); + /** + * Our common block with this peer + */ + private CommonBlockData commonBlockData; + + + /** + * Constructor for initiator peers + */ public RNSPeer(byte[] dhash) { - destinationHash = dhash; - serverIdentity = recall(dhash); + this.destinationHash = dhash; + this.serverIdentity = recall(dhash); initPeerLink(); //setCreationTimestamp(System.currentTimeMillis()); - creationTimestamp = Instant.now(); + this.creationTimestamp = Instant.now(); + this.isVacant = true; + this.replyQueues = new ConcurrentHashMap<>(); + this.pendingMessages = new LinkedBlockingQueue<>(); + this.peerData = new RNSPeerData(dhash); } + /** + * Constructor for non-initiator peers + */ + public RNSPeer(Link link) { + this.peerLink = link; + //this.peerLinkId = link.getLinkId(); + this.peerDestination = link.getDestination(); + this.destinationHash = link.getDestination().getHash(); + this.serverIdentity = link.getRemoteIdentity(); + + this.creationTimestamp = Instant.now(); + this.lastAccessTimestamp = Instant.now(); + this.lastLinkProbeTimestamp = null; + this.isInitiator = false; + this.isVacant = false; + + //this.peerLink.setLinkEstablishedCallback(this::linkEstablished); + //this.peerLink.setLinkClosedCallback(this::linkClosed); + //this.peerLink.setPacketCallback(this::linkPacketReceived); + this.peerData = new RNSPeerData(this.destinationHash); + } public void initPeerLink() { peerDestination = new Destination( this.serverIdentity, Direction.OUT, DestinationType.SINGLE, - RNSNetwork.APP_NAME, + APP_NAME, "core" ); peerDestination.setProofStrategy(ProofStrategy.PROVE_ALL); - lastAccessTimestamp = Instant.now(); - isInitiator = true; + this.creationTimestamp = Instant.now(); + this.lastAccessTimestamp = Instant.now(); + this.lastLinkProbeTimestamp = null; + this.isInitiator = true; - peerLink = new Link(peerDestination); + this.peerLink = new Link(peerDestination); this.peerLink.setLinkEstablishedCallback(this::linkEstablished); this.peerLink.setLinkClosedCallback(this::linkClosed); this.peerLink.setPacketCallback(this::linkPacketReceived); } + @Override + public String toString() { + // for messages we want an address-like string representation + if (nonNull(this.peerLink)) { + return this.getPeerLink().toString(); + } else { + return encodeHexString(this.getDestinationHash()); + } + } + + public BufferedRWPair getOrInitPeerBuffer() { + var channel = this.peerLink.getChannel(); + if (nonNull(this.peerBuffer)) { + log.info("peerBuffer exists: {}, link status: {}", this.peerBuffer, this.peerLink.getStatus()); + try { + log.trace("peerBuffer exists: {}, link status: {}", this.peerBuffer, this.peerLink.getStatus()); + } catch (IllegalStateException e) { + // Exception thrown by Reticulum if the buffer is unusable (Channel, Link, etc) + // This is a chance to correct links status when doing a RNSPingTask + log.warn("can't establish Channel/Buffer (remote peer down?), closing link: {}"); + this.peerBuffer.close(); + this.peerLink.teardown(); + this.peerLink = null; + //log.error("(handled) IllegalStateException - can't establish Channel/Buffer: {}", e); + } + } + else { + log.info("creating buffer - peerLink status: {}, channel: {}", this.peerLink.getStatus(), channel); + this.peerBuffer = Buffer.createBidirectionalBuffer(receiveStreamId, sendStreamId, channel, this::peerBufferReady); + } + //return getPeerBuffer(); + return this.peerBuffer; + } + public Link getOrInitPeerLink() { if (this.peerLink.getStatus() == ACTIVE) { lastAccessTimestamp = Instant.now(); - return this.peerLink; + //return this.peerLink; } else { initPeerLink(); } @@ -101,10 +233,16 @@ public class RNSPeer { } public void shutdown() { - if (nonNull(peerLink)) { + if (nonNull(this.peerLink)) { log.info("shutdown - peerLink: {}, status: {}", peerLink, peerLink.getStatus()); if (peerLink.getStatus() == ACTIVE) { - peerLink.teardown(); + if (nonNull(this.peerBuffer)) { + this.peerBuffer.close(); + this.peerBuffer = null; + } + this.peerLink.teardown(); + } else { + log.info("shutdown - status (non-ACTIVE): {}", peerLink.getStatus()); } this.peerLink = null; } @@ -120,26 +258,36 @@ public class RNSPeer { return getPeerLink().getChannel(); } + public Boolean getIsInitiator() { + return this.isInitiator; + } + /** Link callbacks */ public void linkEstablished(Link link) { link.setLinkClosedCallback(this::linkClosed); log.info("peerLink {} established (link: {}) with peer: hash - {}, link destination hash: {}", - peerLink, link, Hex.encodeHexString(destinationHash), - Hex.encodeHexString(link.getDestination().getHash())); + peerLink, link, encodeHexString(destinationHash), + encodeHexString(link.getDestination().getHash())); + if (isInitiator) { + startPings(); + } } public void linkClosed(Link link) { if (link.getTeardownReason() == TIMEOUT) { log.info("The link timed out"); this.peerTimedOut = true; + this.peerBuffer = null; } else if (link.getTeardownReason() == INITIATOR_CLOSED) { log.info("Link closed callback: The initiator closed the link"); log.info("peerLink {} closed (link: {}), link destination hash: {}", - peerLink, link, Hex.encodeHexString(link.getDestination().getHash())); + peerLink, link, encodeHexString(link.getDestination().getHash())); + this.peerBuffer = null; } else if (link.getTeardownReason() == DESTINATION_CLOSED) { log.info("Link closed callback: The link was closed by the peer, removing peer"); log.info("peerLink {} closed (link: {}), link destination hash: {}", - peerLink, link, Hex.encodeHexString(link.getDestination().getHash())); + peerLink, link, encodeHexString(link.getDestination().getHash())); + this.peerBuffer = null; } else { log.info("Link closed callback"); } @@ -149,44 +297,155 @@ public class RNSPeer { var msgText = new String(message, StandardCharsets.UTF_8); if (msgText.equals("ping")) { log.info("received ping on link"); + this.lastLinkProbeTimestamp = Instant.now(); } else if (msgText.startsWith("close::")) { var targetPeerHash = subarray(message, 7, message.length); log.info("peer dest hash: {}, target hash: {}", - Hex.encodeHexString(destinationHash), - Hex.encodeHexString(targetPeerHash)); + encodeHexString(destinationHash), + encodeHexString(targetPeerHash)); if (Arrays.equals(destinationHash, targetPeerHash)) { log.info("closing link: {}", peerLink.getDestination().getHexHash()); - peerLink.teardown(); + if (nonNull(this.peerBuffer)) { + this.peerBuffer.close(); + this.peerBuffer = null; + } + this.peerLink.teardown(); } } else if (msgText.startsWith("open::")) { var targetPeerHash = subarray(message, 7, message.length); log.info("peer dest hash: {}, target hash: {}", - Hex.encodeHexString(destinationHash), - Hex.encodeHexString(targetPeerHash)); + encodeHexString(destinationHash), + encodeHexString(targetPeerHash)); if (Arrays.equals(destinationHash, targetPeerHash)) { log.info("closing link: {}", peerLink.getDestination().getHexHash()); getOrInitPeerLink(); } } - // TODO: process incoming packet.... } + /* + * Callback from buffer when buffer has data available + * + * :param readyBytes: The number of bytes ready to read + */ + public void peerBufferReady(Integer readyBytes) { + // get the message data + byte[] data = this.peerBuffer.read(readyBytes); + ByteBuffer bb = ByteBuffer.wrap(data); + //log.info("data length: {}, MAGIC: {}, data: {}, ByteBuffer: {}", data.length, this.messageMagic, data, bb); + //log.info("data length: {}, MAGIC: {}, ByteBuffer: {}", data.length, this.messageMagic, bb); + //log.trace("peerBufferReady - data bytes: {}", data.length); + this.lastAccessTimestamp = Instant.now(); + + if (ByteBuffer.wrap(data, 0, emptyBuffer.length).equals(ByteBuffer.wrap(emptyBuffer, 0, emptyBuffer.length))) { + log.info("peerBufferReady - empty buffer detected (length: {})", data.length); + } + else { + try { + //log.info("***> creating message from {} bytes", data.length); + Message message = Message.fromByteBuffer(bb); + //log.info("*=> type {} message received ({} bytes): {}", message.getType(), data.length, message); + log.info("*=> type {} message received ({} bytes)", message.getType(), data.length); + // Handle message based on type + switch (message.getType()) { + // Do we need this ? (seems like a TCP scenario only thing) + // Does any RNSPeer ever require an other RNSPeer's peer list? + //case GET_PEERS: + // //onGetPeersMessage(peer, message); + // onGetRNSPeersMessage(peer, message); + // break; + + case PING: + this.lastPingResponseReceived = Instant.now(); + if (isFalse(this.isInitiator)) { + onPingMessage(this, message); + // Note: buffer flush done in onPingMessage method + } + break; + + case PONG: + //log.info("PONG received"); + break; + + // Do we need this ? (no need to relay peer list...) + //case PEERS_V2: + // onPeersV2Message(peer, message); + // break; + + default: + log.info("default - type {} message received ({} bytes)", message.getType(), data.length); + // Bump up to controller for possible action + //Controller.getInstance().onNetworkMessage(peer, message); + Controller.getInstance().onRNSNetworkMessage(this, message); + break; + } + } catch (MessageException e) { + //log.error("{} from peer {}", e.getMessage(), this); + log.error("{} from peer {}", e, this); + log.info("{} from peer {}", e, this); + } + } + } + + //public void handleMessage(Message message) { + // + //} + + /** + * Set a packet to remote with the message format "close::" + * This method is only useful for non-initiator links to close the remote initiator. + * + * @param link + */ + public void sendCloseToRemote(Link link) { + var baseDestination = RNSNetwork.getInstance().getBaseDestination(); + if (nonNull(link) & (isFalse(link.isInitiator()))) { + // Note: if part of link we need to get the baseDesitination hash + //var data = concatArrays("close::".getBytes(UTF_8),link.getDestination().getHash()); + var data = concatArrays("close::".getBytes(UTF_8), baseDestination.getHash()); + Packet closePacket = new Packet(link, data); + var packetReceipt = closePacket.send(); + packetReceipt.setDeliveryCallback(this::closePacketDelivered); + packetReceipt.setTimeout(1000L); + packetReceipt.setTimeoutCallback(this::packetTimedOut); + } else { + log.debug("can't send to null link"); + } + } /** PacketReceipt callbacks */ - public void packetDelivered(PacketReceipt receipt) { + public void closePacketDelivered(PacketReceipt receipt) { var rttString = new String(""); - //log.info("packet delivered callback, receipt: {}", receipt); if (receipt.getStatus() == PacketReceiptStatus.DELIVERED) { - var rtt = receipt.getRtt(); // rtt (Java) is in miliseconds - //log.info("qqp - packetDelivered - rtt: {}", rtt); + var rtt = receipt.getRtt(); // rtt (Java) is in milliseconds + this.lastPacketRtt = rtt; if (rtt >= 1000) { rtt = Math.round(rtt / 1000); rttString = String.format("%d seconds", rtt); } else { rttString = String.format("%d miliseconds", rtt); } + log.info("Shutdown packet confirmation received from {}, round-trip time is {}", + encodeHexString(receipt.getDestination().getHash()), rttString); + } + } + + public void packetDelivered(PacketReceipt receipt) { + var rttString = ""; + //log.info("packet delivered callback, receipt: {}", receipt); + if (receipt.getStatus() == PacketReceiptStatus.DELIVERED) { + var rtt = receipt.getRtt(); // rtt (Java) is in milliseconds + this.lastPacketRtt = rtt; + //log.info("qqp - packetDelivered - rtt: {}", rtt); + if (rtt >= 1000) { + rtt = Math.round((float) rtt / 1000); + rttString = String.format("%d seconds", rtt); + } else { + rttString = String.format("%d milliseconds", rtt); + } log.info("Valid reply received from {}, round-trip time is {}", - Hex.encodeHexString(receipt.getDestination().getHash()), rttString); + encodeHexString(receipt.getDestination().getHash()), rttString); + this.lastAccessTimestamp = Instant.now(); } } @@ -221,27 +480,42 @@ public class RNSPeer { public void linkResourceTransferStarted(Resource resource) { log.debug("Resource transfer started"); } - public void linkResourceTransferComcluded(Resource resource) { + public void linkResourceTransferConcluded(Resource resource) { log.debug("Resource transfer complete"); } + ///** + // * Send a message using the peer buffer + // */ + //public Message getResponse(Message message) throws InterruptedException { + // var peerBuffer = getOrInitPeerBuffer(); + // + // //// send message + // //peerBuffer.write(...); + // //peerBuffer.flush(); + // + // // receive - peerBufferReady callback result + //} + /** Utility methods */ public void pingRemote() { var link = this.peerLink; + //if (nonNull(link) & (isFalse(link.isInitiator()))) { + //if (nonNull(link) & link.isInitiator()) { if (nonNull(link)) { if (peerLink.getStatus() == ACTIVE) { - log.info("pinging remote: {}", link); + log.info("pinging remote (direct, 1 packet): {}", link); var data = "ping".getBytes(UTF_8); link.setPacketCallback(this::linkPacketReceived); Packet pingPacket = new Packet(link, data); PacketReceipt packetReceipt = pingPacket.send(); + packetReceipt.setDeliveryCallback(this::packetDelivered); // Note: don't setTimeout, we want it to timeout with FAIL if not deliverable //packetReceipt.setTimeout(5000L); packetReceipt.setTimeoutCallback(this::packetTimedOut); - packetReceipt.setDeliveryCallback(this::packetDelivered); } else { log.info("can't send ping to a peer {} with (link) status: {}", - Hex.encodeHexString(peerLink.getDestination().getHash()), peerLink.getStatus()); + encodeHexString(peerLink.getDestination().getHash()), peerLink.getStatus()); } } } @@ -255,30 +529,240 @@ public class RNSPeer { // packetReceipt.setDeliveryCallback(this::shutdownPacketDelivered); //} - ///** check if a link is available (ACTIVE) - // * link: a certain peer link, or null (default link == link to Qortal node RNS baseDestination) - // */ - //public Boolean peerLinkIsAlive(Link link) { - // var result = false; - // if (isNull(link)) { - // // default link - // var defaultLink = getLink(); - // if (nonNull(defaultLink) && defaultLink.getStatus() == ACTIVE) { - // result = true; - // log.info("Default link is available"); - // } else { - // log.info("Default link {} is not available, status: {}", defaultLink, defaultLink.getStatus()); - // } - // } else { - // // other link (future where we have multiple destinations...) - // if (link.getStatus() == ACTIVE) { - // result = true; - // log.info("Link {} is available (status: {})", link, link.getStatus()); - // } else { - // log.info("Link {} is not available, status: {}", link, link.getStatus()); - // } - // } - // return result; - //} + /** qortal networking specific (Tasks) */ + + private void onPingMessage(RNSPeer peer, Message message) { + PingMessage pingMessage = (PingMessage) message; -} \ No newline at end of file + try { + PongMessage pongMessage = new PongMessage(); + pongMessage.setId(message.getId()); // use the ping message id + this.peerBuffer.write(pongMessage.toBytes()); + this.peerBuffer.flush(); + this.lastAccessTimestamp = Instant.now(); + } catch (MessageException e) { + //log.error("{} from peer {}", e.getMessage(), this); + log.error("{} from peer {}", e, this); + } + } + + /** + * Send message to peer and await response, using default RESPONSE_TIMEOUT. + *

+ * Message is assigned a random ID and sent. + * Responses are handled by registered callbacks. + *

+ * Note: The method is called "get..." to match the original method name + * + * @param message message to send + * @return Message if valid response received; null if not or error/exception occurs + * @throws InterruptedException if interrupted while waiting + */ + public void getResponse(Message message) throws InterruptedException { + log.info("RNSPingTask action - pinging peer {}", encodeHexString(getDestinationHash())); + getResponseWithTimeout(message, RESPONSE_TIMEOUT); + } + + /** + * Send message to peer and await response. + *

+ * Message is assigned a random ID and sent. + * If a response with matching ID is received then it is returned to caller. + *

+ * If no response with matching ID within timeout, or some other error/exception occurs, + * then return null.
+ * (Assume peer will be rapidly disconnected after this). + * + * @param message message to send + * @return Message if valid response received; null if not or error/exception occurs + * @throws InterruptedException if interrupted while waiting + */ + public void getResponseWithTimeout(Message message, int timeout) throws InterruptedException { + BlockingQueue blockingQueue = new ArrayBlockingQueue<>(1); + // TODO: implement equivalent of Peer class... + // Assign random ID to this message + Random random = new Random(); + int id; + do { + id = random.nextInt(Integer.MAX_VALUE - 1) + 1; + + // Put queue into map (keyed by message ID) so we can poll for a response + // If putIfAbsent() doesn't return null, then this ID is already taken + } while (this.replyQueues.putIfAbsent(id, blockingQueue) != null); + message.setId(id); + + // Try to send message + if (!this.sendMessageWithTimeout(message, timeout)) { + this.replyQueues.remove(id); + return; + } + + try { + blockingQueue.poll(timeout, TimeUnit.MILLISECONDS); + } finally { + this.replyQueues.remove(id); + } + } + + /** + * Attempt to send Message to peer using the buffer and a custom timeout. + * + * @param message message to be sent + * @return true if message successfully sent; false otherwise + */ + public boolean sendMessageWithTimeout(Message message, int timeout) { + try { + log.trace("Sending {} message with ID {} to peer {}", message.getType().name(), message.getId(), this); + var peerBuffer = getOrInitPeerBuffer(); + this.peerBuffer.write(message.toBytes()); + this.peerBuffer.flush(); + return true; + //} catch (InterruptedException e) { + // // Send failure + // return false; + } catch (IllegalStateException e) { + //log.warn("Can't write to buffer (remote buffer down?)"); + this.peerLink.teardown(); + this.peerBuffer = null; + log.error("IllegalStateException - can't write to buffer: {}", e); + return false; + } catch (MessageException e) { + log.error(e.getMessage(), e); + return false; + } + } + + protected Task getMessageTask() { + /* + * If our peerLink is not in ACTIVE node and there is a message yet to be + * processed then don't produce another message task. + * This allows us to process remaining messages sequentially. + */ + if (this.peerLink.getStatus() != ACTIVE) { + return null; + } + + final Message nextMessage = this.pendingMessages.poll(); + + if (nextMessage == null) { + return null; + } + + // Return a task to process message in queue + return new RNSMessageTask(this, nextMessage); + } + + /** + * Send a Qortal message using a Reticulum Buffer + * + * @param message message to be sent + * @return true if message successfully sent; false otherwise + */ + //@Synchronized + public boolean sendMessage(Message message) { + try { + log.trace("Sending {} message with ID {} to peer {}", message.getType().name(), message.getId(), this); + log.info("Sending {} message with ID {} to peer {}", message.getType().name(), message.getId(), this); + var peerBuffer = getOrInitPeerBuffer(); + peerBuffer.write(message.toBytes()); + peerBuffer.flush(); + return true; + } catch (IllegalStateException e) { + this.peerLink.teardown(); + this.peerBuffer = null; + log.error("IllegalStateException - can't write to buffer: {}", e); + return false; + } catch (MessageException e) { + log.error(e.getMessage(), e); + return false; + } + } + + protected void startPings() { + log.trace("[{}] Enabling pings for peer {}", + peerLink.getDestination().getHexHash(), this); + this.lastPingSent = NTP.getTime(); + } + + protected Task getPingTask(Long now) { + // Pings not enabled yet? + if (now == null || this.lastPingSent == null) { + return null; + } + + // ping only possible over ACTIVE Link + if (nonNull(this.peerLink)) { + if (this.peerLink.getStatus() != ACTIVE) { + return null; + } + } else { + return null; + } + + // Time to send another ping? + if (now < this.lastPingSent + PING_INTERVAL) { + return null; // Not yet + } + + // Not strictly true, but prevents this peer from being immediately chosen again + this.lastPingSent = now; + + return new RNSPingTask(this, now); + } + + // low-level Link (packet) ping + protected Link getPingLinks(Long now) { + if (now == null || this.lastPingSent == null) { + return null; + } + + // ping only possible over ACTIVE link + if (nonNull(this.peerLink)) { + if (this.peerLink.getStatus() != ACTIVE) { + return null; + } + } else { + return null; + } + + if (now < this.lastPingSent + LINK_PING_INTERVAL) { + return null; + } + + this.lastPingSent = now; + + return this.peerLink; + + } + + // Peer methods reticulum implementations + public BlockSummaryData getChainTipData() { + List chainTipSummaries = this.peersChainTipData; + + if (chainTipSummaries.isEmpty()) + return null; + + // Return last entry, which should have greatest height + return chainTipSummaries.get(chainTipSummaries.size() - 1); + } + + public void setChainTipData(BlockSummaryData chainTipData) { + this.peersChainTipData = Collections.singletonList(chainTipData); + } + + public List getChainTipSummaries() { + return this.peersChainTipData; + } + + public void setChainTipSummaries(List chainTipSummaries) { + this.peersChainTipData = List.copyOf(chainTipSummaries); + } + + public CommonBlockData getCommonBlockData() { + return this.commonBlockData; + } + + public void setCommonBlockData(CommonBlockData commonBlockData) { + this.commonBlockData = commonBlockData; + } +} diff --git a/src/main/java/org/qortal/network/RNSPrunePeersTask.java b/src/main/java/org/qortal/network/RNSPrunePeersTask.java new file mode 100644 index 00000000..f0da3ecc --- /dev/null +++ b/src/main/java/org/qortal/network/RNSPrunePeersTask.java @@ -0,0 +1,27 @@ +package org.qortal.network.task; + +import org.qortal.controller.Controller; +//import org.qortal.network.RNSNetwork; +//import org.qortal.repository.DataException; +import org.qortal.utils.ExecuteProduceConsume.Task; + +public class RNSPrunePeersTask implements Task { + public RNSPrunePeersTask() { + } + + @Override + public String getName() { + return "PrunePeersTask"; + } + + @Override + public void perform() throws InterruptedException { + Controller.getInstance().doRNSPrunePeers(); + //try { + // log.debug("Pruning peers..."); + // RNSNetwork.getInstance().prunePeers(); + //} catch (DataException e) { + // log.warn(String.format("Repository issue when trying to prune peers: %s", e.getMessage())); + //} + } +} diff --git a/src/main/java/org/qortal/network/message/Message.java b/src/main/java/org/qortal/network/message/Message.java index 65262321..df797be8 100644 --- a/src/main/java/org/qortal/network/message/Message.java +++ b/src/main/java/org/qortal/network/message/Message.java @@ -9,6 +9,8 @@ import java.io.IOException; import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.util.Arrays; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; /** * Network message for sending over network, or unpacked data received from network. @@ -33,6 +35,7 @@ import java.util.Arrays; *

*/ public abstract class Message { + private static final Logger LOGGER = LogManager.getLogger(Message.class); // MAGIC(4) + TYPE(4) + HAS-ID(1) + ID?(4) + DATA-SIZE(4) + CHECKSUM?(4) + DATA?(*) private static final int MAGIC_LENGTH = 4; @@ -95,9 +98,11 @@ public abstract class Message { byte[] messageMagic = new byte[MAGIC_LENGTH]; readOnlyBuffer.get(messageMagic); - if (!Arrays.equals(messageMagic, Network.getInstance().getMessageMagic())) + if (!Arrays.equals(messageMagic, Network.getInstance().getMessageMagic())) { + LOGGER.info("xyz - mM: {}, Network getMessageMagic: {}", messageMagic, Network.getInstance().getMessageMagic()); // Didn't receive correct Message "magic" throw new MessageException("Received incorrect message 'magic'"); + } // Find supporting object int typeValue = readOnlyBuffer.getInt(); diff --git a/src/main/java/org/qortal/network/task/PeerConnectTask.java b/src/main/java/org/qortal/network/task/PeerConnectTask.java index 7eec4e6b..3ae1b640 100644 --- a/src/main/java/org/qortal/network/task/PeerConnectTask.java +++ b/src/main/java/org/qortal/network/task/PeerConnectTask.java @@ -4,10 +4,15 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.network.Network; import org.qortal.network.Peer; +import org.qortal.utils.DaemonThreadFactory; import org.qortal.utils.ExecuteProduceConsume.Task; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + public class PeerConnectTask implements Task { private static final Logger LOGGER = LogManager.getLogger(PeerConnectTask.class); + private static final ExecutorService connectionExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory(8)); private final Peer peer; private final String name; @@ -24,6 +29,24 @@ public class PeerConnectTask implements Task { @Override public void perform() throws InterruptedException { - Network.getInstance().connectPeer(peer); + // Submit connection task to a dedicated thread pool for non-blocking I/O + connectionExecutor.submit(() -> { + try { + connectPeerAsync(peer); + } catch (InterruptedException e) { + LOGGER.error("Connection attempt interrupted for peer {}", peer, e); + Thread.currentThread().interrupt(); // Reset interrupt flag + } + }); + } + + private void connectPeerAsync(Peer peer) throws InterruptedException { + // Perform peer connection in a separate thread to avoid blocking main task execution + try { + Network.getInstance().connectPeer(peer); + LOGGER.trace("Successfully connected to peer {}", peer); + } catch (Exception e) { + LOGGER.error("Error connecting to peer {}", peer, e); + } } } diff --git a/src/main/java/org/qortal/network/task/RNSBroadcastTask.java b/src/main/java/org/qortal/network/task/RNSBroadcastTask.java new file mode 100644 index 00000000..116cdf9d --- /dev/null +++ b/src/main/java/org/qortal/network/task/RNSBroadcastTask.java @@ -0,0 +1,19 @@ +package org.qortal.network.task; + +import org.qortal.controller.Controller; +import org.qortal.utils.ExecuteProduceConsume.Task; + +public class RNSBroadcastTask implements Task { + public RNSBroadcastTask() { + } + + @Override + public String getName() { + return "BroadcastTask"; + } + + @Override + public void perform() throws InterruptedException { + Controller.getInstance().doRNSNetworkBroadcast(); + } +} diff --git a/src/main/java/org/qortal/network/task/RNSMessageTask.java b/src/main/java/org/qortal/network/task/RNSMessageTask.java new file mode 100644 index 00000000..9b4ea56f --- /dev/null +++ b/src/main/java/org/qortal/network/task/RNSMessageTask.java @@ -0,0 +1,30 @@ +package org.qortal.network.task; + +import org.qortal.network.RNSNetwork; +import org.qortal.network.RNSPeer; +import org.qortal.network.message.Message; +import org.qortal.utils.ExecuteProduceConsume.Task; + +public class RNSMessageTask implements Task { + private final RNSPeer peer; + private final Message nextMessage; + private final String name; + + public RNSMessageTask(RNSPeer peer, Message nextMessage) { + this.peer = peer; + this.nextMessage = nextMessage; + this.name = "MessageTask::" + peer + "::" + nextMessage.getType(); + } + + @Override + public String getName() { + return name; + } + + @Override + public void perform() throws InterruptedException { + //RNSNetwork.getInstance().onMessage(peer, nextMessage); + // TODO: what do we do in the Reticulum case? + // Note: this is automatically handled (asynchronously) by the RNSPeer peerBufferReady callback + } +} diff --git a/src/main/java/org/qortal/network/task/RNSPingTask.java b/src/main/java/org/qortal/network/task/RNSPingTask.java new file mode 100644 index 00000000..acef59a6 --- /dev/null +++ b/src/main/java/org/qortal/network/task/RNSPingTask.java @@ -0,0 +1,55 @@ +package org.qortal.network.task; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.network.RNSPeer; +import org.qortal.network.message.Message; +import org.qortal.network.message.MessageType; +import org.qortal.network.message.PingMessage; +//import org.qortal.network.message.RNSPingMessage; +import org.qortal.network.message.MessageException; +import org.qortal.utils.ExecuteProduceConsume.Task; +import org.qortal.utils.NTP; + +public class RNSPingTask implements Task { + private static final Logger LOGGER = LogManager.getLogger(PingTask.class); + + private final RNSPeer peer; + private final Long now; + private final String name; + + public RNSPingTask(RNSPeer peer, Long now) { + this.peer = peer; + this.now = now; + this.name = "PingTask::" + peer; + } + + @Override + public String getName() { + return name; + } + + @Override + public void perform() throws InterruptedException { + //RNSPingMessage pingMessage = new RNSPingMessage(); + PingMessage pingMessage = new PingMessage(); + + //try { + // var peerBuffer = this.peer.getOrInitPeerBuffer(); + // LOGGER.info("message toBytes: {}", pingMessage.toBytes()); + // peerBuffer.write(pingMessage.toBytes()); + // peerBuffer.flush(); + //} catch (IllegalStateException e) { + // //log.warn("Can't write to buffer (remote buffer down?)"); + // LOGGER.error("IllegalStateException - can't write to buffer: e", e); + //} catch (MessageException e) { + // LOGGER.error(e.getMessage(), e); + //} + // Note: We might use peer.sendMessage(pingMessage) instead + //peer.getResponse(pingMessage); + peer.sendMessage(pingMessage); + + //// task is not over here (Reticulum is asynchronous) + //peer.setLastPing(NTP.getTime() - now); + } +} diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 455ba393..2b653ab5 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -76,9 +76,9 @@ public interface ATRepository { * Although expectedValue, if provided, is natively an unsigned long, * the data segment comparison is done via unsigned hex string. */ - public List getMatchingFinalATStates(byte[] codeHash, Boolean isFinished, - Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, - Integer limit, Integer offset, Boolean reverse) throws DataException; + public List getMatchingFinalATStates(byte[] codeHash, byte[] buyerPublicKey, byte[] sellerPublicKey, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, + Integer limit, Integer offset, Boolean reverse) throws DataException; /** * Returns final ATStateData for ATs matching codeHash (required) diff --git a/src/main/java/org/qortal/repository/AccountRepository.java b/src/main/java/org/qortal/repository/AccountRepository.java index bdad187b..f68fe8eb 100644 --- a/src/main/java/org/qortal/repository/AccountRepository.java +++ b/src/main/java/org/qortal/repository/AccountRepository.java @@ -3,7 +3,9 @@ package org.qortal.repository; import org.qortal.data.account.*; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.function.Function; public interface AccountRepository { @@ -131,7 +133,42 @@ public interface AccountRepository { /** Returns all account balances for given assetID, optionally excluding zero balances. */ public List getAssetBalances(long assetId, Boolean excludeZero) throws DataException; - /** How to order results when fetching asset balances. */ + public SponsorshipReport getSponsorshipReport(String address, String[] realRewardShareRecipients) throws DataException; + + /** + * Get Sponsorship Report + * + * @param address the account address + * @param addressFetcher fetches the addresses that this method will aggregate + * @return the report + * @throws DataException + */ + public SponsorshipReport getMintershipReport(String address, Function> addressFetcher) throws DataException; + + /** + * Get Sponsee Addresses + * + * @param account the sponsor's account address + * @param realRewardShareRecipients the recipients that get real reward shares, not sponsorship + * @return the sponsee addresses + * @throws DataException + */ + public List getSponseeAddresses(String account, String[] realRewardShareRecipients) throws DataException; + + /** + * Get Sponsor + * + * @param address the address of the account + * + * @return the address of accounts sponsor, empty if not sponsored + * + * @throws DataException + */ + public Optional getSponsor(String address) throws DataException; + + public List getAddressLevelPairings(int minLevel) throws DataException; + + /** How to order results when fetching asset balances. */ public enum BalanceOrdering { /** assetID first, then balance, then account address */ ASSET_BALANCE_ACCOUNT, diff --git a/src/main/java/org/qortal/repository/ArbitraryRepository.java b/src/main/java/org/qortal/repository/ArbitraryRepository.java index 175f1daf..4770d29b 100644 --- a/src/main/java/org/qortal/repository/ArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/ArbitraryRepository.java @@ -27,6 +27,10 @@ public interface ArbitraryRepository { public List getArbitraryTransactions(String name, Service service, String identifier, long since) throws DataException; + List getLatestArbitraryTransactions() throws DataException; + + List getLatestArbitraryTransactionsByName(String name) throws DataException; + public ArbitraryTransactionData getInitialTransaction(String name, Service service, Method method, String identifier) throws DataException; public ArbitraryTransactionData getLatestTransaction(String name, Service service, Method method, String identifier) throws DataException; @@ -42,8 +46,19 @@ public interface ArbitraryRepository { public List getArbitraryResources(Service service, String identifier, List names, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Boolean includeMetadata, Boolean includeStatus, Integer limit, Integer offset, Boolean reverse) throws DataException; - public List searchArbitraryResources(Service service, String query, String identifier, List names, String title, String description, boolean prefixOnly, List namesFilter, boolean defaultResource, SearchMode mode, Integer minLevel, Boolean followedOnly, Boolean excludeBlocked, Boolean includeMetadata, Boolean includeStatus, Long before, Long after, Integer limit, Integer offset, Boolean reverse) throws DataException; + public List searchArbitraryResources(Service service, String query, String identifier, List names, String title, String description, List keywords, boolean prefixOnly, List namesFilter, boolean defaultResource, SearchMode mode, Integer minLevel, Boolean followedOnly, Boolean excludeBlocked, Boolean includeMetadata, Boolean includeStatus, Long before, Long after, Integer limit, Integer offset, Boolean reverse) throws DataException; + List searchArbitraryResourcesSimple( + Service service, + String identifier, + List names, + boolean prefixOnly, + Long before, + Long after, + Integer limit, + Integer offset, + Boolean reverse, + Boolean caseInsensitive) throws DataException; // Arbitrary resources cache save/load diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index e47aabbd..901dab89 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -153,13 +153,16 @@ public class BlockArchiveWriter { int i = 0; while (headerBytes.size() + bytes.size() < this.fileSizeTarget) { + // pause, since this can be a long process and other processes need to execute + Thread.sleep(Settings.getInstance().getArchivingPause()); + if (Controller.isStopping()) { return BlockArchiveWriteResult.STOPPING; } - if (Synchronizer.getInstance().isSynchronizing()) { - Thread.sleep(1000L); + + // wait until the Synchronizer stops + if( Synchronizer.getInstance().isSynchronizing() ) continue; - } int currentHeight = startHeight + i; if (currentHeight > endHeight) { diff --git a/src/main/java/org/qortal/repository/ChatRepository.java b/src/main/java/org/qortal/repository/ChatRepository.java index d046fe6b..bd636fe3 100644 --- a/src/main/java/org/qortal/repository/ChatRepository.java +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -22,6 +22,6 @@ public interface ChatRepository { public ChatMessage toChatMessage(ChatTransactionData chatTransactionData, Encoding encoding) throws DataException; - public ActiveChats getActiveChats(String address, Encoding encoding) throws DataException; + public ActiveChats getActiveChats(String address, Encoding encoding, Boolean hasChatReference) throws DataException; } diff --git a/src/main/java/org/qortal/repository/GroupRepository.java b/src/main/java/org/qortal/repository/GroupRepository.java index 49427b02..4e39333c 100644 --- a/src/main/java/org/qortal/repository/GroupRepository.java +++ b/src/main/java/org/qortal/repository/GroupRepository.java @@ -48,6 +48,8 @@ public interface GroupRepository { // Group Admins + public GroupAdminData getAdminFaulty(int groupId, String address) throws DataException; + public GroupAdminData getAdmin(int groupId, String address) throws DataException; public boolean adminExists(int groupId, String address) throws DataException; diff --git a/src/main/java/org/qortal/repository/ReindexManager.java b/src/main/java/org/qortal/repository/ReindexManager.java new file mode 100644 index 00000000..edaa4a0c --- /dev/null +++ b/src/main/java/org/qortal/repository/ReindexManager.java @@ -0,0 +1,213 @@ +package org.qortal.repository; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.block.Block; +import org.qortal.block.GenesisBlock; +import org.qortal.controller.Controller; +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.transaction.Transaction; +import org.qortal.transform.block.BlockTransformation; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +import java.util.concurrent.TimeoutException; + +public class ReindexManager { + + private static final Logger LOGGER = LogManager.getLogger(ReindexManager.class); + + private Repository repository; + + private final int pruneAndTrimBlockInterval = 2000; + private final int maintenanceBlockInterval = 50000; + + private boolean resume = false; + + public ReindexManager() { + + } + + public void reindex() throws DataException { + try { + this.runPreChecks(); + this.rebuildRepository(); + + try (final Repository repository = RepositoryManager.getRepository()) { + this.repository = repository; + this.requestCheckpoint(); + this.processGenesisBlock(); + this.processBlocks(); + } + + } catch (InterruptedException e) { + throw new DataException("Interrupted before complete"); + } + } + + private void runPreChecks() throws DataException, InterruptedException { + LOGGER.info("Running pre-checks..."); + if (Settings.getInstance().isTopOnly()) { + throw new DataException("Reindexing not supported in top-only mode. Please bootstrap or resync from genesis."); + } + if (Settings.getInstance().isLite()) { + throw new DataException("Reindexing not supported in lite mode."); + } + + while (NTP.getTime() == null) { + LOGGER.info("Waiting for NTP..."); + Thread.sleep(5000L); + } + } + + private void rebuildRepository() throws DataException { + if (resume) { + return; + } + + LOGGER.info("Rebuilding repository..."); + RepositoryManager.rebuild(); + } + + private void requestCheckpoint() { + RepositoryManager.setRequestedCheckpoint(Boolean.TRUE); + } + + private void processGenesisBlock() throws DataException, InterruptedException { + if (resume) { + return; + } + + LOGGER.info("Processing genesis block..."); + + GenesisBlock genesisBlock = GenesisBlock.getInstance(repository); + + // Add Genesis Block to blockchain + genesisBlock.process(); + + this.repository.saveChanges(); + } + + private void processBlocks() throws DataException { + LOGGER.info("Processing blocks..."); + + int height = this.repository.getBlockRepository().getBlockchainHeight(); + while (true) { + height++; + + boolean processed = this.processBlock(height); + if (!processed) { + LOGGER.info("Block {} couldn't be processed. If this is the last archived block, then the process is complete.", height); + break; // TODO: check if complete + } + + // Prune and trim regularly, leaving a buffer + if (height >= pruneAndTrimBlockInterval*2 && height % pruneAndTrimBlockInterval == 0) { + int startHeight = Math.max(height - pruneAndTrimBlockInterval*2, 2); + int endHeight = height - pruneAndTrimBlockInterval; + LOGGER.info("Pruning and trimming blocks {} to {}...", startHeight, endHeight); + this.repository.getATRepository().rebuildLatestAtStates(height - 250); + this.repository.saveChanges(); + this.prune(startHeight, endHeight); + this.trim(startHeight, endHeight); + } + + // Run repository maintenance regularly, to keep blockchain.data size down + if (height % maintenanceBlockInterval == 0) { + this.runRepositoryMaintenance(); + } + } + } + + private boolean processBlock(int height) throws DataException { + Block block = this.fetchBlock(height); + if (block == null) { + return false; + } + + // Transactions are stored without approval status so determine that now + for (Transaction transaction : block.getTransactions()) + transaction.setInitialApprovalStatus(); + + // It's best not to run preProcess() until there is a reason to + // block.preProcess(); + + Block.ValidationResult validationResult = block.isValid(); + if (validationResult != Block.ValidationResult.OK) { + throw new DataException(String.format("Invalid block at height %d: %s", height, validationResult)); + } + + // Save transactions attached to this block + for (Transaction transaction : block.getTransactions()) { + TransactionData transactionData = transaction.getTransactionData(); + this.repository.getTransactionRepository().save(transactionData); + } + + block.process(); + + LOGGER.info(String.format("Reindexed block height %d, sig %.8s", block.getBlockData().getHeight(), Base58.encode(block.getBlockData().getSignature()))); + + // Add to block archive table, since this originated from the archive but the chainstate has to be rebuilt + this.addToBlockArchive(block.getBlockData()); + + this.repository.saveChanges(); + + Controller.getInstance().onNewBlock(block.getBlockData()); + + return true; + } + + private Block fetchBlock(int height) { + BlockTransformation b = BlockArchiveReader.getInstance().fetchBlockAtHeight(height); + if (b != null) { + if (b.getAtStatesHash() != null) { + return new Block(this.repository, b.getBlockData(), b.getTransactions(), b.getAtStatesHash()); + } + else { + return new Block(this.repository, b.getBlockData(), b.getTransactions(), b.getAtStates()); + } + } + + return null; + } + + private void addToBlockArchive(BlockData blockData) throws DataException { + // Write the signature and height into the BlockArchive table + BlockArchiveData blockArchiveData = new BlockArchiveData(blockData); + this.repository.getBlockArchiveRepository().save(blockArchiveData); + this.repository.getBlockArchiveRepository().setBlockArchiveHeight(blockData.getHeight()+1); + this.repository.saveChanges(); + } + + private void prune(int startHeight, int endHeight) throws DataException { + this.repository.getBlockRepository().pruneBlocks(startHeight, endHeight); + this.repository.getATRepository().pruneAtStates(startHeight, endHeight); + this.repository.getATRepository().setAtPruneHeight(endHeight+1); + this.repository.saveChanges(); + } + + private void trim(int startHeight, int endHeight) throws DataException { + this.repository.getBlockRepository().trimOldOnlineAccountsSignatures(startHeight, endHeight); + + int count = 1; // Any number greater than 0 + while (count > 0) { + count = this.repository.getATRepository().trimAtStates(startHeight, endHeight, Settings.getInstance().getAtStatesTrimLimit()); + } + + this.repository.getBlockRepository().setBlockPruneHeight(endHeight+1); + this.repository.getATRepository().setAtTrimHeight(endHeight+1); + this.repository.saveChanges(); + } + + private void runRepositoryMaintenance() throws DataException { + try { + this.repository.performPeriodicMaintenance(1000L); + } catch (TimeoutException e) { + LOGGER.info("Timed out waiting for repository before running maintenance"); + } + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 80fc62dc..6310ec02 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -1,9 +1,11 @@ package org.qortal.repository.hsqldb; import com.google.common.primitives.Longs; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.controller.Controller; +import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.repository.ATRepository; @@ -16,6 +18,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; +import org.qortal.data.account.AccountData; + public class HSQLDBATRepository implements ATRepository { private static final Logger LOGGER = LogManager.getLogger(HSQLDBATRepository.class); @@ -400,9 +404,9 @@ public class HSQLDBATRepository implements ATRepository { } @Override - public List getMatchingFinalATStates(byte[] codeHash, Boolean isFinished, - Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, - Integer limit, Integer offset, Boolean reverse) throws DataException { + public List getMatchingFinalATStates(byte[] codeHash, byte[] buyerPublicKey, byte[] sellerPublicKey, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, + Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(1024); List bindParams = new ArrayList<>(); @@ -421,10 +425,14 @@ public class HSQLDBATRepository implements ATRepository { // Order by AT_address and height to use compound primary key as index // Both must be the same direction (DESC) also - sql.append("ORDER BY ATStates.AT_address DESC, ATStates.height DESC " - + "LIMIT 1 " - + ") AS FinalATStates " - + "WHERE code_hash = ? "); + sql.append("ORDER BY ATStates.height DESC LIMIT 1) AS FinalATStates "); + + // Optional JOIN with ATTRANSACTIONS for buyerAddress + if (buyerPublicKey != null && buyerPublicKey.length > 0) { + sql.append("JOIN ATTRANSACTIONS tx ON tx.at_address = ATs.AT_address "); + } + + sql.append("WHERE ATs.code_hash = ? "); bindParams.add(codeHash); if (isFinished != null) { @@ -443,6 +451,20 @@ public class HSQLDBATRepository implements ATRepository { bindParams.add(rawExpectedValue); } + if (buyerPublicKey != null && buyerPublicKey.length > 0 ) { + // the buyer must be the recipient of the transaction and not the creator of the AT + sql.append("AND tx.recipient = ? AND ATs.creator != ? "); + + bindParams.add(Crypto.toAddress(buyerPublicKey)); + bindParams.add(buyerPublicKey); + } + + + if (sellerPublicKey != null && sellerPublicKey.length > 0) { + sql.append("AND ATs.creator = ? "); + bindParams.add(sellerPublicKey); + } + sql.append(" ORDER BY FinalATStates.height "); if (reverse != null && reverse) sql.append("DESC"); @@ -483,7 +505,7 @@ public class HSQLDBATRepository implements ATRepository { Integer dataByteOffset, Long expectedValue, int minimumCount, int maximumCount, long minimumPeriod) throws DataException { // We need most recent entry first so we can use its timestamp to slice further results - List mostRecentStates = this.getMatchingFinalATStates(codeHash, isFinished, + List mostRecentStates = this.getMatchingFinalATStates(codeHash, null, null, isFinished, dataByteOffset, expectedValue, null, 1, 0, true); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java index 7aef66ce..9cec85b2 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java @@ -1,5 +1,7 @@ package org.qortal.repository.hsqldb; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.qortal.asset.Asset; import org.qortal.data.account.*; import org.qortal.repository.AccountRepository; @@ -8,20 +10,28 @@ import org.qortal.repository.DataException; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import static org.qortal.utils.Amounts.prettyAmount; public class HSQLDBAccountRepository implements AccountRepository { + public static final String SELL = "sell"; + public static final String BUY = "buy"; protected HSQLDBRepository repository; public HSQLDBAccountRepository(HSQLDBRepository repository) { this.repository = repository; } + protected static final Logger LOGGER = LogManager.getLogger(HSQLDBAccountRepository.class); // General account @Override @@ -1147,4 +1157,389 @@ public class HSQLDBAccountRepository implements AccountRepository { } } -} + @Override + public SponsorshipReport getSponsorshipReport(String address, String[] realRewardShareRecipients) throws DataException { + + List sponsees = getSponseeAddresses(address, realRewardShareRecipients); + + return getMintershipReport(address, account -> sponsees); + } + + @Override + public SponsorshipReport getMintershipReport(String account, Function> addressFetcher) throws DataException { + + try { + ResultSet accountResultSet = getAccountResultSet(account); + + if( accountResultSet == null ) throw new DataException("Unable to fetch account info from repository"); + + int level = accountResultSet.getInt(2); + int blocksMinted = accountResultSet.getInt(3); + int adjustments = accountResultSet.getInt(4); + int penalties = accountResultSet.getInt(5); + boolean transferPrivs = accountResultSet.getBoolean(6); + + List sponseeAddresses = addressFetcher.apply(account); + + if( sponseeAddresses.isEmpty() ){ + return new SponsorshipReport(account, level, blocksMinted, adjustments, penalties, transferPrivs, new String[0], 0, 0,0, 0, 0, 0, 0, 0, 0, 0); + } + else { + return produceSponsorShipReport(account, level, blocksMinted, adjustments, penalties, sponseeAddresses, transferPrivs); + } + } + catch (Exception e) { + LOGGER.error(e.getMessage(), e); + throw new DataException("Unable to fetch account info from repository", e); + } + } + + @Override + public List getSponseeAddresses(String account, String[] realRewardShareRecipients) throws DataException { + StringBuffer sponseeSql = new StringBuffer(); + + sponseeSql.append( "SELECT DISTINCT t.recipient sponsees " ); + sponseeSql.append( "FROM REWARDSHARETRANSACTIONS t "); + sponseeSql.append( "INNER JOIN ACCOUNTS a on t.minter_public_key = a.public_key "); + sponseeSql.append( "WHERE account = ? and t.recipient != a.account"); + + try { + ResultSet sponseeResultSet; + + // if there are real reward share recipeints to exclude + if (realRewardShareRecipients != null && realRewardShareRecipients.length > 0) { + + // add constraint to where clause + sponseeSql.append(" and t.recipient NOT IN ("); + sponseeSql.append(String.join(", ", Collections.nCopies(realRewardShareRecipients.length, "?"))); + sponseeSql.append(")"); + + // Create a new array to hold both + Object[] combinedArray = new Object[realRewardShareRecipients.length + 1]; + + // Add the single string to the first position + combinedArray[0] = account; + + // Copy the elements from realRewardShareRecipients to the combinedArray starting from index 1 + System.arraycopy(realRewardShareRecipients, 0, combinedArray, 1, realRewardShareRecipients.length); + + sponseeResultSet = this.repository.checkedExecute(sponseeSql.toString(), combinedArray); + } + else { + sponseeResultSet = this.repository.checkedExecute(sponseeSql.toString(), account); + } + + List sponseeAddresses; + + if( sponseeResultSet == null ) { + sponseeAddresses = new ArrayList<>(0); + } + else { + sponseeAddresses = new ArrayList<>(); + + do { + sponseeAddresses.add(sponseeResultSet.getString(1)); + } while (sponseeResultSet.next()); + } + + return sponseeAddresses; + } + catch (SQLException e) { + throw new DataException("can't get sponsees from blockchain data", e); + } + } + + @Override + public Optional getSponsor(String address) throws DataException { + + StringBuffer sponsorSql = new StringBuffer(); + + sponsorSql.append( "SELECT DISTINCT account, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty "); + sponsorSql.append( "FROM REWARDSHARETRANSACTIONS t "); + sponsorSql.append( "INNER JOIN ACCOUNTS a on a.public_key = t.minter_public_key "); + sponsorSql.append( "WHERE recipient = ? and recipient != account "); + + try { + ResultSet sponseeResultSet = this.repository.checkedExecute(sponsorSql.toString(), address); + + if( sponseeResultSet == null ){ + return Optional.empty(); + } + else { + return Optional.ofNullable( sponseeResultSet.getString(1)); + } + } catch (SQLException e) { + throw new DataException("can't get sponsor from blockchain data", e); + } + } + + @Override + public List getAddressLevelPairings(int minLevel) throws DataException { + + StringBuffer accLevelSql = new StringBuffer(51); + + accLevelSql.append( "SELECT account,level FROM ACCOUNTS WHERE level >= ?" ); + + try { + ResultSet accountLevelResultSet = this.repository.checkedExecute(accLevelSql.toString(),minLevel); + + List addressLevelPairings; + + if( accountLevelResultSet == null ) { + addressLevelPairings = new ArrayList<>(0); + } + else { + addressLevelPairings = new ArrayList<>(); + + do { + AddressLevelPairing pairing + = new AddressLevelPairing( + accountLevelResultSet.getString(1), + accountLevelResultSet.getInt(2) + ); + addressLevelPairings.add(pairing); + } while (accountLevelResultSet.next()); + } + return addressLevelPairings; + } catch (SQLException e) { + throw new DataException("Can't get addresses for this level from blockchain data", e); + } + } + + /** + * Produce Sponsorship Report + * + * @param address the account address for the sponsor + * @param level the sponsor's level + * @param blocksMinted the blocks minted by the sponsor + * @param blocksMintedAdjustment + * @param blocksMintedPenalty + * @param sponseeAddresses + * @param transferPrivs true if this account was involved in a TRANSFER_PRIVS transaction + * @return the report + * @throws SQLException + */ + private SponsorshipReport produceSponsorShipReport( + String address, + int level, + int blocksMinted, + int blocksMintedAdjustment, + int blocksMintedPenalty, + List sponseeAddresses, + boolean transferPrivs) throws SQLException, DataException { + + int sponseeCount = sponseeAddresses.size(); + + // get the registered names of the sponsees + ResultSet namesResultSet = getNamesResultSet(sponseeAddresses, sponseeCount); + + List sponseeNames; + + if( namesResultSet != null ) { + sponseeNames = getNames(namesResultSet, sponseeCount); + } + else { + sponseeNames = new ArrayList<>(0); + } + + // get the average balance of the sponsees + ResultSet avgBalanceResultSet = getAverageBalanceResultSet(sponseeAddresses, sponseeCount); + int avgBalance = avgBalanceResultSet.getInt(1); + + // count the arbitrary and transfer asset transactions for all sponsees + ResultSet txTypeResultSet = getTxTypeResultSet(sponseeAddresses, sponseeCount); + + int arbitraryCount; + int transferAssetCount; + int transferPrivsCount; + + if( txTypeResultSet != null) { + + Map countsByType = new HashMap<>(2); + + do{ + Integer type = txTypeResultSet.getInt(1); + + if( type != null ) { + countsByType.put(type, txTypeResultSet.getInt(2)); + } + } while( txTypeResultSet.next()); + + arbitraryCount = countsByType.getOrDefault(10, 0); + transferAssetCount = countsByType.getOrDefault(12, 0); + transferPrivsCount = countsByType.getOrDefault(40, 0); + } + // no rows -> no counts + else { + arbitraryCount = 0; + transferAssetCount = 0; + transferPrivsCount = 0; + } + + ResultSet sellResultSet = getSellResultSet(sponseeAddresses, sponseeCount); + + int sellCount; + int sellAmount; + + // if there are sell results, then fill in the sell amount/counts + if( sellResultSet != null ) { + sellCount = sellResultSet.getInt(1); + sellAmount = sellResultSet.getInt(2); + } + // no rows -> no counts/amounts + else { + sellCount = 0; + sellAmount = 0; + } + + ResultSet buyResultSet = getBuyResultSet(sponseeAddresses, sponseeCount); + + int buyCount; + int buyAmount; + + // if there are buy results, then fill in the buy amount/counts + if( buyResultSet != null ) { + buyCount = buyResultSet.getInt(1); + buyAmount = buyResultSet.getInt(2); + } + // no rows -> no counts/amounts + else { + buyCount = 0; + buyAmount = 0; + } + + return new SponsorshipReport( + address, + level, + blocksMinted, + blocksMintedAdjustment, + blocksMintedPenalty, + transferPrivs, + sponseeNames.toArray(new String[sponseeNames.size()]), + sponseeCount, + sponseeCount - sponseeNames.size(), + avgBalance, + arbitraryCount, + transferAssetCount, + transferPrivsCount, + sellCount, + sellAmount, + buyCount, + buyAmount); + } + + private ResultSet getBuyResultSet(List addresses, int addressCount) throws SQLException { + + StringBuffer sql = new StringBuffer(); + sql.append("SELECT COUNT(*) count, SUM(amount)/100000000 amount "); + sql.append("FROM ACCOUNTS a "); + sql.append("INNER JOIN ATTRANSACTIONS tx ON tx.recipient = a.account "); + sql.append("INNER JOIN ATS ats ON ats.at_address = tx.at_address "); + sql.append("WHERE a.account IN ( "); + sql.append(String.join(", ", Collections.nCopies(addressCount, "?"))); + sql.append(") "); + sql.append("AND a.account = tx.recipient AND a.public_key != ats.creator AND asset_id = 0 "); + Object[] sponsees = addresses.toArray(new Object[addressCount]); + ResultSet buySellResultSet = this.repository.checkedExecute(sql.toString(), sponsees); + + return buySellResultSet; + } + + private ResultSet getSellResultSet(List addresses, int addressCount) throws SQLException { + + StringBuffer sql = new StringBuffer(); + sql.append("SELECT COUNT(*) count, SUM(amount)/100000000 amount "); + sql.append("FROM ATS ats "); + sql.append("INNER JOIN ACCOUNTS a ON a.public_key = ats.creator "); + sql.append("INNER JOIN ATTRANSACTIONS tx ON tx.at_address = ats.at_address "); + sql.append("WHERE a.account IN ( "); + sql.append(String.join(", ", Collections.nCopies(addressCount, "?"))); + sql.append(") "); + sql.append("AND a.account != tx.recipient AND asset_id = 0 "); + Object[] sponsees = addresses.toArray(new Object[addressCount]); + + return this.repository.checkedExecute(sql.toString(), sponsees); + } + + private ResultSet getAccountResultSet(String account) throws SQLException { + + StringBuffer accountSql = new StringBuffer(); + + accountSql.append( "SELECT DISTINCT a.account, a.level, a.blocks_minted, a.blocks_minted_adjustment, a.blocks_minted_penalty, tx.sender IS NOT NULL as transfer "); + accountSql.append( "FROM ACCOUNTS a "); + accountSql.append( "LEFT JOIN TRANSFERPRIVSTRANSACTIONS tx on a.public_key = tx.sender or a.account = tx.recipient "); + accountSql.append( "WHERE account = ? "); + + ResultSet accountResultSet = this.repository.checkedExecute( accountSql.toString(), account); + + return accountResultSet; + } + + + private ResultSet getTxTypeResultSet(List sponseeAddresses, int sponseeCount) throws SQLException { + StringBuffer txTypeTotalsSql = new StringBuffer(); + // Transaction Types, int values + // ARBITRARY = 10 + // TRANSFER_ASSET = 12 + // txTypeTotalsSql.append(" + txTypeTotalsSql.append("SELECT type, count(*) "); + txTypeTotalsSql.append("FROM TRANSACTIONPARTICIPANTS "); + txTypeTotalsSql.append("INNER JOIN TRANSACTIONS USING (signature) "); + txTypeTotalsSql.append("where participant in ( "); + txTypeTotalsSql.append(String.join(", ", Collections.nCopies(sponseeCount, "?"))); + txTypeTotalsSql.append(") and type in (10, 12, 40) "); + txTypeTotalsSql.append("group by type order by type"); + + Object[] sponsees = sponseeAddresses.toArray(new Object[sponseeCount]); + ResultSet txTypeResultSet = this.repository.checkedExecute(txTypeTotalsSql.toString(), sponsees); + return txTypeResultSet; + } + + private ResultSet getAverageBalanceResultSet(List sponseeAddresses, int sponseeCount) throws SQLException { + StringBuffer avgBalanceSql = new StringBuffer(); + avgBalanceSql.append("SELECT avg(balance)/100000000 FROM ACCOUNTBALANCES "); + avgBalanceSql.append("WHERE account in ("); + avgBalanceSql.append(String.join(", ", Collections.nCopies(sponseeCount, "?"))); + avgBalanceSql.append(") and ASSET_ID = 0"); + + Object[] sponsees = sponseeAddresses.toArray(new Object[sponseeCount]); + return this.repository.checkedExecute(avgBalanceSql.toString(), sponsees); + } + + /** + * Get Names + * + * @param namesResultSet the result set to get the names from, can't be null + * @param count the number of potential names + * + * @return the names + * + * @throws SQLException + */ + private static List getNames(ResultSet namesResultSet, int count) throws SQLException { + + List names = new ArrayList<>(count); + + do{ + String name = namesResultSet.getString(1); + + if( name != null ) { + names.add(name); + } + } while( namesResultSet.next() ); + + return names; + } + + private ResultSet getNamesResultSet(List sponseeAddresses, int sponseeCount) throws SQLException { + StringBuffer namesSql = new StringBuffer(); + namesSql.append("SELECT name FROM NAMES "); + namesSql.append("WHERE owner in ("); + namesSql.append(String.join(", ", Collections.nCopies(sponseeCount, "?"))); + namesSql.append(")"); + + Object[] sponsees = sponseeAddresses.toArray(new Object[sponseeCount]); + ResultSet namesResultSet = this.repository.checkedExecute(namesSql.toString(), sponsees); + return namesResultSet; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java index c49074c5..0e15be77 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBArbitraryRepository.java @@ -7,6 +7,7 @@ import org.qortal.arbitrary.ArbitraryDataFile; import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; import org.qortal.arbitrary.misc.Category; import org.qortal.arbitrary.misc.Service; +import org.qortal.data.arbitrary.ArbitraryResourceCache; import org.qortal.data.arbitrary.ArbitraryResourceData; import org.qortal.data.arbitrary.ArbitraryResourceMetadata; import org.qortal.data.arbitrary.ArbitraryResourceStatus; @@ -18,6 +19,7 @@ import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.ArbitraryRepository; import org.qortal.repository.DataException; +import org.qortal.settings.Settings; import org.qortal.transaction.ArbitraryTransaction; import org.qortal.transaction.Transaction.ApprovalStatus; import org.qortal.utils.Base58; @@ -26,8 +28,10 @@ import org.qortal.utils.ListUtils; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.Optional; public class HSQLDBArbitraryRepository implements ArbitraryRepository { @@ -223,6 +227,144 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { } } + @Override + public List getLatestArbitraryTransactions() throws DataException { + String sql = "SELECT type, reference, signature, creator, created_when, fee, " + + "tx_group_id, block_height, approval_status, approval_height, " + + "version, nonce, service, size, is_data_raw, data, metadata_hash, " + + "name, identifier, update_method, secret, compression FROM ArbitraryTransactions " + + "JOIN Transactions USING (signature) " + + "WHERE name IS NOT NULL " + + "ORDER BY created_when DESC"; + List arbitraryTransactionData = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return new ArrayList<>(0); + + do { + byte[] reference = resultSet.getBytes(2); + byte[] signature = resultSet.getBytes(3); + byte[] creatorPublicKey = resultSet.getBytes(4); + long timestamp = resultSet.getLong(5); + + Long fee = resultSet.getLong(6); + if (fee == 0 && resultSet.wasNull()) + fee = null; + + int txGroupId = resultSet.getInt(7); + + Integer blockHeight = resultSet.getInt(8); + if (blockHeight == 0 && resultSet.wasNull()) + blockHeight = null; + + ApprovalStatus approvalStatus = ApprovalStatus.valueOf(resultSet.getInt(9)); + Integer approvalHeight = resultSet.getInt(10); + if (approvalHeight == 0 && resultSet.wasNull()) + approvalHeight = null; + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, approvalStatus, blockHeight, approvalHeight, signature); + + int version = resultSet.getInt(11); + int nonce = resultSet.getInt(12); + 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; + byte[] data = resultSet.getBytes(16); + byte[] metadataHash = resultSet.getBytes(17); + String nameResult = resultSet.getString(18); + String identifierResult = resultSet.getString(19); + Method method = Method.valueOf(resultSet.getInt(20)); + byte[] secret = resultSet.getBytes(21); + Compression compression = Compression.valueOf(resultSet.getInt(22)); + // FUTURE: get payments from signature if needed. Avoiding for now to reduce database calls. + + ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, + version, serviceInt, nonce, size, nameResult, identifierResult, method, secret, + compression, data, dataType, metadataHash, null); + + arbitraryTransactionData.add(transactionData); + } while (resultSet.next()); + + return arbitraryTransactionData; + } catch (SQLException e) { + throw new DataException("Unable to fetch arbitrary transactions from repository", e); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + return new ArrayList<>(0); + } + } + + @Override + public List getLatestArbitraryTransactionsByName( String name ) throws DataException { + String sql = "SELECT type, reference, signature, creator, created_when, fee, " + + "tx_group_id, block_height, approval_status, approval_height, " + + "version, nonce, service, size, is_data_raw, data, metadata_hash, " + + "name, identifier, update_method, secret, compression FROM ArbitraryTransactions " + + "JOIN Transactions USING (signature) " + + "WHERE name = ? " + + "ORDER BY created_when DESC"; + List arbitraryTransactionData = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql, name)) { + if (resultSet == null) + return new ArrayList<>(0); + + do { + byte[] reference = resultSet.getBytes(2); + byte[] signature = resultSet.getBytes(3); + byte[] creatorPublicKey = resultSet.getBytes(4); + long timestamp = resultSet.getLong(5); + + Long fee = resultSet.getLong(6); + if (fee == 0 && resultSet.wasNull()) + fee = null; + + int txGroupId = resultSet.getInt(7); + + Integer blockHeight = resultSet.getInt(8); + if (blockHeight == 0 && resultSet.wasNull()) + blockHeight = null; + + ApprovalStatus approvalStatus = ApprovalStatus.valueOf(resultSet.getInt(9)); + Integer approvalHeight = resultSet.getInt(10); + if (approvalHeight == 0 && resultSet.wasNull()) + approvalHeight = null; + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, approvalStatus, blockHeight, approvalHeight, signature); + + int version = resultSet.getInt(11); + int nonce = resultSet.getInt(12); + 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; + byte[] data = resultSet.getBytes(16); + byte[] metadataHash = resultSet.getBytes(17); + String nameResult = resultSet.getString(18); + String identifierResult = resultSet.getString(19); + Method method = Method.valueOf(resultSet.getInt(20)); + byte[] secret = resultSet.getBytes(21); + Compression compression = Compression.valueOf(resultSet.getInt(22)); + // FUTURE: get payments from signature if needed. Avoiding for now to reduce database calls. + + ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, + version, serviceInt, nonce, size, nameResult, identifierResult, method, secret, + compression, data, dataType, metadataHash, null); + + arbitraryTransactionData.add(transactionData); + } while (resultSet.next()); + + return arbitraryTransactionData; + } catch (SQLException e) { + throw new DataException("Unable to fetch arbitrary transactions from repository", e); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + return new ArrayList<>(0); + } + } + private ArbitraryTransactionData getSingleTransaction(String name, Service service, Method method, String identifier, boolean firstNotLast) throws DataException { if (name == null || service == null) { // Required fields @@ -720,9 +862,54 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { } @Override - public List searchArbitraryResources(Service service, String query, String identifier, List names, String title, String description, boolean prefixOnly, + public List searchArbitraryResources(Service service, String query, String identifier, List names, String title, String description, List keywords, boolean prefixOnly, List exactMatchNames, boolean defaultResource, SearchMode mode, Integer minLevel, Boolean followedOnly, Boolean excludeBlocked, Boolean includeMetadata, Boolean includeStatus, Long before, Long after, Integer limit, Integer offset, Boolean reverse) throws DataException { + + if(Settings.getInstance().isDbCacheEnabled()) { + List list + = HSQLDBCacheUtils.callCache( + ArbitraryResourceCache.getInstance(), + service, query, identifier, names, title, description, prefixOnly, exactMatchNames, + defaultResource, mode, minLevel, followedOnly, excludeBlocked, includeMetadata, includeStatus, + before, after, limit, offset, reverse); + + if( !list.isEmpty() ) { + List results + = HSQLDBCacheUtils.filterList( + list, + ArbitraryResourceCache.getInstance().getLevelByName(), + Optional.ofNullable(mode), + Optional.ofNullable(service), + Optional.ofNullable(query), + Optional.ofNullable(identifier), + Optional.ofNullable(names), + Optional.ofNullable(title), + Optional.ofNullable(description), + prefixOnly, + Optional.ofNullable(exactMatchNames), + Optional.ofNullable(keywords), + defaultResource, + Optional.ofNullable(minLevel), + Optional.ofNullable(() -> ListUtils.followedNames()), + Optional.ofNullable(ListUtils::blockedNames), + Optional.ofNullable(includeMetadata), + Optional.ofNullable(includeStatus), + Optional.ofNullable(before), + Optional.ofNullable(after), + Optional.ofNullable(limit), + Optional.ofNullable(offset), + Optional.ofNullable(reverse) + ); + + return results; + } + else { + LOGGER.info("Db Enabled Cache has zero candidates."); + } + } + + StringBuilder sql = new StringBuilder(512); List bindParams = new ArrayList<>(); @@ -809,6 +996,26 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { bindParams.add(queryWildcard); } + if (keywords != null && !keywords.isEmpty()) { + List searchKeywords = new ArrayList<>(keywords); + + List conditions = new ArrayList<>(); + List bindValues = new ArrayList<>(); + + for (int i = 0; i < searchKeywords.size(); i++) { + conditions.add("LOWER(description) LIKE ?"); + bindValues.add("%" + searchKeywords.get(i).trim().toLowerCase() + "%"); + } + + String finalCondition = String.join(" OR ", conditions); + sql.append(" AND (").append(finalCondition).append(")"); + + bindParams.addAll(bindValues); + } + + + + // Handle name searches if (names != null && !names.isEmpty()) { sql.append(" AND ("); @@ -954,6 +1161,128 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository { } } + @Override + public List searchArbitraryResourcesSimple( + Service service, + String identifier, + List names, + boolean prefixOnly, + Long before, + Long after, + Integer limit, + Integer offset, + Boolean reverse, + Boolean caseInsensitive) throws DataException { + StringBuilder sql = new StringBuilder(512); + List bindParams = new ArrayList<>(); + + sql.append("SELECT name, service, identifier, size, status, created_when, updated_when "); + sql.append("FROM ArbitraryResourcesCache "); + sql.append("WHERE name IS NOT NULL"); + + if (service != null) { + sql.append(" AND service = ?"); + bindParams.add(service.value); + } + + // Handle identifier matches + if (identifier != null) { + if(caseInsensitive || prefixOnly) { + // Search anywhere in the identifier, unless "prefixOnly" has been requested + String queryWildcard = getQueryWildcard(identifier, prefixOnly, caseInsensitive); + sql.append(caseInsensitive ? " AND LCASE(identifier) LIKE ?" : " AND identifier LIKE ?"); + bindParams.add(queryWildcard); + } + else { + sql.append(" AND identifier = ?"); + bindParams.add(identifier); + } + } + + // Handle name searches + if (names != null && !names.isEmpty()) { + sql.append(" AND ("); + + if( caseInsensitive || prefixOnly ) { + for (int i = 0; i < names.size(); ++i) { + // Search anywhere in the name, unless "prefixOnly" has been requested + String queryWildcard = getQueryWildcard(names.get(i), prefixOnly, caseInsensitive); + if (i > 0) sql.append(" OR "); + sql.append(caseInsensitive ? "LCASE(name) LIKE ?" : "name LIKE ?"); + bindParams.add(queryWildcard); + } + } + else { + for (int i = 0; i < names.size(); ++i) { + if (i > 0) sql.append(" OR "); + sql.append("name = ?"); + bindParams.add(names.get(i)); + } + } + + sql.append(")"); + } + + // Timestamp range + if (before != null) { + sql.append(" AND created_when < ?"); + bindParams.add(before); + } + if (after != null) { + sql.append(" AND created_when > ?"); + bindParams.add(after); + } + + sql.append(" ORDER BY created_when"); + + if (reverse != null && reverse) { + sql.append(" DESC"); + } + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List arbitraryResources = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return arbitraryResources; + + do { + String nameResult = resultSet.getString(1); + Service serviceResult = Service.valueOf(resultSet.getInt(2)); + String identifierResult = resultSet.getString(3); + Integer sizeResult = resultSet.getInt(4); + Integer status = resultSet.getInt(5); + Long created = resultSet.getLong(6); + Long updated = resultSet.getLong(7); + + if (Objects.equals(identifierResult, "default")) { + // Map "default" back to null. This is optional but probably less confusing than returning "default". + identifierResult = null; + } + + ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData(); + arbitraryResourceData.name = nameResult; + arbitraryResourceData.service = serviceResult; + arbitraryResourceData.identifier = identifierResult; + arbitraryResourceData.size = sizeResult; + arbitraryResourceData.created = created; + arbitraryResourceData.updated = (updated == 0) ? null : updated; + + arbitraryResources.add(arbitraryResourceData); + } while (resultSet.next()); + + return arbitraryResources; + } catch (SQLException e) { + throw new DataException("Unable to fetch simple arbitrary resources from repository", e); + } + } + + private static String getQueryWildcard(String value, boolean prefixOnly, boolean caseInsensitive) { + String valueToUse = caseInsensitive ? value.toLowerCase() : value; + return prefixOnly ? String.format("%s%%", valueToUse) : valueToUse; + } + // Arbitrary resources cache save/load diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCacheUtils.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCacheUtils.java new file mode 100644 index 00000000..46cd7cab --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCacheUtils.java @@ -0,0 +1,863 @@ +package org.qortal.repository.hsqldb; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.api.SearchMode; +import org.qortal.api.resource.TransactionsResource; +import org.qortal.arbitrary.misc.Category; +import org.qortal.arbitrary.misc.Service; +import org.qortal.controller.Controller; +import org.qortal.data.account.AccountBalanceData; +import org.qortal.data.account.AddressAmountData; +import org.qortal.data.account.BlockHeightRange; +import org.qortal.data.account.BlockHeightRangeAddressAmounts; +import org.qortal.data.arbitrary.ArbitraryResourceCache; +import org.qortal.data.arbitrary.ArbitraryResourceData; +import org.qortal.data.arbitrary.ArbitraryResourceMetadata; +import org.qortal.data.arbitrary.ArbitraryResourceStatus; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.utils.BalanceRecorderUtils; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLNonTransientConnectionException; +import java.sql.Statement; +import java.time.format.DateTimeFormatter; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.qortal.api.SearchMode.LATEST; + +public class HSQLDBCacheUtils { + + private static final Logger LOGGER = LogManager.getLogger(HSQLDBCacheUtils.class); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final Comparator CREATED_WHEN_COMPARATOR = new Comparator() { + @Override + public int compare(ArbitraryResourceData data1, ArbitraryResourceData data2) { + + Long a = data1.created; + Long b = data2.created; + + return Long.compare(a != null ? a : Long.MIN_VALUE, b != null ? b : Long.MIN_VALUE); + } + }; + private static final String DEFAULT_IDENTIFIER = "default"; + private static final int ZERO = 0; + public static final String DB_CACHE_TIMER = "DB Cache Timer"; + public static final String DB_CACHE_TIMER_TASK = "DB Cache Timer Task"; + public static final String BALANCE_RECORDER_TIMER = "Balance Recorder Timer"; + public static final String BALANCE_RECORDER_TIMER_TASK = "Balance Recorder Timer Task"; + + /** + * + * @param cache + * @param service the service to filter + * @param query query for name, identifier, title or description match + * @param identifier the identifier to match + * @param names the names to match, ignored if there are exact names + * @param title the title to match for + * @param description the description to match for + * @param prefixOnly true to match on prefix only, false for match anywhere in string + * @param exactMatchNames names to match exactly, overrides names + * @param defaultResource true to query filter identifier on the default identifier and use the query terms to match candidates names only + * @param mode LATEST or ALL + * @param minLevel the minimum account level for resource creators + * @param includeOnly names to retain, exclude all others + * @param exclude names to exclude, retain all others + * @param includeMetadata true to include resource metadata in the results, false to exclude metadata + * @param includeStatus true to include resource status in the results, false to exclude status + * @param before the latest creation timestamp for any candidate + * @param after the earliest creation timestamp for any candidate + * @param limit the maximum number of resource results to return + * @param offset the number of resource results to skip after the results have been retained, filtered and sorted + * @param reverse true to reverse the sort order, false to order in chronological order + * + * @return the resource results + */ + public static List callCache( + ArbitraryResourceCache cache, + Service service, + String query, + String identifier, + List names, + String title, + String description, + boolean prefixOnly, + List exactMatchNames, + boolean defaultResource, + SearchMode mode, + Integer minLevel, + Boolean followedOnly, + Boolean excludeBlocked, + Boolean includeMetadata, + Boolean includeStatus, + Long before, + Long after, + Integer limit, + Integer offset, + Boolean reverse) { + + List candidates = new ArrayList<>(); + + // cache all results for requested service + if( service != null ) { + candidates.addAll(cache.getDataByService().getOrDefault(service.value, new ArrayList<>(0))); + } + // if no requested, then empty cache + + return candidates; + } + + /** + * Filter candidates + * + * @param candidates the candidates, they may be preprocessed + * @param levelByName name -> level map + * @param mode LATEST or ALL + * @param service the service to filter + * @param query query for name, identifier, title or description match + * @param identifier the identifier to match + * @param names the names to match, ignored if there are exact names + * @param title the title to match for + * @param description the description to match for + * @param prefixOnly true to match on prefix only, false for match anywhere in string + * @param exactMatchNames names to match exactly, overrides names + * @param defaultResource true to query filter identifier on the default identifier and use the query terms to match candidates names only + * @param minLevel the minimum account level for resource creators + * @param includeOnly names to retain, exclude all others + * @param exclude names to exclude, retain all others + * @param includeMetadata true to include resource metadata in the results, false to exclude metadata + * @param includeStatus true to include resource status in the results, false to exclude status + * @param before the latest creation timestamp for any candidate + * @param after the earliest creation timestamp for any candidate + * @param limit the maximum number of resource results to return + * @param offset the number of resource results to skip after the results have been retained, filtered and sorted + * @param reverse true to reverse the sort order, false to order in chronological order + * + * @return the resource results + */ + public static List filterList( + List candidates, + Map levelByName, + Optional mode, + Optional service, + Optional query, + Optional identifier, + Optional> names, + Optional title, + Optional description, + boolean prefixOnly, + Optional> exactMatchNames, + Optional> keywords, + boolean defaultResource, + Optional minLevel, + Optional>> includeOnly, + Optional>> exclude, + Optional includeMetadata, + Optional includeStatus, + Optional before, + Optional after, + Optional limit, + Optional offset, + Optional reverse) { + + // retain only candidates with names + Stream stream = candidates.stream().filter(candidate -> candidate.name != null ); + + if(after.isPresent()) { + stream = stream.filter( candidate -> candidate.created > after.get().longValue() ); + } + + if(before.isPresent()) { + stream = stream.filter( candidate -> candidate.created < before.get().longValue() ); + } + + if(exclude.isPresent()) + stream = stream.filter( candidate -> !exclude.get().get().contains( candidate.name )); + + // filter by service + if( service.isPresent() ) + stream = stream.filter(candidate -> candidate.service.equals(service.get())); + + // filter by query (either identifier, name, title or description) + if (query.isPresent()) { + + Predicate predicate + = prefixOnly ? getPrefixPredicate(query.get()) : getContainsPredicate(query.get()); + + if (defaultResource) { + stream = stream.filter( candidate -> DEFAULT_IDENTIFIER.equals( candidate.identifier ) && predicate.test(candidate.name)); + } else { + stream = stream.filter( candidate -> passQuery(predicate, candidate)); + } + } + + // filter for identifier, title and description + stream = filterTerm(identifier, data -> data.identifier, prefixOnly, stream); + stream = filterTerm(title, data -> data.metadata != null ? data.metadata.getTitle() : null, prefixOnly, stream); + stream = filterTerm(description, data -> data.metadata != null ? data.metadata.getDescription() : null, prefixOnly, stream); + + // New: Filter by keywords if provided + if (keywords.isPresent() && !keywords.get().isEmpty()) { + List searchKeywords = keywords.get().stream() + .map(String::toLowerCase) + .collect(Collectors.toList()); + + stream = stream.filter(candidate -> { + + if (candidate.metadata != null && candidate.metadata.getDescription() != null) { + String descriptionLower = candidate.metadata.getDescription().toLowerCase(); + return searchKeywords.stream().anyMatch(descriptionLower::contains); + } + return false; + }); + } + + if (keywords.isPresent() && !keywords.get().isEmpty()) { + List searchKeywords = keywords.get().stream() + .map(String::toLowerCase) + .collect(Collectors.toList()); + + stream = stream.filter(candidate -> { + if (candidate.metadata != null && candidate.metadata.getDescription() != null) { + String descriptionLower = candidate.metadata.getDescription().toLowerCase(); + return searchKeywords.stream().anyMatch(descriptionLower::contains); + } + return false; + }); + } + + // if exact names is set, retain resources with exact names + if( exactMatchNames.isPresent() && !exactMatchNames.get().isEmpty()) { + + // key the data by lower case name + Map> dataByName + = stream.collect(Collectors.groupingBy(data -> data.name.toLowerCase())); + + // lower the case of the exact names + // retain the lower case names of the data above + List exactNamesToSearch + = exactMatchNames.get().stream() + .map(String::toLowerCase) + .collect(Collectors.toList()); + exactNamesToSearch.retainAll(dataByName.keySet()); + + // get the data for the names retained and + // set them to the stream + stream + = dataByName.entrySet().stream() + .filter(entry -> exactNamesToSearch.contains(entry.getKey())).flatMap(entry -> entry.getValue().stream()); + } + // if exact names is not set, retain resources that match + else if( names.isPresent() && !names.get().isEmpty() ) { + + stream = retainTerms(names.get(), data -> data.name, prefixOnly, stream); + } + + // filter for minimum account level + if(minLevel.isPresent()) + stream = stream.filter( candidate -> levelByName.getOrDefault(candidate.name, 0) >= minLevel.get() ); + + // if latest mode or empty + if( LATEST.equals( mode.orElse( LATEST ) ) ) { + + // Include latest item only for a name/service combination + stream + = stream.filter(candidate -> candidate.service != null && candidate.created != null ).collect( + Collectors.groupingBy( + data -> new AbstractMap.SimpleEntry<>(data.name, data.service), // name, service combination + Collectors.maxBy(Comparator.comparingLong(data -> data.created)) // latest data item + )).values().stream().filter(Optional::isPresent).map(Optional::get); // if there is a value for the group, then retain it + } + + // sort + if( reverse.isPresent() && reverse.get()) + stream = stream.sorted(CREATED_WHEN_COMPARATOR.reversed()); + else + stream = stream.sorted(CREATED_WHEN_COMPARATOR); + + // skip to offset + if( offset.isPresent() ) stream = stream.skip(offset.get()); + + // truncate to limit + if( limit.isPresent() && limit.get() > 0 ) stream = stream.limit(limit.get()); + + List listCopy1 = stream.collect(Collectors.toList()); + + List listCopy2 = new ArrayList<>(listCopy1.size()); + + // remove metadata from the first copy + if( includeMetadata.isEmpty() || !includeMetadata.get() ) { + for( ArbitraryResourceData data : listCopy1 ) { + ArbitraryResourceData copy = new ArbitraryResourceData(); + copy.name = data.name; + copy.service = data.service; + copy.identifier = data.identifier; + copy.status = data.status; + copy.metadata = null; + + copy.size = data.size; + copy.created = data.created; + copy.updated = data.updated; + + listCopy2.add(copy); + } + } + // put the list copy 1 into the second copy + else { + listCopy2.addAll(listCopy1); + } + + // remove status from final copy + if( includeStatus.isEmpty() || !includeStatus.get() ) { + + List finalCopy = new ArrayList<>(listCopy2.size()); + + for( ArbitraryResourceData data : listCopy2 ) { + ArbitraryResourceData copy = new ArbitraryResourceData(); + copy.name = data.name; + copy.service = data.service; + copy.identifier = data.identifier; + copy.status = null; + copy.metadata = data.metadata; + + copy.size = data.size; + copy.created = data.created; + copy.updated = data.updated; + + finalCopy.add(copy); + } + + return finalCopy; + } + // keep status included by returning the second copy + else { + return listCopy2; + } + } + + /** + * Filter Terms + * + * @param term the term to filter + * @param stringSupplier the string of interest from the resource candidates + * @param prefixOnly true if prexif only, false for contains + * @param stream the stream of candidates + * + * @return the stream that filtered the term + */ + private static Stream filterTerm( + Optional term, + Function stringSupplier, + boolean prefixOnly, + Stream stream) { + + if(term.isPresent()){ + Predicate predicate + = prefixOnly ? getPrefixPredicate(term.get()): getContainsPredicate(term.get()); + stream = stream.filter(candidate -> predicate.test(stringSupplier.apply(candidate))); + } + + return stream; + } + + /** + * Retain Terms + * + * Retain resources that satisfy terms given. + * + * @param terms the terms to retain + * @param stringSupplier the string of interest from the resource candidates + * @param prefixOnly true if prexif only, false for contains + * @param stream the stream of candidates + * + * @return the stream that retained the terms + */ + private static Stream retainTerms( + List terms, + Function stringSupplier, + boolean prefixOnly, + Stream stream) { + + // collect the data to process, start the data to retain + List toProcess = stream.collect(Collectors.toList()); + List toRetain = new ArrayList<>(); + + // for each term, get the predicate, get a new stream process and + // apply the predicate to each data item in the stream + for( String term : terms ) { + Predicate predicate + = prefixOnly ? getPrefixPredicate(term) : getContainsPredicate(term); + toRetain.addAll( + toProcess.stream() + .filter(candidate -> predicate.test(stringSupplier.apply(candidate))) + .collect(Collectors.toList()) + ); + } + + return toRetain.stream(); + } + + private static Predicate getContainsPredicate(String term) { + return value -> value != null && value.toLowerCase().contains(term.toLowerCase()); + } + + private static Predicate getPrefixPredicate(String term) { + return value -> value != null && value.toLowerCase().startsWith(term.toLowerCase()); + } + + /** + * Pass Query + * + * Compare name, identifier, title and description + * + * @param predicate the string comparison predicate + * @param candidate the candiddte to compare + * + * @return true if there is a match, otherwise false + */ + private static boolean passQuery(Predicate predicate, ArbitraryResourceData candidate) { + + if( predicate.test(candidate.name) ) return true; + + if( predicate.test(candidate.identifier) ) return true; + + if( candidate.metadata != null ) { + + if( predicate.test(candidate.metadata.getTitle() )) return true; + if( predicate.test(candidate.metadata.getDescription())) return true; + } + + return false; + } + + /** + * Start Caching + * + * @param priorityRequested the thread priority to fill cache in + * @param frequency the frequency to fill the cache (in seconds) + * + * @return the data cache + */ + public static void startCaching(int priorityRequested, int frequency) { + + Timer timer = buildTimer(DB_CACHE_TIMER, priorityRequested); + + TimerTask task = new TimerTask() { + @Override + public void run() { + + Thread.currentThread().setName(DB_CACHE_TIMER_TASK); + + try (final HSQLDBRepository respository = (HSQLDBRepository) Controller.REPOSITORY_FACTORY.getRepository()) { + fillCache(ArbitraryResourceCache.getInstance(), respository); + } + catch( DataException e ) { + LOGGER.error(e.getMessage(), e); + } + } + }; + + // delay 1 second + timer.scheduleAtFixedRate(task, 1000, frequency * 1000); + } + + /** + * Start Recording Balances + * + * @param balancesByHeight height -> account balances + * @param balanceDynamics every balance dynamic + * @param priorityRequested the requested thread priority + * @param frequency the recording frequencies, in minutes + * @param capacity the maximum size of balanceDynamics + */ + public static void startRecordingBalances( + final ConcurrentHashMap> balancesByHeight, + CopyOnWriteArrayList balanceDynamics, + int priorityRequested, + int frequency, + int capacity) { + + Timer timer = buildTimer(BALANCE_RECORDER_TIMER, priorityRequested); + + TimerTask task = new TimerTask() { + @Override + public void run() { + + Thread.currentThread().setName(BALANCE_RECORDER_TIMER_TASK); + + int currentHeight = recordCurrentBalances(balancesByHeight); + + LOGGER.debug("recorded balances: height = " + currentHeight); + + // remove invalidated recordings, recording after current height + BalanceRecorderUtils.removeRecordingsAboveHeight(currentHeight, balancesByHeight); + + // remove invalidated dynamics, on or after current height + BalanceRecorderUtils.removeDynamicsOnOrAboveHeight(currentHeight, balanceDynamics); + + // if there are 2 or more recordings, then produce balance dynamics for the first 2 recordings + if( balancesByHeight.size() > 1 ) { + + Optional priorHeight = BalanceRecorderUtils.getPriorHeight(currentHeight, balancesByHeight); + + // if there is a prior height + if(priorHeight.isPresent()) { + + boolean isRewardDistribution = BalanceRecorderUtils.isRewardDistributionRange(priorHeight.get(), currentHeight); + + // if this range has a reward recording block or if other blocks are enabled for recording + if( isRewardDistribution || !Settings.getInstance().isRewardRecordingOnly() ) { + produceBalanceDynamics(currentHeight, priorHeight, isRewardDistribution, balancesByHeight, balanceDynamics, capacity); + } + } + else { + LOGGER.warn("Expecting prior height and nothing was discovered, current height = " + currentHeight); + } + } + // else this should be the first recording + else { + LOGGER.info("first balance recording completed"); + } + } + }; + + // wait 5 minutes + timer.scheduleAtFixedRate(task, 300_000, frequency * 60_000); + } + + private static void produceBalanceDynamics(int currentHeight, Optional priorHeight, boolean isRewardDistribution, ConcurrentHashMap> balancesByHeight, CopyOnWriteArrayList balanceDynamics, int capacity) { + BlockHeightRange blockHeightRange = new BlockHeightRange(priorHeight.get(), currentHeight, isRewardDistribution); + + LOGGER.debug("building dynamics for block heights: range = " + blockHeightRange); + + List currentBalances = balancesByHeight.get(currentHeight); + + ArrayList transactions = getTransactionDataForBlocks(blockHeightRange); + + LOGGER.info("transactions counted for balance adjustments: count = " + transactions.size()); + List currentDynamics + = BalanceRecorderUtils.buildBalanceDynamics( + currentBalances, + balancesByHeight.get(priorHeight.get()), + Settings.getInstance().getMinimumBalanceRecording(), + transactions); + + LOGGER.debug("dynamics built: count = " + currentDynamics.size()); + + if(LOGGER.isDebugEnabled()) + currentDynamics.stream() + .sorted(Comparator.comparingLong(AddressAmountData::getAmount).reversed()) + .limit(Settings.getInstance().getTopBalanceLoggingLimit()) + .forEach(top5Dynamic -> LOGGER.debug("Top Dynamics = " + top5Dynamic)); + + BlockHeightRangeAddressAmounts amounts + = new BlockHeightRangeAddressAmounts( blockHeightRange, currentDynamics ); + + balanceDynamics.add(amounts); + + BalanceRecorderUtils.removeRecordingsBelowHeight(currentHeight - Settings.getInstance().getBalanceRecorderRollbackAllowance(), balancesByHeight); + + while(balanceDynamics.size() > capacity) { + BlockHeightRangeAddressAmounts oldestDynamics = BalanceRecorderUtils.removeOldestDynamics(balanceDynamics); + + LOGGER.debug("removing oldest dynamics: range " + oldestDynamics.getRange()); + } + } + + private static ArrayList getTransactionDataForBlocks(BlockHeightRange blockHeightRange) { + ArrayList transactions; + + try (final Repository repository = RepositoryManager.getRepository()) { + List signatures + = repository.getTransactionRepository().getSignaturesMatchingCriteria( + blockHeightRange.getBegin() + 1, blockHeightRange.getEnd() - blockHeightRange.getBegin(), + null, null,null, null, null, + TransactionsResource.ConfirmationStatus.CONFIRMED, + null, null, null); + + transactions = new ArrayList<>(signatures.size()); + for (byte[] signature : signatures) { + transactions.add(repository.getTransactionRepository().fromSignature(signature)); + } + + LOGGER.debug(String.format("Found %s transactions for " + blockHeightRange, transactions.size())); + } catch (Exception e) { + transactions = new ArrayList<>(0); + LOGGER.warn("Problems getting transactions for balance recording: " + e.getMessage()); + } + return transactions; + } + + private static int recordCurrentBalances(ConcurrentHashMap> balancesByHeight) { + int currentHeight; + + try (final HSQLDBRepository repository = (HSQLDBRepository) Controller.REPOSITORY_FACTORY.getRepository()) { + + // get current balances + List accountBalances = getAccountBalances(repository); + + // get anyone of the balances + Optional data = accountBalances.stream().findAny(); + + // if there are any balances, then record them + if (data.isPresent()) { + // map all new balances to the current height + balancesByHeight.put(data.get().getHeight(), accountBalances); + + currentHeight = data.get().getHeight(); + } + else { + currentHeight = Integer.MAX_VALUE; + } + } catch (DataException e) { + LOGGER.error(e.getMessage(), e); + currentHeight = Integer.MAX_VALUE; + } + + return currentHeight; + } + + /** + * Build Timer + * + * Build a timer for scheduling a timer task. + * + * @param name the name for the thread running the timer task + * @param priorityRequested the priority for the thread running the timer task + * + * @return a timer for scheduling a timer task + */ + private static Timer buildTimer( final String name, int priorityRequested) { + // ensure priority is in between 1-10 + final int priority = Math.max(0, Math.min(10, priorityRequested)); + + // Create a custom Timer with updated priority threads + Timer timer = new Timer(true) { // 'true' to make the Timer daemon + @Override + public void schedule(TimerTask task, long delay) { + Thread thread = new Thread(task, name) { + @Override + public void run() { + this.setPriority(priority); + super.run(); + } + }; + thread.setPriority(priority); + thread.start(); + } + }; + return timer; + } + + /** + * Fill Cache + * + * @param cache the cache to fill + * @param repository the data source to fill the cache with + */ + public static void fillCache(ArbitraryResourceCache cache, HSQLDBRepository repository) { + + try { + // ensure all data is committed in, before we query it + repository.saveChanges(); + + List resources = getResources(repository); + + Map> dataByService + = resources.stream() + .collect(Collectors.groupingBy(data -> data.service.value)); + + // lock, clear and refill + synchronized (cache.getDataByService()) { + cache.getDataByService().clear(); + cache.getDataByService().putAll(dataByService); + } + + fillNamepMap(cache.getLevelByName(), repository); + } + catch (SQLNonTransientConnectionException e ) { + LOGGER.warn("Connection problems. Retry later."); + } + catch (Exception e) { + LOGGER.error(e.getMessage(), e); + } + } + + /** + * Fill Name Map + * + * Name -> Level + * + * @param levelByName the map to fill + * @param repository the data source + * + * @throws SQLException + */ + private static void fillNamepMap(ConcurrentHashMap levelByName, HSQLDBRepository repository ) throws SQLException { + + StringBuilder sql = new StringBuilder(512); + + sql.append("SELECT name, level "); + sql.append("FROM NAMES "); + sql.append("INNER JOIN ACCOUNTS on owner = account "); + + Statement statement = repository.connection.createStatement(); + + ResultSet resultSet = statement.executeQuery(sql.toString()); + + if (resultSet == null) + return; + + if (!resultSet.next()) + return; + + do { + levelByName.put(resultSet.getString(1), resultSet.getInt(2)); + } while(resultSet.next()); + } + + /** + * Get Resource + * + * @param repository source data + * + * @return the resources + * @throws SQLException + */ + private static List getResources( HSQLDBRepository repository) throws SQLException { + + List resources = new ArrayList<>(); + + StringBuilder sql = new StringBuilder(512); + + sql.append("SELECT name, service, identifier, size, status, created_when, updated_when, "); + sql.append("title, description, category, tag1, tag2, tag3, tag4, tag5 "); + sql.append("FROM ArbitraryResourcesCache "); + sql.append("LEFT JOIN ArbitraryMetadataCache USING (service, name, identifier) WHERE name IS NOT NULL"); + + List arbitraryResources = new ArrayList<>(); + Statement statement = repository.connection.createStatement(); + + ResultSet resultSet = statement.executeQuery(sql.toString()); + + if (resultSet == null) + return resources; + + if (!resultSet.next()) + return resources; + + do { + String nameResult = resultSet.getString(1); + int serviceResult = resultSet.getInt(2); + String identifierResult = resultSet.getString(3); + Integer sizeResult = resultSet.getInt(4); + Integer status = resultSet.getInt(5); + Long created = resultSet.getLong(6); + Long updated = resultSet.getLong(7); + + String titleResult = resultSet.getString(8); + String descriptionResult = resultSet.getString(9); + String category = resultSet.getString(10); + String tag1 = resultSet.getString(11); + String tag2 = resultSet.getString(12); + String tag3 = resultSet.getString(13); + String tag4 = resultSet.getString(14); + String tag5 = resultSet.getString(15); + + if (Objects.equals(identifierResult, "default")) { + // Map "default" back to null. This is optional but probably less confusing than returning "default". + identifierResult = null; + } + + ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData(); + arbitraryResourceData.name = nameResult; + arbitraryResourceData.service = Service.valueOf(serviceResult); + arbitraryResourceData.identifier = identifierResult; + arbitraryResourceData.size = sizeResult; + arbitraryResourceData.created = created; + arbitraryResourceData.updated = (updated == 0) ? null : updated; + + arbitraryResourceData.setStatus(ArbitraryResourceStatus.Status.valueOf(status)); + + ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata(); + metadata.setTitle(titleResult); + metadata.setDescription(descriptionResult); + metadata.setCategory(Category.uncategorizedValueOf(category)); + + List tags = new ArrayList<>(); + if (tag1 != null) tags.add(tag1); + if (tag2 != null) tags.add(tag2); + if (tag3 != null) tags.add(tag3); + if (tag4 != null) tags.add(tag4); + if (tag5 != null) tags.add(tag5); + metadata.setTags(!tags.isEmpty() ? tags : null); + + if (metadata.hasMetadata()) { + arbitraryResourceData.metadata = metadata; + } + + resources.add( arbitraryResourceData ); + } while (resultSet.next()); + + return resources; + } + + public static List getAccountBalances(HSQLDBRepository repository) { + + StringBuilder sql = new StringBuilder(); + + sql.append("SELECT account, balance, height "); + sql.append("FROM ACCOUNTBALANCES as balances "); + sql.append("JOIN (SELECT height FROM BLOCKS ORDER BY height DESC LIMIT 1) AS max_height ON true "); + sql.append("WHERE asset_id=0"); + + List data = new ArrayList<>(); + + LOGGER.info( "Getting account balances ..."); + + try { + Statement statement = repository.connection.createStatement(); + + ResultSet resultSet = statement.executeQuery(sql.toString()); + + if (resultSet == null || !resultSet.next()) + return new ArrayList<>(0); + + do { + String account = resultSet.getString(1); + long balance = resultSet.getLong(2); + int height = resultSet.getInt(3); + + data.add(new AccountBalanceData(account, ZERO, balance, height)); + } while (resultSet.next()); + } catch (SQLException e) { + LOGGER.warn(e.getMessage()); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + } + + LOGGER.info("Retrieved account balances: count = " + data.size()); + + return data; + } +} \ 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 571a587d..80865739 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -23,7 +23,7 @@ public class HSQLDBChatRepository implements ChatRepository { public HSQLDBChatRepository(HSQLDBRepository repository) { this.repository = repository; } - + @Override public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] referenceBytes, byte[] chatReferenceBytes, Boolean hasChatReference, List involving, String senderAddress, @@ -176,14 +176,14 @@ public class HSQLDBChatRepository implements ChatRepository { } @Override - public ActiveChats getActiveChats(String address, Encoding encoding) throws DataException { - List groupChats = getActiveGroupChats(address, encoding); - List directChats = getActiveDirectChats(address); + public ActiveChats getActiveChats(String address, Encoding encoding, Boolean hasChatReference) throws DataException { + List groupChats = getActiveGroupChats(address, encoding, hasChatReference); + List directChats = getActiveDirectChats(address, hasChatReference); return new ActiveChats(groupChats, directChats); } - - private List getActiveGroupChats(String address, Encoding encoding) throws DataException { + + private List getActiveGroupChats(String address, Encoding encoding, Boolean hasChatReference) 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 " @@ -194,11 +194,19 @@ public class HSQLDBChatRepository implements ChatRepository { + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " // NOTE: We need to qualify "Groups.group_id" here to avoid "General error" bug in HSQLDB v2.5.0 - + "WHERE tx_group_id = Groups.group_id AND type = " + TransactionType.CHAT.value + " " - + "ORDER BY created_when DESC " + + "WHERE tx_group_id = Groups.group_id AND type = " + TransactionType.CHAT.value + " "; + + if (hasChatReference != null) { + if (hasChatReference) { + groupsSql += "AND chat_reference IS NOT NULL "; + } else { + groupsSql += "AND chat_reference IS NULL "; + } + } + groupsSql += "ORDER BY created_when DESC " + "LIMIT 1" - + ") AS LatestMessages ON TRUE " - + "WHERE address = ?"; + + ") AS LatestMessages ON TRUE " + + "WHERE address = ?"; List groupChats = new ArrayList<>(); try (ResultSet resultSet = this.repository.checkedExecute(groupsSql, address)) { @@ -230,8 +238,16 @@ public class HSQLDBChatRepository implements ChatRepository { + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " + "WHERE tx_group_id = 0 " - + "AND recipient IS NULL " - + "ORDER BY created_when DESC " + + "AND recipient IS NULL "; + + if (hasChatReference != null) { + if (hasChatReference) { + grouplessSql += "AND chat_reference IS NOT NULL "; + } else { + grouplessSql += "AND chat_reference IS NULL "; + } + } + grouplessSql += "ORDER BY created_when DESC " + "LIMIT 1"; try (ResultSet resultSet = this.repository.checkedExecute(grouplessSql)) { @@ -259,7 +275,7 @@ public class HSQLDBChatRepository implements ChatRepository { return groupChats; } - private List getActiveDirectChats(String address) throws DataException { + private List getActiveDirectChats(String address, Boolean hasChatReference) throws DataException { // Find chat messages involving address String directSql = "SELECT other_address, name, latest_timestamp, sender, sender_name " + "FROM (" @@ -275,11 +291,21 @@ public class HSQLDBChatRepository implements ChatRepository { + "NATURAL JOIN Transactions " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " + "WHERE (sender = other_address AND recipient = ?) " - + "OR (sender = ? AND recipient = other_address) " - + "ORDER BY created_when DESC " - + "LIMIT 1" - + ") AS LatestMessages " - + "LEFT OUTER JOIN Names ON owner = other_address"; + + "OR (sender = ? AND recipient = other_address) "; + + // Apply hasChatReference filter + if (hasChatReference != null) { + if (hasChatReference) { + directSql += "AND chat_reference IS NOT NULL "; + } else { + directSql += "AND chat_reference IS NULL "; + } + } + + directSql += "ORDER BY created_when DESC " + + "LIMIT 1" + + ") AS LatestMessages " + + "LEFT OUTER JOIN Names ON owner = other_address"; Object[] bindParams = new Object[] { address, address, address, address }; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 54af22e9..ca55f3a8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -454,40 +454,41 @@ public class HSQLDBDatabaseUpdates { case 12: // Groups - stmt.execute("CREATE TABLE Groups (group_id GroupID, owner QortalAddress NOT NULL, group_name GroupName NOT NULL, " + // NOTE: We need to set Groups to `GROUPS` here to avoid SQL Standard Keywords in HSQLDB v2.7.4 + stmt.execute("CREATE TABLE `GROUPS` (group_id GroupID, owner QortalAddress NOT NULL, group_name GroupName NOT NULL, " + "created_when EpochMillis NOT NULL, updated_when EpochMillis, is_open BOOLEAN NOT NULL, " + "approval_threshold TINYINT NOT NULL, min_block_delay INTEGER NOT NULL, max_block_delay INTEGER NOT NULL, " + "reference Signature, creation_group_id GroupID, reduced_group_name GroupName NOT NULL, " + "description GenericDescription NOT NULL, PRIMARY KEY (group_id))"); // For finding groups by name - stmt.execute("CREATE INDEX GroupNameIndex on Groups (group_name)"); + stmt.execute("CREATE INDEX GroupNameIndex on `GROUPS` (group_name)"); // For finding groups by reduced name - stmt.execute("CREATE INDEX GroupReducedNameIndex on Groups (reduced_group_name)"); + stmt.execute("CREATE INDEX GroupReducedNameIndex on `GROUPS` (reduced_group_name)"); // For finding groups by owner - stmt.execute("CREATE INDEX GroupOwnerIndex ON Groups (owner)"); + stmt.execute("CREATE INDEX GroupOwnerIndex ON `GROUPS` (owner)"); // We need a corresponding trigger to make sure new group_id values are assigned sequentially starting from 1 - stmt.execute("CREATE TRIGGER Group_ID_Trigger BEFORE INSERT ON Groups " + stmt.execute("CREATE TRIGGER Group_ID_Trigger BEFORE INSERT ON `GROUPS` " + "REFERENCING NEW ROW AS new_row FOR EACH ROW WHEN (new_row.group_id IS NULL) " - + "SET new_row.group_id = (SELECT IFNULL(MAX(group_id) + 1, 1) FROM Groups)"); + + "SET new_row.group_id = (SELECT IFNULL(MAX(group_id) + 1, 1) FROM `GROUPS`)"); // Admins stmt.execute("CREATE TABLE GroupAdmins (group_id GroupID, admin QortalAddress, reference Signature NOT NULL, " - + "PRIMARY KEY (group_id, admin), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, admin), FOREIGN KEY (group_id) REFERENCES `GROUPS` (group_id) ON DELETE CASCADE)"); // For finding groups by admin address stmt.execute("CREATE INDEX GroupAdminIndex ON GroupAdmins (admin)"); // Members stmt.execute("CREATE TABLE GroupMembers (group_id GroupID, address QortalAddress, " + "joined_when EpochMillis NOT NULL, reference Signature NOT NULL, " - + "PRIMARY KEY (group_id, address), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, address), FOREIGN KEY (group_id) REFERENCES `GROUPS` (group_id) ON DELETE CASCADE)"); // For finding groups by member address stmt.execute("CREATE INDEX GroupMemberIndex ON GroupMembers (address)"); // Invites stmt.execute("CREATE TABLE GroupInvites (group_id GroupID, inviter QortalAddress, invitee QortalAddress, " + "expires_when EpochMillis, reference Signature, " - + "PRIMARY KEY (group_id, invitee), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, invitee), FOREIGN KEY (group_id) REFERENCES `GROUPS` (group_id) ON DELETE CASCADE)"); // For finding invites sent by inviter stmt.execute("CREATE INDEX GroupInviteInviterIndex ON GroupInvites (inviter)"); // For finding invites by group @@ -503,7 +504,7 @@ public class HSQLDBDatabaseUpdates { // NULL expires_when means does not expire! stmt.execute("CREATE TABLE GroupBans (group_id GroupID, offender QortalAddress, admin QortalAddress NOT NULL, " + "banned_when EpochMillis NOT NULL, reason GenericDescription NOT NULL, expires_when EpochMillis, reference Signature NOT NULL, " - + "PRIMARY KEY (group_id, offender), FOREIGN KEY (group_id) REFERENCES Groups (group_id) ON DELETE CASCADE)"); + + "PRIMARY KEY (group_id, offender), FOREIGN KEY (group_id) REFERENCES `GROUPS` (group_id) ON DELETE CASCADE)"); // For expiry maintenance stmt.execute("CREATE INDEX GroupBanExpiryIndex ON GroupBans (expires_when)"); break; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java index 9c7521fc..a15582e2 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java @@ -351,7 +351,7 @@ public class HSQLDBGroupRepository implements GroupRepository { // Group Admins @Override - public GroupAdminData getAdmin(int groupId, String address) throws DataException { + public GroupAdminData getAdminFaulty(int groupId, String address) throws DataException { try (ResultSet resultSet = this.repository.checkedExecute("SELECT admin, reference FROM GroupAdmins WHERE group_id = ?", groupId)) { if (resultSet == null) return null; @@ -365,6 +365,21 @@ public class HSQLDBGroupRepository implements GroupRepository { } } + @Override + public GroupAdminData getAdmin(int groupId, String address) throws DataException { + try (ResultSet resultSet = this.repository.checkedExecute("SELECT admin, reference FROM GroupAdmins WHERE group_id = ? AND admin = ?", groupId, address)) { + if (resultSet == null) + return null; + + String admin = resultSet.getString(1); + byte[] reference = resultSet.getBytes(2); + + return new GroupAdminData(groupId, admin, reference); + } catch (SQLException e) { + throw new DataException("Unable to fetch group admin from repository", e); + } + } + @Override public boolean adminExists(int groupId, String address) throws DataException { try { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java index fdaf41a2..2ddabf8d 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java @@ -5,6 +5,8 @@ import org.apache.logging.log4j.Logger; import org.hsqldb.HsqlException; import org.hsqldb.error.ErrorCode; import org.hsqldb.jdbc.HSQLDBPool; +import org.hsqldb.jdbc.HSQLDBPoolMonitored; +import org.qortal.data.system.DbConnectionInfo; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryFactory; @@ -14,6 +16,8 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; import java.util.Properties; public class HSQLDBRepositoryFactory implements RepositoryFactory { @@ -57,7 +61,13 @@ public class HSQLDBRepositoryFactory implements RepositoryFactory { HSQLDBRepository.attemptRecovery(connectionUrl, "backup"); } - this.connectionPool = new HSQLDBPool(Settings.getInstance().getRepositoryConnectionPoolSize()); + if(Settings.getInstance().isConnectionPoolMonitorEnabled()) { + this.connectionPool = new HSQLDBPoolMonitored(Settings.getInstance().getRepositoryConnectionPoolSize()); + } + else { + this.connectionPool = new HSQLDBPool(Settings.getInstance().getRepositoryConnectionPoolSize()); + } + this.connectionPool.setUrl(this.connectionUrl); Properties properties = new Properties(); @@ -153,4 +163,19 @@ public class HSQLDBRepositoryFactory implements RepositoryFactory { return HSQLDBRepository.isDeadlockException(e); } + /** + * Get Connection States + * + * Get the database connection states, if database connection pool monitoring is enabled. + * + * @return the connection states if enabled, otherwise an empty list + */ + public List getDbConnectionsStates() { + if( Settings.getInstance().isConnectionPoolMonitorEnabled() ) { + return ((HSQLDBPoolMonitored) this.connectionPool).getDbConnectionsStates(); + } + else { + return new ArrayList<>(0); + } + } } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index de0ce5ed..f3f84e12 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -114,6 +114,8 @@ public class Settings { /** Whether we check, fetch and install auto-updates */ private boolean autoUpdateEnabled = true; + /** Whether we check, restart node without connected peers */ + private boolean autoRestartEnabled = false; /** How long between repository backups (ms), or 0 if disabled. */ private long repositoryBackupInterval = 0; // ms /** Whether to show a notification when we backup repository. */ @@ -197,32 +199,32 @@ public class Settings { /** Target number of outbound connections to peers we should make. */ private int minOutboundPeers = 32; /** Maximum number of peer connections we allow. */ - private int maxPeers = 60; + private int maxPeers = 64; /** Number of slots to reserve for short-lived QDN data transfers */ private int maxDataPeers = 5; /** Maximum number of threads for network engine. */ - private int maxNetworkThreadPoolSize = 620; + private int maxNetworkThreadPoolSize = 512; /** Maximum number of threads for network proof-of-work compute, used during handshaking. */ - private int networkPoWComputePoolSize = 2; + private int networkPoWComputePoolSize = 4; /** Maximum number of retry attempts if a peer fails to respond with the requested data */ - private int maxRetries = 2; + private int maxRetries = 3; /** The number of seconds of no activity before recovery mode begins */ public long recoveryModeTimeout = 9999999999999L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "4.5.1"; + private String minPeerVersion = "4.6.5"; /** 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 */ private boolean allowConnectionsWithOlderPeerVersions = true; /** Minimum time (in seconds) that we should attempt to remain connected to a peer for */ - private int minPeerConnectionTime = 60 * 60; // seconds + private int minPeerConnectionTime = 2 * 60 * 60; // seconds /** Maximum time (in seconds) that we should attempt to remain connected to a peer for */ - private int maxPeerConnectionTime = 4 * 60 * 60; // seconds + private int maxPeerConnectionTime = 6 * 60 * 60; // seconds /** Maximum time (in seconds) that a peer should remain connected when requesting QDN data */ - private int maxDataPeerConnectionTime = 2 * 60; // seconds + private int maxDataPeerConnectionTime = 30 * 60; // seconds /** Whether to sync multiple blocks at once in normal operation */ private boolean fastSyncEnabled = true; @@ -272,13 +274,17 @@ public class Settings { private String[] bootstrapHosts = new String[] { "http://bootstrap.qortal.org", "http://bootstrap2.qortal.org", - "http://bootstrap3.qortal.org" + "http://bootstrap3.qortal.org", + "http://bootstrap4.qortal.org" }; // Auto-update sources private String[] autoUpdateRepos = new String[] { "https://github.com/Qortal/qortal/raw/%s/qortal.update", - "https://raw.githubusercontent.com@151.101.16.133/Qortal/qortal/%s/qortal.update" + "https://raw.githubusercontent.com@151.101.16.133/Qortal/qortal/%s/qortal.update", + "https://qortal.link/Auto-Update/%s/qortal.update", + "https://qortal.name/Auto-Update/%s/qortal.update", + "https://update.qortal.org/Auto-Update/%s/qortal.update" }; // Lists @@ -323,11 +329,14 @@ public class Settings { /* Foreign chains */ /** The number of consecutive empty addresses required before treating a wallet's transaction set as complete */ - private int gapLimit = 24; + private int gapLimit = 3; /** How many wallet keys to generate when using bitcoinj as the blockchain interface (e.g. when sending coins) */ private int bitcoinjLookaheadSize = 50; + /** How many units of data to be kept in a blockchain cache before the cache should be reduced or cleared. */ + private int blockchainCacheLimit = 1000; + // Data storage (QDN) /** Data storage enabled/disabled*/ @@ -374,6 +383,167 @@ public class Settings { * Exclude from settings.json to disable this warning. */ private Integer threadCountPerMessageTypeWarningThreshold = null; + /** + * DB Cache Enabled? + */ + private boolean dbCacheEnabled = true; + + /** + * DB Cache Thread Priority + * + * If DB Cache is disabled, then this is ignored. If value is lower then 1, than 1 is used. If value is higher + * than 10,, then 10 is used. + */ + private int dbCacheThreadPriority = 1; + + /** + * DB Cache Frequency + * + * The number of seconds in between DB cache updates. If DB Cache is disabled, then this is ignored. + */ + private int dbCacheFrequency = 120; + + /** + * Network Thread Priority + * + * The Network Thread Priority + * + * The thread priority (1 is lowest, 10 is highest) of the threads used for network peer connections. This is the + * main thread connecting to a peer in the network. + */ + private int networkThreadPriority = 7; + + /** + * The Handshake Thread Priority + * + * The thread priority (1 i slowest, 10 is highest) of the threads used for peer handshake messaging. This is a + * secondary thread to exchange status messaging to a peer in the network. + */ + private int handshakeThreadPriority = 7; + + /** + * Pruning Thread Priority + * + * The thread priority (1 is lowest, 10 is highest) of the threads used for database pruning and trimming. + */ + private int pruningThreadPriority = 2; + + /** + * Sychronizer Thread Priority + * + * The thread priority (1 is lowest, 10 is highest) of the threads used for synchronizing with the others peers. + */ + private int synchronizerThreadPriority = 10; + + /** + * Archiving Pause + * + * In milliseconds + * + * The pause in between archiving blocks to allow other processes to execute. + */ + private long archivingPause = 3000; + + /** + * Enable Balance Recorder? + * + * True for balance recording, otherwise false. + */ + private boolean balanceRecorderEnabled = false; + + /** + * Balance Recorder Priority + * + * The thread priority (1 is lowest, 10 is highest) of the balance recorder thread, if enabled. + */ + private int balanceRecorderPriority = 1; + + /** + * Balance Recorder Frequency + * + * How often the balances will be recorded, if enabled, measured in minutes. + */ + private int balanceRecorderFrequency = 20; + + /** + * Balance Recorder Capacity + * + * The number of balance recorder ranges will be held in memory. + */ + private int balanceRecorderCapacity = 1000; + + /** + * Minimum Balance Recording + * + * The minimum recored balance change in Qortoshis (1/100000000 QORT) + */ + private long minimumBalanceRecording = 100000000; + + /** + * Top Balance Logging Limit + * + * When logging the number limit of top balance changes to show in the logs for any given block range. + */ + private long topBalanceLoggingLimit = 100; + + /** + * Balance Recorder Rollback Allowance + * + * If the balance recorder is enabled, it must protect its prior balances by this number of blocks in case of + * a blockchain rollback and reorganization. + */ + private int balanceRecorderRollbackAllowance = 100; + + /** + * Is Reward Recording Only + * + * Set true to only retain the recordings that cover reward distributions, otherwise set false. + */ + private boolean rewardRecordingOnly = true; + + /** + * Is The Connection Monitored? + * + * Is the database connection pooled monitored? + */ + private boolean connectionPoolMonitorEnabled = false; + + /** + * Buiild Arbitrary Resources Batch Size + * + * The number resources to batch per iteration when rebuilding. + */ + private int buildArbitraryResourcesBatchSize = 200; + + /** + * Arbitrary Indexing Priority + * + * The thread priority when indexing arbirary resources. + */ + private int arbitraryIndexingPriority = 5; + + /** + * Arbitrary Indexing Frequency (In Minutes) + * + * The frequency at which the arbitrary indices are cached. + */ + private int arbitraryIndexingFrequency = 10; + + private boolean rebuildArbitraryResourceCacheTaskEnabled = false; + + /** + * Rebuild Arbitrary Resource Cache Task Delay (In Minutes) + * + * Waiting period before the first rebuild task is started. + */ + private int rebuildArbitraryResourceCacheTaskDelay = 300; + + /** + * Rebuild Arbitrary Resource Cache Task Period (In Hours) + * + * The frequency the arbitrary resource cache is rebuilt. + */ + private int rebuildArbitraryResourceCacheTaskPeriod = 24; // Domain mapping public static class ThreadLimit { @@ -444,6 +614,15 @@ public class Settings { } } + // Related to Reticulum networking + + /** Maximum number of Reticulum peers allowed. */ + private int reticulumMaxPeers = 55; + /** Minimum number of Reticulum peers desired. */ + private int reticulumMinDesiredPeers = 3; + /** Maximum number of task executor network threads */ + private int reticulumMaxNetworkThreadPoolSize = 89; + // Constructors private Settings() { @@ -909,6 +1088,10 @@ public class Settings { return this.autoUpdateEnabled; } + public boolean isAutoRestartEnabled() { + return this.autoRestartEnabled; + } + public String[] getAutoUpdateRepos() { return this.autoUpdateRepos; } @@ -1049,6 +1232,9 @@ public class Settings { return bitcoinjLookaheadSize; } + public int getBlockchainCacheLimit() { + return blockchainCacheLimit; + } public boolean isQdnEnabled() { return this.qdnEnabled; @@ -1125,4 +1311,108 @@ public class Settings { public Integer getThreadCountPerMessageTypeWarningThreshold() { return this.threadCountPerMessageTypeWarningThreshold; } + + public boolean isDbCacheEnabled() { + return dbCacheEnabled; + } + + public int getDbCacheThreadPriority() { + return dbCacheThreadPriority; + } + + public int getDbCacheFrequency() { + return dbCacheFrequency; + } + + public int getNetworkThreadPriority() { + return networkThreadPriority; + } + + public int getHandshakeThreadPriority() { + return handshakeThreadPriority; + } + + public int getPruningThreadPriority() { + return pruningThreadPriority; + } + + public int getSynchronizerThreadPriority() { + return synchronizerThreadPriority; + } + + public long getArchivingPause() { + return archivingPause; + } + + public int getBalanceRecorderPriority() { + return balanceRecorderPriority; + } + + public int getBalanceRecorderFrequency() { + return balanceRecorderFrequency; + } + + public int getBalanceRecorderCapacity() { + return balanceRecorderCapacity; + } + + public boolean isBalanceRecorderEnabled() { + return balanceRecorderEnabled; + } + + public long getMinimumBalanceRecording() { + return minimumBalanceRecording; + } + + public long getTopBalanceLoggingLimit() { + return topBalanceLoggingLimit; + } + + public int getBalanceRecorderRollbackAllowance() { + return balanceRecorderRollbackAllowance; + } + + public boolean isRewardRecordingOnly() { + return rewardRecordingOnly; + } + + public boolean isConnectionPoolMonitorEnabled() { + return connectionPoolMonitorEnabled; + } + + public int getReticulumMaxPeers() { + return this.reticulumMaxPeers; + } + + public int getReticulumMinDesiredPeers() { + return this.reticulumMinDesiredPeers; + } + + public int getReticulumMaxNetworkThreadPoolSize() { + return this.reticulumMaxNetworkThreadPoolSize; + } + + public int getBuildArbitraryResourcesBatchSize() { + return buildArbitraryResourcesBatchSize; + } + + public int getArbitraryIndexingPriority() { + return arbitraryIndexingPriority; + } + + public int getArbitraryIndexingFrequency() { + return arbitraryIndexingFrequency; + } + + public boolean isRebuildArbitraryResourceCacheTaskEnabled() { + return rebuildArbitraryResourceCacheTaskEnabled; + } + + public int getRebuildArbitraryResourceCacheTaskDelay() { + return rebuildArbitraryResourceCacheTaskDelay; + } + + public int getRebuildArbitraryResourceCacheTaskPeriod() { + return rebuildArbitraryResourceCacheTaskPeriod; + } } diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index 1a9f888b..a51913e0 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.arbitrary.metadata.ArbitraryDataTransactionMetadata; import org.qortal.arbitrary.misc.Service; import org.qortal.block.BlockChain; import org.qortal.controller.arbitrary.ArbitraryDataManager; +import org.qortal.controller.arbitrary.ArbitraryTransactionDataHashWrapper; import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.crypto.Crypto; import org.qortal.crypto.MemoryPoW; @@ -31,8 +32,12 @@ import org.qortal.utils.ArbitraryTransactionUtils; import org.qortal.utils.NTP; import java.io.IOException; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; public class ArbitraryTransaction extends Transaction { @@ -303,8 +308,13 @@ public class ArbitraryTransaction extends Transaction { // Add/update arbitrary resource caches, but don't update the status as this involves time-consuming // disk reads, and is more prone to failure. The status will be updated on metadata retrieval, or when // accessing the resource. - this.updateArbitraryResourceCache(repository); - this.updateArbitraryMetadataCache(repository); + // Also, must add this transaction as a latest transaction, since the it has not been saved to the + // repository yet. + this.updateArbitraryResourceCacheIncludingMetadata( + repository, + Set.of(new ArbitraryTransactionDataHashWrapper(arbitraryTransactionData)), + new HashMap<>(0) + ); repository.saveChanges(); @@ -360,7 +370,10 @@ public class ArbitraryTransaction extends Transaction { * * @throws DataException */ - public void updateArbitraryResourceCache(Repository repository) throws DataException { + public void updateArbitraryResourceCacheIncludingMetadata( + Repository repository, + Set latestTransactionWrappers, + Map resourceByWrapper) throws DataException { // Don't cache resources without a name (such as auto updates) if (arbitraryTransactionData.getName() == null) { return; @@ -385,17 +398,33 @@ public class ArbitraryTransaction extends Transaction { arbitraryResourceData.name = name; arbitraryResourceData.identifier = identifier; - // Get the latest transaction - ArbitraryTransactionData latestTransactionData = repository.getArbitraryRepository().getLatestTransaction(arbitraryTransactionData.getName(), arbitraryTransactionData.getService(), null, arbitraryTransactionData.getIdentifier()); - if (latestTransactionData == null) { - // We don't have a latest transaction, so delete from cache - repository.getArbitraryRepository().delete(arbitraryResourceData); - return; - } + final ArbitraryTransactionDataHashWrapper wrapper = new ArbitraryTransactionDataHashWrapper(arbitraryTransactionData); - // Get existing cached entry if it exists - ArbitraryResourceData existingArbitraryResourceData = repository.getArbitraryRepository() - .getArbitraryResource(service, name, identifier); + ArbitraryTransactionData latestTransactionData; + if( latestTransactionWrappers.contains(wrapper)) { + latestTransactionData + = latestTransactionWrappers.stream() + .filter( latestWrapper -> latestWrapper.equals(wrapper)) + .findAny().get() + .getData(); + } + else { + // Get the latest transaction + latestTransactionData = repository.getArbitraryRepository().getLatestTransaction(arbitraryTransactionData.getName(), arbitraryTransactionData.getService(), null, arbitraryTransactionData.getIdentifier()); + if (latestTransactionData == null) { + LOGGER.info("We don't have a latest transaction, so delete from cache: arbitraryResourceData = " + arbitraryResourceData); + // We don't have a latest transaction, so delete from cache + repository.getArbitraryRepository().delete(arbitraryResourceData); + return; + } + } + ArbitraryResourceData existingArbitraryResourceData = resourceByWrapper.get(wrapper); + + if( existingArbitraryResourceData == null ) { + // Get existing cached entry if it exists + existingArbitraryResourceData = repository.getArbitraryRepository() + .getArbitraryResource(service, name, identifier); + } // Check for existing cached data if (existingArbitraryResourceData == null) { @@ -404,6 +433,7 @@ public class ArbitraryTransaction extends Transaction { arbitraryResourceData.updated = null; } else { + resourceByWrapper.put(wrapper, existingArbitraryResourceData); // An entry already exists - update created time from current transaction if this is older arbitraryResourceData.created = Math.min(existingArbitraryResourceData.created, arbitraryTransactionData.getTimestamp()); @@ -421,6 +451,34 @@ public class ArbitraryTransaction extends Transaction { // Save repository.getArbitraryRepository().save(arbitraryResourceData); + + // Update metadata for latest transaction if it is local + if (latestTransactionData.getMetadataHash() != null) { + ArbitraryDataFile metadataFile = ArbitraryDataFile.fromHash(latestTransactionData.getMetadataHash(), latestTransactionData.getSignature()); + if (metadataFile.exists()) { + ArbitraryDataTransactionMetadata transactionMetadata = new ArbitraryDataTransactionMetadata(metadataFile.getFilePath()); + try { + transactionMetadata.read(); + + ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata(); + metadata.setArbitraryResourceData(arbitraryResourceData); + metadata.setTitle(transactionMetadata.getTitle()); + metadata.setDescription(transactionMetadata.getDescription()); + metadata.setCategory(transactionMetadata.getCategory()); + metadata.setTags(transactionMetadata.getTags()); + repository.getArbitraryRepository().save(metadata); + + } catch (IOException e) { + // Ignore, as we can add it again later + } + } else { + // We don't have a local copy of this metadata file, so delete it from the cache + // It will be re-added if the file later arrives via the network + ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata(); + metadata.setArbitraryResourceData(arbitraryResourceData); + repository.getArbitraryRepository().delete(metadata); + } + } } public void updateArbitraryResourceStatus(Repository repository) throws DataException { @@ -455,60 +513,4 @@ public class ArbitraryTransaction extends Transaction { repository.getArbitraryRepository().setStatus(arbitraryResourceData, status); } - public void updateArbitraryMetadataCache(Repository repository) throws DataException { - // Get the latest transaction - ArbitraryTransactionData latestTransactionData = repository.getArbitraryRepository().getLatestTransaction(arbitraryTransactionData.getName(), arbitraryTransactionData.getService(), null, arbitraryTransactionData.getIdentifier()); - if (latestTransactionData == null) { - // We don't have a latest transaction, so give up - return; - } - - Service service = latestTransactionData.getService(); - String name = latestTransactionData.getName(); - String identifier = latestTransactionData.getIdentifier(); - - if (service == null) { - // Unsupported service - ignore this resource - return; - } - - // In the cache we store null identifiers as "default", as it is part of the primary key - if (identifier == null) { - identifier = "default"; - } - - ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData(); - arbitraryResourceData.service = service; - arbitraryResourceData.name = name; - arbitraryResourceData.identifier = identifier; - - // Update metadata for latest transaction if it is local - if (latestTransactionData.getMetadataHash() != null) { - ArbitraryDataFile metadataFile = ArbitraryDataFile.fromHash(latestTransactionData.getMetadataHash(), latestTransactionData.getSignature()); - if (metadataFile.exists()) { - ArbitraryDataTransactionMetadata transactionMetadata = new ArbitraryDataTransactionMetadata(metadataFile.getFilePath()); - try { - transactionMetadata.read(); - - ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata(); - metadata.setArbitraryResourceData(arbitraryResourceData); - metadata.setTitle(transactionMetadata.getTitle()); - metadata.setDescription(transactionMetadata.getDescription()); - metadata.setCategory(transactionMetadata.getCategory()); - metadata.setTags(transactionMetadata.getTags()); - repository.getArbitraryRepository().save(metadata); - - } catch (IOException e) { - // Ignore, as we can add it again later - } - } else { - // We don't have a local copy of this metadata file, so delete it from the cache - // It will be re-added if the file later arrives via the network - ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata(); - metadata.setArbitraryResourceData(arbitraryResourceData); - repository.getArbitraryRepository().delete(metadata); - } - } - } - } diff --git a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java index f3511ded..95a267f3 100644 --- a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.data.group.GroupData; import org.qortal.data.transaction.CancelGroupBanTransactionData; @@ -12,6 +13,7 @@ import org.qortal.repository.Repository; import java.util.Collections; import java.util.List; +import java.util.Objects; public class CancelGroupBanTransaction extends Transaction { @@ -70,9 +72,26 @@ public class CancelGroupBanTransaction extends Transaction { if (!this.repository.getGroupRepository().adminExists(groupId, admin.getAddress())) return ValidationResult.NOT_GROUP_ADMIN; - // Can't unban if not group's current owner - if (!admin.getAddress().equals(groupData.getOwner())) - return ValidationResult.INVALID_GROUP_OWNER; + if( this.repository.getBlockRepository().getBlockchainHeight() < BlockChain.getInstance().getNullGroupMembershipHeight() ) { + // Can't cancel ban if not group's current owner + if (!admin.getAddress().equals(groupData.getOwner())) + return ValidationResult.INVALID_GROUP_OWNER; + } + // if( this.repository.getBlockRepository().getBlockchainHeight() >= BlockChain.getInstance().getNullGroupMembershipHeight() ) + else { + String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); + + // if null ownership group, then check for admin approval + if(groupOwnedByNullAccount ) { + // Require approval if transaction relates to a group owned by the null account + if (!this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + } + // Can't cancel ban if not group's current owner + else if (!admin.getAddress().equals(groupData.getOwner())) + return ValidationResult.INVALID_GROUP_OWNER; + } Account member = getMember(); diff --git a/src/main/java/org/qortal/transaction/CancelGroupInviteTransaction.java b/src/main/java/org/qortal/transaction/CancelGroupInviteTransaction.java index d4306bbe..678aa411 100644 --- a/src/main/java/org/qortal/transaction/CancelGroupInviteTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelGroupInviteTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.data.group.GroupData; import org.qortal.data.transaction.CancelGroupInviteTransactionData; @@ -12,6 +13,7 @@ import org.qortal.repository.Repository; import java.util.Collections; import java.util.List; +import java.util.Objects; public class CancelGroupInviteTransaction extends Transaction { @@ -80,6 +82,16 @@ public class CancelGroupInviteTransaction extends Transaction { if (admin.getConfirmedBalance(Asset.QORT) < this.cancelGroupInviteTransactionData.getFee()) return ValidationResult.NO_BALANCE; + // if null ownership group, then check for admin approval + if( this.repository.getBlockRepository().getBlockchainHeight() >= BlockChain.getInstance().getNullGroupMembershipHeight() ) { + String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); + + // Require approval if transaction relates to a group owned by the null account + if (groupOwnedByNullAccount && !this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + } + return ValidationResult.OK; } diff --git a/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java b/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java index aa0e6a6f..bc37a11a 100644 --- a/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java @@ -3,6 +3,7 @@ package org.qortal.transaction; import com.google.common.base.Utf8; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.block.BlockChain; import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.data.naming.NameData; import org.qortal.data.transaction.CancelSellNameTransactionData; @@ -63,8 +64,11 @@ public class CancelSellNameTransaction extends Transaction { return ValidationResult.NAME_DOES_NOT_EXIST; // Check name is currently for sale - if (!nameData.isForSale()) - return ValidationResult.NAME_NOT_FOR_SALE; + if (!nameData.isForSale()) { + // Only validate after feature-trigger timestamp, due to a small number of double cancelations in the chain history + if (this.cancelSellNameTransactionData.getTimestamp() > BlockChain.getInstance().getCancelSellNameValidationTimestamp()) + return ValidationResult.NAME_NOT_FOR_SALE; + } // Check transaction creator matches name's current owner Account owner = getOwner(); diff --git a/src/main/java/org/qortal/transaction/GroupBanTransaction.java b/src/main/java/org/qortal/transaction/GroupBanTransaction.java index 1716d206..143a66fb 100644 --- a/src/main/java/org/qortal/transaction/GroupBanTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupBanTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.data.group.GroupData; import org.qortal.data.transaction.GroupBanTransactionData; @@ -12,6 +13,7 @@ import org.qortal.repository.Repository; import java.util.Collections; import java.util.List; +import java.util.Objects; public class GroupBanTransaction extends Transaction { @@ -70,9 +72,25 @@ public class GroupBanTransaction extends Transaction { if (!this.repository.getGroupRepository().adminExists(groupId, admin.getAddress())) return ValidationResult.NOT_GROUP_ADMIN; - // Can't ban if not group's current owner - if (!admin.getAddress().equals(groupData.getOwner())) - return ValidationResult.INVALID_GROUP_OWNER; + if( this.repository.getBlockRepository().getBlockchainHeight() < BlockChain.getInstance().getNullGroupMembershipHeight() ) { + // Can't ban if not group's current owner + if (!admin.getAddress().equals(groupData.getOwner())) + return ValidationResult.INVALID_GROUP_OWNER; + } + // if( this.repository.getBlockRepository().getBlockchainHeight() >= BlockChain.getInstance().getNullGroupMembershipHeight() ) + else { + String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); + + // if null ownership group, then check for admin approval + if(groupOwnedByNullAccount ) { + // Require approval if transaction relates to a group owned by the null account + if (!this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + } + else if (!admin.getAddress().equals(groupData.getOwner())) + return ValidationResult.INVALID_GROUP_OWNER; + } Account offender = getOffender(); diff --git a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java index e58d1b9c..96179d1b 100644 --- a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.data.transaction.GroupInviteTransactionData; import org.qortal.data.transaction.TransactionData; @@ -11,6 +12,7 @@ import org.qortal.repository.Repository; import java.util.Collections; import java.util.List; +import java.util.Objects; public class GroupInviteTransaction extends Transaction { @@ -85,6 +87,16 @@ public class GroupInviteTransaction extends Transaction { if (admin.getConfirmedBalance(Asset.QORT) < this.groupInviteTransactionData.getFee()) return ValidationResult.NO_BALANCE; + // if null ownership group, then check for admin approval + if( this.repository.getBlockRepository().getBlockchainHeight() >= BlockChain.getInstance().getNullGroupMembershipHeight() ) { + String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); + + // Require approval if transaction relates to a group owned by the null account + if (groupOwnedByNullAccount && !this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + } + return ValidationResult.OK; } diff --git a/src/main/java/org/qortal/transaction/GroupKickTransaction.java b/src/main/java/org/qortal/transaction/GroupKickTransaction.java index 3c426039..e13114fc 100644 --- a/src/main/java/org/qortal/transaction/GroupKickTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupKickTransaction.java @@ -3,6 +3,7 @@ package org.qortal.transaction; import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; import org.qortal.asset.Asset; +import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.data.group.GroupData; import org.qortal.data.transaction.GroupKickTransactionData; @@ -14,6 +15,7 @@ import org.qortal.repository.Repository; import java.util.Collections; import java.util.List; +import java.util.Objects; public class GroupKickTransaction extends Transaction { @@ -82,9 +84,26 @@ public class GroupKickTransaction extends Transaction { if (!admin.getAddress().equals(groupData.getOwner()) && groupRepository.adminExists(groupId, member.getAddress())) return ValidationResult.INVALID_GROUP_OWNER; - // Can't kick if not group's current owner - if (!admin.getAddress().equals(groupData.getOwner())) - return ValidationResult.INVALID_GROUP_OWNER; + if( this.repository.getBlockRepository().getBlockchainHeight() < BlockChain.getInstance().getNullGroupMembershipHeight() ) { + // Can't kick if not group's current owner + if (!admin.getAddress().equals(groupData.getOwner())) + return ValidationResult.INVALID_GROUP_OWNER; + } + // if( this.repository.getBlockRepository().getBlockchainHeight() >= BlockChain.getInstance().getNullGroupMembershipHeight() ) + else { + String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); + + // if null ownership group, then check for admin approval + if(groupOwnedByNullAccount ) { + // Require approval if transaction relates to a group owned by the null account + if (!this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + } + // Can't kick if not group's current owner + else if (!admin.getAddress().equals(groupData.getOwner())) + return ValidationResult.INVALID_GROUP_OWNER; + } // Check creator has enough funds if (admin.getConfirmedBalance(Asset.QORT) < this.groupKickTransactionData.getFee()) diff --git a/src/main/java/org/qortal/transaction/RewardShareTransaction.java b/src/main/java/org/qortal/transaction/RewardShareTransaction.java index b2261181..31734204 100644 --- a/src/main/java/org/qortal/transaction/RewardShareTransaction.java +++ b/src/main/java/org/qortal/transaction/RewardShareTransaction.java @@ -98,6 +98,14 @@ public class RewardShareTransaction extends Transaction { @Override public ValidationResult isValid() throws DataException { + final int disableRs = BlockChain.getInstance().getDisableRewardshareHeight(); + final int enableRs = BlockChain.getInstance().getEnableRewardshareHeight(); + int blockchainHeight = this.repository.getBlockRepository().getBlockchainHeight(); + + // Check if reward share is disabled. + if (blockchainHeight >= disableRs && blockchainHeight < enableRs) + return ValidationResult.GENERAL_TEMPORARY_DISABLED; + // Check reward share given to recipient. Negative is potentially OK to end a current reward-share. Zero also fine. if (this.rewardShareTransactionData.getSharePercent() > MAX_SHARE) return ValidationResult.INVALID_REWARD_SHARE_PERCENT; @@ -115,7 +123,7 @@ public class RewardShareTransaction extends Transaction { final boolean isCancellingSharePercent = this.rewardShareTransactionData.getSharePercent() < 0; // Creator themselves needs to be allowed to mint (unless cancelling) - if (!isCancellingSharePercent && !creator.canMint()) + if (!isCancellingSharePercent && !creator.canMint(false)) return ValidationResult.NOT_MINTING_ACCOUNT; // Qortal: special rules in play depending whether recipient is also minter diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index 5e5b9fba..f993194a 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -65,11 +65,11 @@ public abstract class Transaction { UPDATE_GROUP(23, true), ADD_GROUP_ADMIN(24, true), REMOVE_GROUP_ADMIN(25, true), - GROUP_BAN(26, false), - CANCEL_GROUP_BAN(27, false), - GROUP_KICK(28, false), - GROUP_INVITE(29, false), - CANCEL_GROUP_INVITE(30, false), + GROUP_BAN(26, true), + CANCEL_GROUP_BAN(27, true), + GROUP_KICK(28, true), + GROUP_INVITE(29, true), + CANCEL_GROUP_INVITE(30, true), JOIN_GROUP(31, false), LEAVE_GROUP(32, false), GROUP_APPROVAL(33, false), @@ -249,6 +249,7 @@ public abstract class Transaction { ACCOUNT_NOT_TRANSFERABLE(99), TRANSFER_PRIVS_DISABLED(100), TEMPORARY_DISABLED(101), + GENERAL_TEMPORARY_DISABLED(102), INVALID_BUT_OK(999), NOT_YET_RELEASED(1000), NOT_SUPPORTED(1001); diff --git a/src/main/java/org/qortal/utils/ArbitraryIndexUtils.java b/src/main/java/org/qortal/utils/ArbitraryIndexUtils.java new file mode 100644 index 00000000..17c966fe --- /dev/null +++ b/src/main/java/org/qortal/utils/ArbitraryIndexUtils.java @@ -0,0 +1,250 @@ +package org.qortal.utils; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.api.SearchMode; +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.controller.Controller; +import org.qortal.data.arbitrary.ArbitraryDataIndex; +import org.qortal.data.arbitrary.ArbitraryDataIndexDetail; +import org.qortal.data.arbitrary.ArbitraryResourceData; +import org.qortal.data.arbitrary.IndexCache; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class ArbitraryIndexUtils { + + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Logger LOGGER = LogManager.getLogger(ArbitraryIndexUtils.class); + + public static final String INDEX_CACHE_TIMER = "Arbitrary Index Cache Timer"; + public static final String INDEX_CACHE_TIMER_TASK = "Arbitrary Index Cache Timer Task"; + + public static void startCaching(int priorityRequested, int frequency) { + + Timer timer = buildTimer(INDEX_CACHE_TIMER, priorityRequested); + + TimerTask task = new TimerTask() { + @Override + public void run() { + + Thread.currentThread().setName(INDEX_CACHE_TIMER_TASK); + + try { + fillCache(IndexCache.getInstance()); + } catch (IOException | DataException e) { + LOGGER.error(e.getMessage(), e); + } + } + }; + + // delay 1 second + timer.scheduleAtFixedRate(task, 1_000, frequency * 60_000); + } + + private static void fillCache(IndexCache instance) throws DataException, IOException { + + try (final Repository repository = RepositoryManager.getRepository()) { + + List indexResources + = repository.getArbitraryRepository().searchArbitraryResources( + Service.JSON, + null, + "idx-", + null, + null, + null, + null, + true, + null, + false, + SearchMode.ALL, + 0, + null, + null, + null, + null, + null, + null, + null, + null, + true); + + List indexDetails = new ArrayList<>(); + + LOGGER.debug("processing index resource data: count = " + indexResources.size()); + + // process all index resources + for( ArbitraryResourceData indexResource : indexResources ) { + + try { + LOGGER.debug("processing index resource: name = " + indexResource.name + ", identifier = " + indexResource.identifier); + String json = ArbitraryIndexUtils.getJson(indexResource.name, indexResource.identifier); + + // map the JSON string to a list of Java objects + List indices = OBJECT_MAPPER.readValue(json, new TypeReference>() {}); + + LOGGER.debug("processed indices = " + indices); + + // rank and create index detail for each index in this index resource + for( int rank = 1; rank <= indices.size(); rank++ ) { + + indexDetails.add( new ArbitraryDataIndexDetail(indexResource.name, rank, indices.get(rank - 1), indexResource.identifier )); + } + } catch (InvalidFormatException e) { + LOGGER.debug("invalid format, skipping: " + indexResource); + } catch (UnrecognizedPropertyException e) { + LOGGER.debug("unrecognized property, skipping " + indexResource); + } + } + + LOGGER.debug("processing indices by term ..."); + Map> indicesByTerm + = indexDetails.stream().collect( + Collectors.toMap( + detail -> detail.term, // map by term + detail -> List.of(detail), // create list for term + (list1, list2) // merge lists for same term + -> Stream.of(list1, list2) + .flatMap(List::stream) + .collect(Collectors.toList()) + ) + ); + + LOGGER.info("processed indices by term: count = " + indicesByTerm.size()); + + // lock, clear old, load new + synchronized( IndexCache.getInstance().getIndicesByTerm() ) { + IndexCache.getInstance().getIndicesByTerm().clear(); + IndexCache.getInstance().getIndicesByTerm().putAll(indicesByTerm); + } + + LOGGER.info("loaded indices by term"); + + LOGGER.debug("processing indices by issuer ..."); + Map> indicesByIssuer + = indexDetails.stream().collect( + Collectors.toMap( + detail -> detail.issuer, // map by issuer + detail -> List.of(detail), // create list for issuer + (list1, list2) // merge lists for same issuer + -> Stream.of(list1, list2) + .flatMap(List::stream) + .collect(Collectors.toList()) + ) + ); + + LOGGER.info("processed indices by issuer: count = " + indicesByIssuer.size()); + + // lock, clear old, load new + synchronized( IndexCache.getInstance().getIndicesByIssuer() ) { + IndexCache.getInstance().getIndicesByIssuer().clear(); + IndexCache.getInstance().getIndicesByIssuer().putAll(indicesByIssuer); + } + + LOGGER.info("loaded indices by issuer"); + } + } + + private static Timer buildTimer( final String name, int priorityRequested) { + // ensure priority is in between 1-10 + final int priority = Math.max(0, Math.min(10, priorityRequested)); + + // Create a custom Timer with updated priority threads + Timer timer = new Timer(true) { // 'true' to make the Timer daemon + @Override + public void schedule(TimerTask task, long delay) { + Thread thread = new Thread(task, name) { + @Override + public void run() { + this.setPriority(priority); + super.run(); + } + }; + thread.setPriority(priority); + thread.start(); + } + }; + return timer; + } + + + public static String getJsonWithExceptionHandling( String name, String identifier ) { + try { + return getJson(name, identifier); + } + catch( Exception e ) { + LOGGER.error(e.getMessage(), e); + return e.getMessage(); + } + } + + public static String getJson(String name, String identifier) throws IOException { + + try { + ArbitraryDataReader arbitraryDataReader + = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, Service.JSON, identifier); + + int attempts = 0; + Integer maxAttempts = 5; + + while (!Controller.isStopping()) { + attempts++; + if (!arbitraryDataReader.isBuilding()) { + try { + arbitraryDataReader.loadSynchronously(false); + break; + } catch (MissingDataException e) { + if (attempts > maxAttempts) { + // Give up after 5 attempts + throw new IOException("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 IOException( "File not found"); + } + + // No file path supplied - so check if this is a single file resource + String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal"); + String filepath = files[0]; + + 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 new IOException( message ); + } + + String data = Files.readString(path); + + return data; + } catch (Exception e) { + throw new IOException(String.format("Unable to load %s %s: %s", Service.JSON, name, e.getMessage())); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java index f641255f..c860a034 100644 --- a/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java +++ b/src/main/java/org/qortal/utils/ArbitraryTransactionUtils.java @@ -24,6 +24,7 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; @@ -72,23 +73,23 @@ public class ArbitraryTransactionUtils { return latestPut; } - public static boolean hasMoreRecentPutTransaction(Repository repository, ArbitraryTransactionData arbitraryTransactionData) { + public static Optional hasMoreRecentPutTransaction(Repository repository, ArbitraryTransactionData arbitraryTransactionData) { byte[] signature = arbitraryTransactionData.getSignature(); if (signature == null) { // We can't make a sensible decision without a signature // so it's best to assume there is nothing newer - return false; + return Optional.empty(); } ArbitraryTransactionData latestPut = ArbitraryTransactionUtils.fetchLatestPut(repository, arbitraryTransactionData); if (latestPut == null) { - return false; + return Optional.empty(); } // If the latest PUT transaction has a newer timestamp, it will override the existing transaction // Any data relating to the older transaction is no longer needed boolean hasNewerPut = (latestPut.getTimestamp() > arbitraryTransactionData.getTimestamp()); - return hasNewerPut; + return hasNewerPut ? Optional.of(latestPut) : Optional.empty(); } public static boolean completeFileExists(ArbitraryTransactionData transactionData) throws DataException { @@ -208,7 +209,15 @@ public class ArbitraryTransactionUtils { return ArbitraryTransactionUtils.isFileRecent(filePath, now, cleanupAfter); } - public static void deleteCompleteFile(ArbitraryTransactionData arbitraryTransactionData, long now, long cleanupAfter) throws DataException { + /** + * + * @param arbitraryTransactionData + * @param now + * @param cleanupAfter + * @return true if file is deleted, otherwise return false + * @throws DataException + */ + public static boolean deleteCompleteFile(ArbitraryTransactionData arbitraryTransactionData, long now, long cleanupAfter) throws DataException { byte[] completeHash = arbitraryTransactionData.getData(); byte[] signature = arbitraryTransactionData.getSignature(); @@ -219,6 +228,11 @@ public class ArbitraryTransactionUtils { "if needed", Base58.encode(completeHash)); arbitraryDataFile.delete(); + + return true; + } + else { + return false; } } diff --git a/src/main/java/org/qortal/utils/BalanceRecorderUtils.java b/src/main/java/org/qortal/utils/BalanceRecorderUtils.java new file mode 100644 index 00000000..8ad346ac --- /dev/null +++ b/src/main/java/org/qortal/utils/BalanceRecorderUtils.java @@ -0,0 +1,319 @@ +package org.qortal.utils; + +import org.qortal.block.Block; +import org.qortal.crypto.Crypto; +import org.qortal.data.PaymentData; +import org.qortal.data.account.AccountBalanceData; +import org.qortal.data.account.AddressAmountData; +import org.qortal.data.account.BlockHeightRange; +import org.qortal.data.account.BlockHeightRangeAddressAmounts; +import org.qortal.data.transaction.ATTransactionData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.BuyNameTransactionData; +import org.qortal.data.transaction.CreateAssetOrderTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MultiPaymentTransactionData; +import org.qortal.data.transaction.PaymentTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.data.transaction.TransferAssetTransactionData; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +public class BalanceRecorderUtils { + + public static final Predicate ADDRESS_AMOUNT_DATA_NOT_ZERO = addressAmount -> addressAmount.getAmount() != 0; + public static final Comparator BLOCK_HEIGHT_RANGE_ADDRESS_AMOUNTS_COMPARATOR = new Comparator() { + @Override + public int compare(BlockHeightRangeAddressAmounts amounts1, BlockHeightRangeAddressAmounts amounts2) { + return amounts1.getRange().getEnd() - amounts2.getRange().getEnd(); + } + }; + + public static final Comparator ADDRESS_AMOUNT_DATA_COMPARATOR = new Comparator() { + @Override + public int compare(AddressAmountData addressAmountData, AddressAmountData t1) { + if( addressAmountData.getAmount() > t1.getAmount() ) { + return 1; + } + else if( addressAmountData.getAmount() < t1.getAmount() ) { + return -1; + } + else { + return 0; + } + } + }; + + public static final Comparator BLOCK_HEIGHT_RANGE_COMPARATOR = new Comparator() { + @Override + public int compare(BlockHeightRange range1, BlockHeightRange range2) { + return range1.getEnd() - range2.getEnd(); + } + }; + + /** + * Build Balance Dynmaics For Account + * + * @param priorBalances the balances prior to the current height, assuming only one balance per address + * @param accountBalance the current balance + * + * @return the difference between the current balance and the prior balance for the current balance address + */ + public static AddressAmountData buildBalanceDynamicsForAccount(List priorBalances, AccountBalanceData accountBalance) { + Optional matchingAccountPriorBalance + = priorBalances.stream() + .filter(priorBalance -> accountBalance.getAddress().equals(priorBalance.getAddress())) + .findFirst(); + if(matchingAccountPriorBalance.isPresent()) { + return new AddressAmountData(accountBalance.getAddress(), accountBalance.getBalance() - matchingAccountPriorBalance.get().getBalance()); + } + else { + return new AddressAmountData(accountBalance.getAddress(), accountBalance.getBalance()); + } + } + + public static List buildBalanceDynamics( + final List balances, + final List priorBalances, + long minimum, + List transactions) { + + Map amountsByAddress = new HashMap<>(transactions.size()); + + for( TransactionData transactionData : transactions ) { + + mapBalanceModificationsForTransaction(amountsByAddress, transactionData); + } + + List addressAmounts + = balances.stream() + .map(balance -> buildBalanceDynamicsForAccount(priorBalances, balance)) + .map( data -> adjustAddressAmount(amountsByAddress.getOrDefault(data.getAddress(), 0L), data)) + .filter(ADDRESS_AMOUNT_DATA_NOT_ZERO) + .filter(data -> data.getAmount() >= minimum) + .collect(Collectors.toList()); + + return addressAmounts; + } + + public static AddressAmountData adjustAddressAmount(long adjustment, AddressAmountData data) { + + return new AddressAmountData(data.getAddress(), data.getAmount() - adjustment); + } + + public static void mapBalanceModificationsForTransaction(Map amountsByAddress, TransactionData transactionData) { + String creatorAddress; + + // AT Transaction + if( transactionData instanceof ATTransactionData) { + creatorAddress = mapBalanceModificationsForAtTransaction(amountsByAddress, (ATTransactionData) transactionData); + } + // Buy Name Transaction + else if( transactionData instanceof BuyNameTransactionData) { + creatorAddress = mapBalanceModificationsForBuyNameTransaction(amountsByAddress, (BuyNameTransactionData) transactionData); + } + // Create Asset Order Transaction + else if( transactionData instanceof CreateAssetOrderTransactionData) { + //TODO I'm not sure how to handle this one. This hasn't been used at this point in the blockchain. + + creatorAddress = Crypto.toAddress(transactionData.getCreatorPublicKey()); + } + // Deploy AT Transaction + else if( transactionData instanceof DeployAtTransactionData ) { + creatorAddress = mapBalanceModificationsForDeployAtTransaction(amountsByAddress, (DeployAtTransactionData) transactionData); + } + // Multi Payment Transaction + else if( transactionData instanceof MultiPaymentTransactionData) { + creatorAddress = mapBalanceModificationsForMultiPaymentTransaction(amountsByAddress, (MultiPaymentTransactionData) transactionData); + } + // Payment Transaction + else if( transactionData instanceof PaymentTransactionData ) { + creatorAddress = mapBalanceModicationsForPaymentTransaction(amountsByAddress, (PaymentTransactionData) transactionData); + } + // Transfer Asset Transaction + else if( transactionData instanceof TransferAssetTransactionData) { + creatorAddress = mapBalanceModificationsForTransferAssetTransaction(amountsByAddress, (TransferAssetTransactionData) transactionData); + } + // Other Transactions + else { + creatorAddress = Crypto.toAddress(transactionData.getCreatorPublicKey()); + } + + // all transactions modify the balance for fees + mapBalanceModifications(amountsByAddress, transactionData.getFee(), creatorAddress, Optional.empty()); + } + + public static String mapBalanceModificationsForTransferAssetTransaction(Map amountsByAddress, TransferAssetTransactionData transferAssetData) { + String creatorAddress = Crypto.toAddress(transferAssetData.getSenderPublicKey()); + + if( transferAssetData.getAssetId() == 0) { + mapBalanceModifications( + amountsByAddress, + transferAssetData.getAmount(), + creatorAddress, + Optional.of(transferAssetData.getRecipient()) + ); + } + return creatorAddress; + } + + public static String mapBalanceModicationsForPaymentTransaction(Map amountsByAddress, PaymentTransactionData paymentData) { + String creatorAddress = Crypto.toAddress(paymentData.getCreatorPublicKey()); + + mapBalanceModifications(amountsByAddress, + paymentData.getAmount(), + creatorAddress, + Optional.of(paymentData.getRecipient()) + ); + return creatorAddress; + } + + public static String mapBalanceModificationsForMultiPaymentTransaction(Map amountsByAddress, MultiPaymentTransactionData multiPaymentData) { + String creatorAddress = Crypto.toAddress(multiPaymentData.getCreatorPublicKey()); + + for(PaymentData payment : multiPaymentData.getPayments() ) { + mapBalanceModificationsForTransaction( + amountsByAddress, + getPaymentTransactionData(multiPaymentData, payment) + ); + } + return creatorAddress; + } + + public static String mapBalanceModificationsForDeployAtTransaction(Map amountsByAddress, DeployAtTransactionData transactionData) { + String creatorAddress; + DeployAtTransactionData deployAtData = transactionData; + + creatorAddress = Crypto.toAddress(deployAtData.getCreatorPublicKey()); + + if( deployAtData.getAssetId() == 0 ) { + mapBalanceModifications( + amountsByAddress, + deployAtData.getAmount(), + creatorAddress, + Optional.of(deployAtData.getAtAddress()) + ); + } + return creatorAddress; + } + + public static String mapBalanceModificationsForBuyNameTransaction(Map amountsByAddress, BuyNameTransactionData transactionData) { + String creatorAddress; + BuyNameTransactionData buyNameData = transactionData; + + creatorAddress = Crypto.toAddress(buyNameData.getCreatorPublicKey()); + + mapBalanceModifications( + amountsByAddress, + buyNameData.getAmount(), + creatorAddress, + Optional.of(buyNameData.getSeller()) + ); + return creatorAddress; + } + + public static String mapBalanceModificationsForAtTransaction(Map amountsByAddress, ATTransactionData transactionData) { + String creatorAddress; + ATTransactionData atData = transactionData; + creatorAddress = atData.getATAddress(); + + if( atData.getAssetId() != null && atData.getAssetId() == 0) { + mapBalanceModifications( + amountsByAddress, + atData.getAmount(), + creatorAddress, + Optional.of(atData.getRecipient()) + ); + } + return creatorAddress; + } + + public static PaymentTransactionData getPaymentTransactionData(MultiPaymentTransactionData multiPaymentData, PaymentData payment) { + return new PaymentTransactionData( + new BaseTransactionData( + multiPaymentData.getTimestamp(), + multiPaymentData.getTxGroupId(), + multiPaymentData.getReference(), + multiPaymentData.getCreatorPublicKey(), + 0L, + multiPaymentData.getSignature() + ), + payment.getRecipient(), + payment.getAmount() + ); + } + + public static void mapBalanceModifications(Map amountsByAddress, Long amount, String sender, Optional recipient) { + amountsByAddress.put( + sender, + amountsByAddress.getOrDefault(sender, 0L) - amount + ); + + if( recipient.isPresent() ) + amountsByAddress.put( + recipient.get(), + amountsByAddress.getOrDefault(recipient.get(), 0L) + amount + ); + } + + public static void removeRecordingsAboveHeight(int currentHeight, ConcurrentHashMap> balancesByHeight) { + balancesByHeight.entrySet().stream() + .filter(heightWithBalances -> heightWithBalances.getKey() > currentHeight) + .forEach(heightWithBalances -> balancesByHeight.remove(heightWithBalances.getKey())); + } + + public static void removeRecordingsBelowHeight(int currentHeight, ConcurrentHashMap> balancesByHeight) { + balancesByHeight.entrySet().stream() + .filter(heightWithBalances -> heightWithBalances.getKey() < currentHeight) + .forEach(heightWithBalances -> balancesByHeight.remove(heightWithBalances.getKey())); + } + + public static void removeDynamicsOnOrAboveHeight(int currentHeight, CopyOnWriteArrayList balanceDynamics) { + balanceDynamics.stream() + .filter(addressAmounts -> addressAmounts.getRange().getEnd() >= currentHeight) + .forEach(addressAmounts -> balanceDynamics.remove(addressAmounts)); + } + + public static BlockHeightRangeAddressAmounts removeOldestDynamics(CopyOnWriteArrayList balanceDynamics) { + BlockHeightRangeAddressAmounts oldestDynamics + = balanceDynamics.stream().sorted(BLOCK_HEIGHT_RANGE_ADDRESS_AMOUNTS_COMPARATOR).findFirst().get(); + + balanceDynamics.remove(oldestDynamics); + return oldestDynamics; + } + + public static Optional getPriorHeight(int currentHeight, ConcurrentHashMap> balancesByHeight) { + Optional priorHeight + = balancesByHeight.keySet().stream() + .filter(height -> height < currentHeight) + .sorted(Comparator.reverseOrder()).findFirst(); + return priorHeight; + } + + /** + * Is Reward Distribution Range? + * + * @param start start height, exclusive + * @param end end height, inclusive + * + * @return true there is a reward distribution block within this block range + */ + public static boolean isRewardDistributionRange(int start, int end) { + + // iterate through the block height until a reward distribution block or the end of the range + for( int i = start + 1; i <= end; i++) { + if( Block.isRewardDistributionBlock(i) ) return true; + } + + // no reward distribution blocks found within range + return false; + } +} diff --git a/src/main/java/org/qortal/utils/Blocks.java b/src/main/java/org/qortal/utils/Blocks.java new file mode 100644 index 00000000..0681af75 --- /dev/null +++ b/src/main/java/org/qortal/utils/Blocks.java @@ -0,0 +1,99 @@ +package org.qortal.utils; + +import io.druid.extendedset.intset.ConciseSet; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.block.BlockChain; +import org.qortal.data.account.AddressLevelPairing; +import org.qortal.data.account.RewardShareData; +import org.qortal.data.block.BlockData; +import org.qortal.data.block.DecodedOnlineAccountData; +import org.qortal.data.group.GroupMemberData; +import org.qortal.data.naming.NameData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transform.block.BlockTransformer; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Class Blocks + * + * Methods for block related logic. + */ +public class Blocks { + + private static final Logger LOGGER = LogManager.getLogger(Blocks.class); + + /** + * Get Decode Online Accounts For Block + * + * @param repository the data repository + * @param blockData the block data + * + * @return the online accounts set to the block + * + * @throws DataException + */ + public static Set getDecodedOnlineAccountsForBlock(Repository repository, BlockData blockData) throws DataException { + try { + // get all online account indices from block + ConciseSet onlineAccountIndices = BlockTransformer.decodeOnlineAccounts(blockData.getEncodedOnlineAccounts()); + + // get online reward shares from the online accounts on the block + List onlineRewardShares = repository.getAccountRepository().getRewardSharesByIndexes(onlineAccountIndices.toArray()); + + // online timestamp for block + long onlineTimestamp = blockData.getOnlineAccountsTimestamp(); + Set onlineAccounts = new HashSet<>(); + + // all minting group member addresses + List mintingGroupAddresses + = Groups.getAllMembers( + repository.getGroupRepository(), + Groups.getGroupIdsToMint(BlockChain.getInstance(), blockData.getHeight()) + ); + + // all names, indexed by address + Map nameByAddress + = repository.getNameRepository() + .getAllNames().stream() + .collect(Collectors.toMap(NameData::getOwner, NameData::getName)); + + // all accounts at level 1 or higher, indexed by address + Map levelByAddress + = repository.getAccountRepository().getAddressLevelPairings(1).stream() + .collect(Collectors.toMap(AddressLevelPairing::getAddress, AddressLevelPairing::getLevel)); + + // for each reward share where the minter is online, + // construct the data object and add it to the return list + for (RewardShareData onlineRewardShare : onlineRewardShares) { + String minter = onlineRewardShare.getMinter(); + DecodedOnlineAccountData onlineAccountData + = new DecodedOnlineAccountData( + onlineTimestamp, + minter, + onlineRewardShare.getRecipient(), + onlineRewardShare.getSharePercent(), + mintingGroupAddresses.contains(minter), + nameByAddress.get(minter), + levelByAddress.get(minter) + ); + + onlineAccounts.add(onlineAccountData); + } + + return onlineAccounts; + } catch (DataException e) { + throw e; + } catch (Exception e ) { + LOGGER.error(e.getMessage(), e); + + return new HashSet<>(0); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/utils/DaemonThreadFactory.java b/src/main/java/org/qortal/utils/DaemonThreadFactory.java index 9a73bd1d..e07fe28c 100644 --- a/src/main/java/org/qortal/utils/DaemonThreadFactory.java +++ b/src/main/java/org/qortal/utils/DaemonThreadFactory.java @@ -8,19 +8,22 @@ public class DaemonThreadFactory implements ThreadFactory { private final String name; private final AtomicInteger threadNumber = new AtomicInteger(1); + private int priority = Thread.NORM_PRIORITY; - public DaemonThreadFactory(String name) { + public DaemonThreadFactory(String name, int priority) { this.name = name; + this.priority = priority; } - public DaemonThreadFactory() { - this(null); + public DaemonThreadFactory(int priority) { + this(null, priority);; } @Override public Thread newThread(Runnable runnable) { Thread thread = Executors.defaultThreadFactory().newThread(runnable); thread.setDaemon(true); + thread.setPriority(this.priority); if (this.name != null) thread.setName(this.name + "-" + this.threadNumber.getAndIncrement()); diff --git a/src/main/java/org/qortal/utils/ExecuteProduceConsume.java b/src/main/java/org/qortal/utils/ExecuteProduceConsume.java index 58e1af05..a103f6f7 100644 --- a/src/main/java/org/qortal/utils/ExecuteProduceConsume.java +++ b/src/main/java/org/qortal/utils/ExecuteProduceConsume.java @@ -9,7 +9,14 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +/** + * Class ExecuteProduceConsume + * + * @ThreadSafe + */ public abstract class ExecuteProduceConsume implements Runnable { @XmlAccessorType(XmlAccessType.FIELD) @@ -30,25 +37,25 @@ public abstract class ExecuteProduceConsume implements Runnable { protected ExecutorService executor; - // These are volatile to prevent thread-local caching of values - // but all are updated inside synchronized blocks - // so we don't need AtomicInteger/AtomicBoolean + // These are atomic to make this class thread-safe - private volatile int activeThreadCount = 0; - private volatile int greatestActiveThreadCount = 0; - private volatile int consumerCount = 0; - private volatile int tasksProduced = 0; - private volatile int tasksConsumed = 0; - private volatile int spawnFailures = 0; + private AtomicInteger activeThreadCount = new AtomicInteger(0); + private AtomicInteger greatestActiveThreadCount = new AtomicInteger(0); + private AtomicInteger consumerCount = new AtomicInteger(0); + private AtomicInteger tasksProduced = new AtomicInteger(0); + private AtomicInteger tasksConsumed = new AtomicInteger(0); + private AtomicInteger spawnFailures = new AtomicInteger(0); /** Whether a new thread has already been spawned and is waiting to start. Used to prevent spawning multiple new threads. */ - private volatile boolean hasThreadPending = false; + private AtomicBoolean hasThreadPending = new AtomicBoolean(false); public ExecuteProduceConsume(ExecutorService executor) { this.className = this.getClass().getSimpleName(); this.logger = LogManager.getLogger(this.getClass()); this.executor = executor; + + this.logger.info("Created Thread-Safe ExecuteProduceConsume"); } public ExecuteProduceConsume() { @@ -71,14 +78,12 @@ public abstract class ExecuteProduceConsume implements Runnable { public StatsSnapshot getStatsSnapshot() { StatsSnapshot snapshot = new StatsSnapshot(); - synchronized (this) { - snapshot.activeThreadCount = this.activeThreadCount; - snapshot.greatestActiveThreadCount = this.greatestActiveThreadCount; - snapshot.consumerCount = this.consumerCount; - snapshot.tasksProduced = this.tasksProduced; - snapshot.tasksConsumed = this.tasksConsumed; - snapshot.spawnFailures = this.spawnFailures; - } + snapshot.activeThreadCount = this.activeThreadCount.get(); + snapshot.greatestActiveThreadCount = this.greatestActiveThreadCount.get(); + snapshot.consumerCount = this.consumerCount.get(); + snapshot.tasksProduced = this.tasksProduced.get(); + snapshot.tasksConsumed = this.tasksConsumed.get(); + snapshot.spawnFailures = this.spawnFailures.get(); return snapshot; } @@ -93,6 +98,8 @@ public abstract class ExecuteProduceConsume implements Runnable { * @param canBlock * @return task to be performed, or null if no task pending. * @throws InterruptedException + * + * @ThreadSafe */ protected abstract Task produceTask(boolean canBlock) throws InterruptedException; @@ -105,117 +112,65 @@ public abstract class ExecuteProduceConsume implements Runnable { public void run() { Thread.currentThread().setName(this.className + "-" + Thread.currentThread().getId()); - boolean wasThreadPending; - synchronized (this) { - ++this.activeThreadCount; - if (this.activeThreadCount > this.greatestActiveThreadCount) - this.greatestActiveThreadCount = this.activeThreadCount; - - this.logger.trace(() -> String.format("[%d] started, hasThreadPending was: %b, activeThreadCount now: %d", - Thread.currentThread().getId(), this.hasThreadPending, this.activeThreadCount)); + this.activeThreadCount.incrementAndGet(); + if (this.activeThreadCount.get() > this.greatestActiveThreadCount.get()) + this.greatestActiveThreadCount.set( this.activeThreadCount.get() ); // Defer clearing hasThreadPending to prevent unnecessary threads waiting to produce... - wasThreadPending = this.hasThreadPending; - } + boolean wasThreadPending = this.hasThreadPending.get(); try { while (!Thread.currentThread().isInterrupted()) { Task task = null; - String taskType; - this.logger.trace(() -> String.format("[%d] waiting to produce...", Thread.currentThread().getId())); - - synchronized (this) { - if (wasThreadPending) { - // Clear thread-pending flag now that we about to produce. - this.hasThreadPending = false; - wasThreadPending = false; - } - - // If we're the only non-consuming thread - producer can afford to block this round - boolean canBlock = this.activeThreadCount - this.consumerCount <= 1; - - this.logger.trace(() -> String.format("[%d] producing... [activeThreadCount: %d, consumerCount: %d, canBlock: %b]", - Thread.currentThread().getId(), this.activeThreadCount, this.consumerCount, canBlock)); - - final long beforeProduce = this.logger.isDebugEnabled() ? System.currentTimeMillis() : 0; - - try { - task = produceTask(canBlock); - } catch (InterruptedException e) { - // We're in shutdown situation so exit - Thread.currentThread().interrupt(); - } catch (Exception e) { - this.logger.warn(() -> String.format("[%d] exception while trying to produce task", Thread.currentThread().getId()), e); - } - - if (this.logger.isDebugEnabled()) { - final long productionPeriod = System.currentTimeMillis() - beforeProduce; - taskType = task == null ? "no task" : task.getName(); - - this.logger.debug(() -> String.format("[%d] produced [%s] in %dms [canBlock: %b]", - Thread.currentThread().getId(), - taskType, - productionPeriod, - canBlock - )); - } else { - taskType = null; - } + if (wasThreadPending) { + // Clear thread-pending flag now that we about to produce. + this.hasThreadPending.set( false ); + wasThreadPending = false; } - if (task == null) - synchronized (this) { - this.logger.trace(() -> String.format("[%d] no task, activeThreadCount: %d, consumerCount: %d", - Thread.currentThread().getId(), this.activeThreadCount, this.consumerCount)); + // If we're the only non-consuming thread - producer can afford to block this round + boolean canBlock = this.activeThreadCount.get() - this.consumerCount.get() <= 1; - // If we have an excess of non-consuming threads then we can exit - if (this.activeThreadCount - this.consumerCount > 1) { - --this.activeThreadCount; + try { + task = produceTask(canBlock); + } catch (InterruptedException e) { + // We're in shutdown situation so exit + Thread.currentThread().interrupt(); + } catch (Exception e) { + this.logger.warn(() -> String.format("[%d] exception while trying to produce task", Thread.currentThread().getId()), e); + } - this.logger.trace(() -> String.format("[%d] ending, activeThreadCount now: %d", - Thread.currentThread().getId(), this.activeThreadCount)); + if (task == null) { + // If we have an excess of non-consuming threads then we can exit + if (this.activeThreadCount.get() - this.consumerCount.get() > 1) { + this.activeThreadCount.decrementAndGet(); - return; - } - - continue; + return; } + continue; + } // We have a task - synchronized (this) { - ++this.tasksProduced; - ++this.consumerCount; + this.tasksProduced.incrementAndGet(); + this.consumerCount.incrementAndGet(); - this.logger.trace(() -> String.format("[%d] hasThreadPending: %b, activeThreadCount: %d, consumerCount now: %d", - Thread.currentThread().getId(), this.hasThreadPending, this.activeThreadCount, this.consumerCount)); + // If we have no thread pending and no excess of threads then we should spawn a fresh thread + if (!this.hasThreadPending.get() && this.activeThreadCount.get() == this.consumerCount.get()) { - // If we have no thread pending and no excess of threads then we should spawn a fresh thread - if (!this.hasThreadPending && this.activeThreadCount == this.consumerCount) { - this.logger.trace(() -> String.format("[%d] spawning another thread", Thread.currentThread().getId())); + this.hasThreadPending.set( true ); - this.hasThreadPending = true; + try { + this.executor.execute(this); // Same object, different thread + } catch (RejectedExecutionException e) { + this.spawnFailures.decrementAndGet(); + this.hasThreadPending.set( false ); - try { - this.executor.execute(this); // Same object, different thread - } catch (RejectedExecutionException e) { - ++this.spawnFailures; - this.hasThreadPending = false; - - this.logger.trace(() -> String.format("[%d] failed to spawn another thread", Thread.currentThread().getId())); - - this.onSpawnFailure(); - } - } else { - this.logger.trace(() -> String.format("[%d] NOT spawning another thread", Thread.currentThread().getId())); + this.onSpawnFailure(); } } - this.logger.trace(() -> String.format("[%d] consuming [%s] task...", Thread.currentThread().getId(), taskType)); - - final long beforePerform = this.logger.isDebugEnabled() ? System.currentTimeMillis() : 0; - try { task.perform(); // This can block for a while } catch (InterruptedException e) { @@ -225,23 +180,11 @@ public abstract class ExecuteProduceConsume implements Runnable { this.logger.warn(() -> String.format("[%d] exception while consuming task", Thread.currentThread().getId()), e); } - if (this.logger.isDebugEnabled()) { - final long productionPeriod = System.currentTimeMillis() - beforePerform; - - this.logger.debug(() -> String.format("[%d] consumed [%s] task in %dms", Thread.currentThread().getId(), taskType, productionPeriod)); - } - - synchronized (this) { - ++this.tasksConsumed; - --this.consumerCount; - - this.logger.trace(() -> String.format("[%d] consumerCount now: %d", - Thread.currentThread().getId(), this.consumerCount)); - } + this.tasksConsumed.incrementAndGet(); + this.consumerCount.decrementAndGet(); } } finally { Thread.currentThread().setName(this.className); } } - -} +} \ No newline at end of file diff --git a/src/main/java/org/qortal/utils/Groups.java b/src/main/java/org/qortal/utils/Groups.java new file mode 100644 index 00000000..131bc93e --- /dev/null +++ b/src/main/java/org/qortal/utils/Groups.java @@ -0,0 +1,122 @@ +package org.qortal.utils; + +import org.qortal.block.BlockChain; +import org.qortal.data.group.GroupAdminData; +import org.qortal.data.group.GroupMemberData; +import org.qortal.repository.DataException; +import org.qortal.repository.GroupRepository; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Class Groups + * + * A utility class for group related functionality. + */ +public class Groups { + + /** + * Does the member exist in any of these groups? + * + * @param groupRepository the group data repository + * @param groupsIds the group Ids to look for the address + * @param address the address + * + * @return true if the address is in any of the groups listed otherwise false + * @throws DataException + */ + public static boolean memberExistsInAnyGroup(GroupRepository groupRepository, List groupsIds, String address) throws DataException { + + // if any of the listed groups have the address as a member, then return true + for( Integer groupIdToMint : groupsIds) { + if( groupRepository.memberExists(groupIdToMint, address) ) { + return true; + } + } + + // if none of the listed groups have the address as a member, then return false + return false; + } + + /** + * Get All Members + * + * Get all the group members from a list of groups. + * + * @param groupRepository the group data repository + * @param groupIds the list of group Ids to look at + * + * @return the list of all members belonging to any of the groups, no duplicates + * @throws DataException + */ + public static List getAllMembers( GroupRepository groupRepository, List groupIds ) throws DataException { + // collect all the members in a set, the set keeps out duplicates + Set allMembers = new HashSet<>(); + + // add all members from each group to the all members set + for( int groupId : groupIds ) { + allMembers.addAll( groupRepository.getGroupMembers(groupId).stream().map(GroupMemberData::getMember).collect(Collectors.toList())); + } + + return new ArrayList<>(allMembers); + } + + /** + * Get All Admins + * + * Get all the admins from a list of groups. + * + * @param groupRepository the group data repository + * @param groupIds the list of group Ids to look at + * + * @return the list of all admins to any of the groups, no duplicates + * @throws DataException + */ + public static List getAllAdmins( GroupRepository groupRepository, List groupIds ) throws DataException { + // collect all the admins in a set, the set keeps out duplicates + Set allAdmins = new HashSet<>(); + + // collect admins for each group + for( int groupId : groupIds ) { + allAdmins.addAll( groupRepository.getGroupAdmins(groupId).stream().map(GroupAdminData::getAdmin).collect(Collectors.toList()) ); + } + + return new ArrayList<>(allAdmins); + } + + /** + * Get Group Ids To Mint + * + * @param blockchain the blockchain + * @param blockchainHeight the block height to mint + * + * @return the group Ids for the minting groups at the height given + */ + public static List getGroupIdsToMint(BlockChain blockchain, int blockchainHeight) { + + // sort heights lowest to highest + Comparator compareByHeight = Comparator.comparingInt(entry -> entry.height); + + // sort heights highest to lowest + Comparator compareByHeightReversed = compareByHeight.reversed(); + + // get highest height that is less than the blockchain height + Optional ids = blockchain.getMintingGroupIds().stream() + .filter(entry -> entry.height < blockchainHeight) + .sorted(compareByHeightReversed) + .findFirst(); + + if( ids.isPresent()) { + return ids.get().ids; + } + else { + return new ArrayList<>(0); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/utils/NamedThreadFactory.java b/src/main/java/org/qortal/utils/NamedThreadFactory.java index 6834c3b8..290cc00d 100644 --- a/src/main/java/org/qortal/utils/NamedThreadFactory.java +++ b/src/main/java/org/qortal/utils/NamedThreadFactory.java @@ -8,15 +8,18 @@ public class NamedThreadFactory implements ThreadFactory { private final String name; private final AtomicInteger threadNumber = new AtomicInteger(1); + private final int priority; - public NamedThreadFactory(String name) { + public NamedThreadFactory(String name, int priority) { this.name = name; + this.priority = priority; } @Override public Thread newThread(Runnable runnable) { Thread thread = Executors.defaultThreadFactory().newThread(runnable); thread.setName(this.name + "-" + this.threadNumber.getAndIncrement()); + thread.setPriority(this.priority); return thread; } diff --git a/src/main/resources/block-1333492-deltas.json b/src/main/resources/block-1333492-deltas.json new file mode 100644 index 00000000..835268c1 --- /dev/null +++ b/src/main/resources/block-1333492-deltas.json @@ -0,0 +1,6106 @@ +[ + { "address": "QLc7AsWVc1Z5jP79MavtnnYiK5EDzWNQo5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLcAQpko5egwNjifueCAeAsT8CAj2Sr5qJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLcQyompcUKMUEhe3HpjRJnndQUL94Fk5t", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLcbrQP6pzHTF2dKscKZeSc2wuVL1rGr6J", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLcnnJygHWRvCYyxk1EUwMMWpJicgp8WkF", "assetId": 0, "balance": "0.00000988" }, + { "address": "QLcpPmHdJwMg1rZukHU54SAf6PzsP9nAyC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLdG1peyDTbYNyxQqZtZmR6DY88PHUJYL9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLdHTh3exsoEvpQZNRfWqJ1m8agm9Y7wmJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLdRmQnJJnMj9vFvSnNrG7dZgP8MGtVobF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLdWowThLpktzhMq7SSNrujj7s3gfZsP3q", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLdcfs7P7hZ3YXikAfgTFnzMSKTb84UHeV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLdcwc3Pt7rWiWi1zcqPMqdKBFp36vyFdK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLddS8w8RQDwboEYaSeKteB143Zsazba75", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLdePGw5anSgU23Zz4DQEGfSDANHtkKWZ8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLdezVBg17vULKcUpGrjTTaNnUNuv86Y4S", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLds63V9Cwvibo6hseyoZ5Ggw3dNgz8E94", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLdzJkBrDbV5oSPXwqtkff1Ek5qP6A4YWb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLeEz7a57ZCuPQJFAviS17xnfRmM4Jz9Hx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLeQdQHniu8ZjZKDAtFC7HCaYdxZaD5R32", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLeUdSHqYyunyJ8urxdmAnwgQQGWNv9gKM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLeYVsnrSA6GAE1M2WGfRTXq3JBn2gmkEx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLfBmz8ZxGvLtPq2N3seXfVTKXjLrZwTKa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLfGhcyvRtwb1iGsDqZfotDd4pBkaHm87L", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLfUgN4QRkHacD6PdxfUUQUBG7NXkqz2Pg", "assetId": 0, "balance": "0.00000988" }, + { "address": "QLg2LYeUU7cn4SzMtW3Po9y6FikYP7L4HK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLgRCBsh8zd7aAseQVznYMQdVEvTbvrcwt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLgYbWJHU8WAL8fYgFCPGKW4h5YGG3ryZH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLghg5S1gATv3wY5N6sKnW4PpZksvzYS5d", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLgkv2jSjrnMDufua7VjSu3swSAukVZMmJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLhxpFjnYi8HToiHep6X3okP1U45bpz54S", "assetId": 0, "balance": "0.00000988" }, + { "address": "QLi8RY1wquju2jXpEgJ1f9e2i1NyBQhxJy", "assetId": 0, "balance": "0.00000667" }, + { "address": "QLiK4uVxB3SN21XXvTBaQJQbAYvTeGMsVa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLiKLY7pscmwUPUsK8fxomEaikKcKYZE44", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLiQp14Q59SxYQ8cS6WuB9tk6MuJNJstYF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLiWeWrqC79QP6H1ohi795fSr78UWdNK3a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLiY67LK8o1tcQjbwKVkPdWf5f86JdcL1s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLj3L7YAX1TqCBvkMcXJ7pKoVyVwhjhhMA", "assetId": 0, "balance": "0.00000669" }, + { "address": "QLj7TizFM6bmuTXoHyTxtm58cpPzHx995r", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLjE5xQbBcALTSpnuu3Ey5SG7jqj4ke8hZ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QLjiqcgAtvWMbwk7uvPQ1mBZMuSgpwjYQ7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLjivMvdhtA5py6okVdmS9S9biUw9dohV6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLjoZdqa37x9epuzSzsxpnpp14k1nJXadX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLk9ZLUj7WdAVyHNZMhNggvDDefWTveCS2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLkFXEQygEDxtJGFuJrFjFAJiHESRrjViw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLkRktaMTcYMKNMTxvLdh9rgsWbhVth2A1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLkc6UqGL9cvDMZD11T3xwzEXf3wUCoTVo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLkhFHsDcekXpz9j67BkahBPmpBh514c68", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLm2mMFs6FewU1HXtnirMv9EnzCFJpmDbz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLmHq7myUueVXrYa2KGhmQ7kM1EiwCD5Tq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLmhih8Jbs7AwP7WKgiFH8C6YBysvB3TZV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLmooPMCg52jfuKZToYWx8oFvQbEAATEVg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLmxHD26EztkKmSEeC9vGEqu7rowyaiBNL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLmyafCB8W5AQU2W735vaw5bAnhdxu7kuP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLnYJZfmisfKD4LnMFqLxwxYwAAzTaB7yo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLna3T9TeYdximMr3F7Xcf9iTT7B1Hr9ze", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLo6JCRXbphMc3JQyVdTgqFYHPgNVSUHM1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLoHAStvYCHEeML6bHqghNEzRiS2zmHVkZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLoVEEsyTxAiBULLpepoWVTw7CXUZj1tCt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLobg86xXZBFbvztQ9r3K7GLLCaUgB2jfm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLon1FceRpG4KTUdFq9cD7D53tj6iPVhM8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLonba2eASPawEEiEYgum2nnzQ1VY9kHfv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLp7ohbPYSqeqZmPHwAtc7edGLEjsE46Hw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLp9gfVoDYcpfQ2iiGUSdB3v4ZAwkiidze", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLpBYiNR5y6kPWP5ZVwubHgfTuNCNPd27G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLpDh1tVcc1GwrtCULTFiQxtRYDmH75jnU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLpZKzNUqnCNqtco4tU2H83kDReCYdRndr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLpgRfhJxbXaS9QPbXo2oT6N5YggAX8m7c", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLpy7qLaFZJrVQQjYWC8vZNNPhbTF58x49", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLq4J1GrxNTS2i5AB3fQtPTmi5u9Zs1826", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLqSs841jDXCJQ1RJ4xq68V5V2FQtP9GkU", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QLqeoXwo7RMJUsTv2sKbBffjDvbSSQN7vH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLqfbz9JEAZ3ojZnASLLgDA8Ryg7NSLxRo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLr2d2rVviocEfqta6cRb9uKZfH8YEGb2P", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QLr8tZ9v9JGEH8VMFm5sEMURV9nspn6fPa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLrUPC1WvH6sEsqztB3kMPzEQjWb755iJg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLrZWMaPhs71zHYYoN6h2iBXGF7ZPmT8zD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLroPBRHWo9d6thVK36j21og8AYo3fZcfh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLrwsxvoaLZ7UA2kfA9RUxepVAKYYfLquB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLs7wHxU2689FmKgwn9dATtvYHAzHCvyBs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLsBZiJJuzwf514eK5qopHj325dokVYi42", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLsj5Xsg6xAaTdftdHkRtbgc2kZ8GSJj4G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLsuUu3i1NKxre5YCiNL1Hi79nPFD1MSzk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLtHhuoK2UYCfxW1ab21LmGGERdoNVvnpo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLtzeiQ8BGcCDKNVLQHh5GtwPvTyfBcF5p", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLu1ZhFYAHdq5YPemwBBy6wNtJS9ZnsiV4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QLu4ogGSb2NQAXmEm2EjdpLePZdNK44Erp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLv9ETxNjq31yZQ8D9RFrNn58NgXidW35S", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLvRFpP8Z6gxeoq6wPFnoZs8UnsVDiCxUf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLwMaXmDDUvh7aN5MdpY28rqTKE8U1Cepc", "assetId": 0, "balance": "0.00000018" }, + { "address": "QLwZ15mVQ7NRra4P646GEqJ5B631XfrPfE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLwdXJ6nYDXwTjGVFzRMiV9FSBQxypSrvs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLweDCgxSm8YGGJFMJB3MvSpc8uYBpoF15", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLwmtwHAbQkgxqQcpDSQHrJGuukfWQdoyM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLwu2pG2M6ckgJScNkxovbL8Yket7PPrWQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLx4vDnBm4B5113CPm17KDWnoXq6ALM1bo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLxSSDU5QNfpSzWpBuLSauuj6uEecqUcD1", "assetId": 0, "balance": "0.00000988" }, + { "address": "QLxdfiNAFyJEMEwJLDnsJUZmrtFkndqy5e", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLxgnacYpr8FijAJfXJM6KNJGAY35gQth9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLyRStGCSGYHbbGJJZDekqRVw4wm5Xp6EB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLyXsWvyjSAos22p6Gn5aLevdqWWXbkdCb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLybsseHQYwSepQaAncX8upFvDexaBz2tY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLz3UwbCQ3uTPM14gaVS9tFG7pYJQPcFq8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLzDLVbjkPsNgefQXrRpdNFEc2aeueSbdo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLzJqstGnFLfUR3GQ3mrGhLGjE2kopvZ2n", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLzNf2Zg8orbmUXtFKUvaAtxx5qziVnYUi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLzc6xym3HgRsHfzsNZUgCJNGFkUYaB9vV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QLzopsvzy4ubxXhcd29XNsEUoYoTtB1i7S", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLzscK2YzgwLwS72CufaJdTtfxkCDwrLLL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QLztbwDKdJkhLNELtjBN4g6BXdoLVDEzeY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM18Q9VKaotV5KuCXhPd1pqunWMa7UfUUn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM1KDTQNeULoHFp1z949o8sPp9cG7z19ad", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM1LfNx63waF8nYDnzEmUZxbeWwtL4wEba", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM1jwcy9hgFmjkbNHcJTv9ksS6q3gpeHNd", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QM1v8PP9yRakqDscTmfLHpmBxBTuu5FYss", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM2AkuZJ3Ee2QbasLRr33bQri2dnogmf7r", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM2DE5zs73Fc3KW5RBpYTW4JxrKukEUd7L", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM2KMd9C4yGpSAaXDW776rERv7AVDpbqMm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM2Mjp1Y3dRCtQKmhhXkV7U8AhwPLs3De7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM2SbBPT1EXQE8QqBZkoijqC6CeThLhVFH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM2Wxy2TL42MBn8AwPhGyhS7Wc91nuMLzH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM2bhAgSF6WTx7CyZH9NjjBJT2kCu5ovbX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM2o7fbjx6CSqKVoESnBHq2vofeem5t3Vf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM2v5PwRFSx7tYCKhCe58YM9zci8PhMuXe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM3LdxYGpmLFL3SNW7wZqfyLPyexqGaSu1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM3vdRufa1uGMPNoxJCDcK1Adtx9QrHWSi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM4GdJtyeALFraeBq4tnu31Symfr6vYgpk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM4MvVX5K2yiGcArpKsozdPitFavdBqPpj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM4UoNnPQYtUi8XSGjkzi2b3RTHWkmSMhC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM4cy6u7CeiER49j1U2fzeziYBMncX2N2i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM4jqMwfLbLM7xoSEhKfLUuFWUPvdgXPBF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM4tBwbWaQqTMf2NVmaw52P9k7NnErwA5R", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM58EdC2hgFGXmPBZ91srmRqS8KgSwufyR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM6dnoedvjrAgjZYS2ePkz6Jk3rFBhi9Ab", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM6jaiYuztUq9iLBCHybyQgofAw5RKD1ZF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM6obE2KLspDboimMGgdXGiz4qgcFeyQC9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM7D1muapsof8nK5qP7nrhTwe8gi7XeMRh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM7DByb5YDvQ65PSWf6xANYUuD77J7HaTR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM7Q98mw77JXPiYwKqXd3k73VxYGckkwY9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM7eJiSzbeCJyYzdudhp5nrie6gE6GeZaX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM7gZsy5p5qxuyikPEqMUcUArDHtJ2A1Kv", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QM81vjbjKz35mJ7DjMN2bARTKaawmi64pZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM84wzk2KfBRnTLRbpCLYGDUTC6j9xiAkj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM86g7mEoK283nfGwgVFFXYx9tPWg9ZJxY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM8BBdcBdWnjbgJGRu4Bs4w5jvwc2GxC18", "assetId": 0, "balance": "-0.00018150" }, + { "address": "QM8KKHC7j9XrxDjMTPfUQ8AZxx8ycj2bEt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM8Ygx31n6GDDrYrMXisNY1x82bqxQyPem", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM8e62vrkAUBVDm6YgDiaq1njpncneAwRy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM8h6Y2kWYfUFDU2uCF1ZLDxh1sCe2Y5r8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM9fWdccEybVUowsJ39LNVgX8C5PsxLjMv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM9hbS2o1Lrw8bYtDZQ8b2nuFPzpApUtG7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QM9vyeam39YqMEVNKohj3oVySbsKJsJG4K", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QM9zVbXXnfrtQ1X7zPQ5zxPYPAWTaVMXqZ", "assetId": 0, "balance": "0.00000003" }, + { "address": "QMAa7454xP2YA59RBoRY82NHw8aQAsjEbW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMAraUhCuvwj4uMR8ouUajRL46ERUA21Tp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMB8HhUtovtx16fE5HFTDmDnrjmnGqfdnE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMBELmvBJ7Pu3BHejuAmSM819cTGTg9fNo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMBLyy8SvMWAxfzpyxWZ3heBvkimzGskAL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMBZSNXMAzZiQmeqpHuJKpykZFsvqkQEW3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMBp85tqidoZuADCWqSrygmc1jendbGzgU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMC3FQSa83vDUH3ApyoVnJe6P8JTdedCiR", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QMC6Jzwy46FzzcvF2XRarU6QxhnEnqvCeS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMC7HeiFyfWcWZF52JxRZWjiDDJ8FgUhj5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMC7br3LhDhohqSP7m6iPyLRSiwidTm5n6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMCFnrzasKm2wYTzvhSkQr8FSWNJqBfM11", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMCKNZsLQwffxAGTUTZMpL3UtEbCevE738", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMCxYbYg58dS73X7wrn3inU4nTZWeijz32", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMDE2kgxmWLx7F1Sud5nq41CbT69n8UbnZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMDJgdtMJQayZAcu1UcBWCnBYKiFYPj9Dy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMDKRu4WHEfVbnBdEG2XSvKztC4cTaCvWT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMDPFrUnp4b44HWHGxBu6fkLvdJ5o526oW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMDRnrhSabF4xy15qYeMRAZ952BUP88BjK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QME1q3RMokECjV7Z4PrPDyxuDLixJAfrom", "assetId": 0, "balance": "0.00000015" }, + { "address": "QME1ytUsL2BrJYrZGimG6W8GuiEeNZcgKr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QME3AAYxZcc8tBzqhfh5dQdqdXyq9ctnLE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMEFWMLuPfmFzzGH5WCECz4VE6HjenLic9", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMEQrhyggeBQecEczdsxiLwY7tFHFQVJjw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMEpagrEEMuH2HxTHG2Y8JAG2HFXh6TbSh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMExFd8i85n3hZK5rVsis2QrFAgjdAGnuF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMF2Cpw8ZRpB8qCXjYbviX4ZBtABDpAKhT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMFDSjgJUci3ePK98J8UJAAwdDR6n4NoNM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMFG3ucD5qJXShA9uzD3gVnhfKfTnNnJpX", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMFLQc8kADG9E9m15YPNUPWkms6AMifB5A", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMFquievaGWrKddfjSB2aVpnxu3AWbQqGo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMG1vFu3rdPRrZXLvgNqD2okksR2FttUMc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMGRr5B5tUkcj8MqB2FoKkkwEhyCq1jfHn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMGWXTo34SK6zCv8RjcKACQKQq1h9FVN8d", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMGgED2eawpZZNoRTGghwkMP9NLUoCYoVw", "assetId": 0, "balance": "0.00000655" }, + { "address": "QMGzJBxbrxxZ8gpzSV3x1NNGHwHXLhEL58", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMH8qGMEHvw9YPUCE2fX9recgGfSWh6Aao", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QMHKttQxBBYhSWo6WhrTCSdWnJhccWAJ76", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMHLdqqeoQLrK85ocHUk8XdxWawKjjxsF9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMHbcfCReXRZPd1xaRaFMdFdKXXUsLC3nc", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMHobmhHPTEqkEbdZcBeS1bfwwnb9TewwW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMJgFB7dpGkLCatxzGG2Fg4qTaZyZBzGAK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMJhRryKwspn4Z5rivvJk9HJgxztbEQzqt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMJj8w6SLdWH1g2LviCNEvVrSUHioAFLsR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMJwdufHY9dMoARHCUyGbMPAqUB4BcqGKm", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMKcUL7B3kHX5ftbEPLncYSNCtVds4jBJL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMKv9YHkd6ewiad222Vv8JD9uqMBo3f1Pn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QML4yP7emfrEqwQBeTW1KVCkV45jWbZt1C", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMLKxHueHuHiitgrejCRLnGMyuyJRpxgPX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMLLzuV5XZuwqgJB4SPijzmNuNuDnPgchd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMLWhDTHZMHWr8Xna1eV6SCNDhLMZtmife", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMLmsx5gek3QswTZtdh1YdKGZZLQKCNJsD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMLzoL7vJE7mGqUTZeZie3PcRTqukBeiwV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMMDE6Y4nYmsuTQDxmJvyx7TYpvCBinRvh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMMDd3Wz4LL32QFrgdgj5ZPBrFuKevfDd9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMMfxWmkrPxddTiTBLcBuV9EATg3izU4Ja", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMMh94Pfs5LVE4xJee1yggViqP1YDdQHT4", "assetId": 0, "balance": "0.00000002" }, + { "address": "QMMozheogDnGLCk7CLDyaExBfFgAzgQNbA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMN6jnpi5S4jfNnA3qQDU2rUYs8D7XG7VD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMNG44GUN6g6PUgP6zPtS2TPfB12MYYBAW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMNJRSNsvwAFHAxFuHqpJu73BqNV7ST4UW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMNS5UZyZc4gdV3msX1B6JY9dMEyu42A6F", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMNsee4zuwccxMBPCF3sxmUh9uNKjYSB4W", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMNySu6s7qpnCLmyCnhtbwXYDUf6Fi16iT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMPNM6p7zADqo2hp4DTXdPEZLgKTQ7qJJr", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QMPfKpP9JizxhxGm1KbawrJT9yCQsN4BXZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMPuLqETQBtyYP3ECxN3pHY81qo2SWcSVV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMPzegoYsWSg1WkxJDtuWGmAsKRSdM3Eqe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMQ53hZrN1tA92w42NnwuwWdS31CqLpi2p", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMQAba5bXoC86whppzbRkkhPjnU2h78WpJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMQKzygMix6WVy2J1kdepSSHjJnk2nK6MK", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMQSPtQqhW6bgTJXVMUh5kzSA2wJehr6Ap", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMQY6UyY7VZBTF78SfaJTTNvMZra6sg9mU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMR1S5Tx71FTWnyQPmLTqpxqWFLgvQTynv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMR5mjB7YcE2PEyeuaJ5GYyr2nqU16MkV5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMRUe5GJJ6AFHisGmGBoVEaKBz3ecQQ4gc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMSA55c2jWuesUpxSYyRne7R5GvFRgTLV1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMSMei85WXLsYCJhvaDXfqExsXMyhLTPJT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMSZSeXuRUAVzaBPWnqXCQkBJ6fMHZTmCT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMSaqd94VanazEY7K5twr4hmT7zBR4YNhg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMSfC5v8AF5SntsKuKnsgLt2EbaMNWvNhz", "assetId": 0, "balance": "-0.00001457" }, + { "address": "QMTBFFuaYMMbfNJsBJypCQfyqeMc8pHKVT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMTDUob97sWQkYW7Q86gc6GSddELEUUPnB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMTMm8ow2N7QfV1Jm48vPGqoWkxxG6kNGb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMTSWxrJW3pvc3xCoNEAtfwoUGXXuugBBe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMTXFuy1rm5gNqQdbAVWvtz4RJehpF7Gpe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMTdmVQZiCUjaDsQUxhkQeK4Y9ktkFxJ1Z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMTejujyENeeewWpFjnDsV3RK1ruztdERp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMTx9DRxTALKBXR5EtKAZngCVCzRQ1MPvw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMU8UzT1S2dqvdB1jkrMnp7GyJ2LnANSaC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMU8xadpQdR5P4TLqfNqDrNWEt7Y4LEZzh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMUP4S5FCtKG4W47B8VWaiMhteoPzoUmz7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMUWHAPd7vFL6VGafJhXfU9dbkqhRZJTj5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMUhdmjhB5MujWgXwdzMsDPM5rGvYoTVaZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMUorgbCa12VoirYsQZwL2zc5q1aiGXqfe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMUqD2GbfnjAoU3SF1K79oWSJg2GWwqR8j", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMV9a8VhrEEGLtzpZs69YzpRya1urvCEUb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMVQoJQE6fuWD7o6Jj9NWBRzoD7rbRcvUX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMVZpqrMqTfVqWV3Xfhbm8wh3VcemJD7A3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMW2ax4YhUjc7KmqVFQF8KDy71QgRWh65r", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMWg5hg859RSdMCQmq4h9gu85BcabQZrCW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMWqUGZQYvcZzQkAppRvdWRNyFuub5h8zE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMX5523Di3o68FKyV5QRQ572PSgJcMJR9M", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMXC9fNhKCXR1ZAGtnroCxu5SNUYKFhMS5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMXGvEx1yci4XvvYQeXvdAwJtmFjV25jCE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMXVnZKTv9MA3X1PDoCvt3rCdrLGih1Vim", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMXohrnbpzXZqeJeV8TCtT5EdagsLCrDvH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMXstwUoF3FH855i9HadyoF6uypfo9qRmG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMYiizLGogL2ehGsETz3cREUsYj5UVJCXb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMYo3ggqmpHUUAje1vZ1VM2uXATV9esNto", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMYxRB9Mhq68dbdD8EsuhB1mXpf6aKPwp5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMZ6RRQ39M8gkYahozE4Bifmhu53S6uAqw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMZJe6ZxJ7rVQmX2nUqH6JdUAXXyJTvJHu", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMZctDZbD55sXm2EvbGoBZZHTy6KK3DwJN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMaP2fdtJATvT51DG2u63Ma2ymEC463inn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMab4YYYU6V6dGSbZ2ZzPStW6wLy1zevpN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMbRhovdts2x3TkTbcG7RZZn7yFu5jxmfm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMbchRQXWhdRX4zm4SbSbv9tLusKzSdMei", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMbo6JtTmVgAaufRYvVapaXgGzmBBbEbks", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMbzBDRujR7U1Nc2uapvjprQxrsTMh6xLQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMc6vB4cLeUJPr97ncwgKZF8HqCSPmoC2n", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMcX2CUZHthQhx1GfT9u5re3zQn7eWWVu5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMcXwMucZ8rJ3XkcHufYkB6HETctywnbXj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMcgVHKUZiKdbrS4gptuvNuTbPgHYTJxhx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMcxVkDBs1bKUcRPyHZiqZDJEewYrEL8ks", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMd159Z3j3UNGvUfpWVxotbGjvYqa8LW5f", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMd2A6gJL8QgF71ADRSoN3y9ujBFqkrg1x", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMd6YLN3dpKbQP8GQe8GtWuLWEkhwF8sC3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMdJ37se5rUWteNE4TNGHy8P7Jn382PTJV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMdMvphtbnuJRjXr6EAJBaKpW9VrrujcUB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMdSfok7hTT4zyQJrU4SCDd7y5vFgPYC7i", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMdhtE5czpaZuLJ6eG9QBVSFRNfaZyEr6T", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMdoXnbX5WGN5v7e1yuw2ib7bpeijghGP8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMdzoL8jo6hJuRaT7FYpgY2eUWaPEzKXkq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMe6E7EsJHDfdyz7w86ckk545XEtFJ6VFZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMeLa84jXweALBPW2RMmnUmH7XU67x2yS2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMfG9MqD3Kk6hgMrk2YK6uvMzg6h135YeG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMfWg9oJg49izXMeRWrsErgNnBD6mJcKiX", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMfaHqTT3wXLJMSekPERxgyzwc1ybbr6DX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMfdwCGEStjGzKxrYCHzmfkAsmVSvbTeSW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMfhABGLbceexdafSLQUzXRc6aHc6qrdBp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMfjEMoeftMjfzNdkA3kn61sGEnUcbgi2f", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMg4TykQaRWcXMSrD7icPFxz85tubGxY2W", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMg77X5bDY72VERDNJfxbvU7ZoSmVduabW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMgPaiuNAWexKVFqBKPWETPnuDe4tam7mm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMgVQctByVrRthVCFuxtxN8CydstJx5a1k", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMgb3Q7UMnTjodxFJRRrNFLCwSxUyWLEbh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMgfJob21uHjsMaFFx7PHgqgfWh4B1DqEQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMguLqqR1uXUu3qSRn5B7886spLGFn4jzf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMh571yeeKh5R3VKAjfPdGSivq91vBCdmo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMhqm9LnEFV9a7Aa85j1xyXr8rH1Q8nP14", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMiEFiFQQCCRWApWs7gbnEyeNm9FS5Avcs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMiMfNJXBGv28oY6Re2KvDn463ErXFfZV9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMioyYqbCgujVviLM1Za1pZb5D9GZVrZb9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMitJ8fDfseTDXVWHKexuKsTfXd4a4M5C5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMiwxZwKX16sgk5Yu9GBaGvsVbs5ALdbXs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMjcTH8gBz5dEfQxSbBe6xTvQxwMz65roZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMk7eRhc7gYzb6JHAy3gsKBpZaARYpnzP2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMkKo9L6uzmm9W6RDhTwyTVHgwkMdqX2Mr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMkrRwwxeqkk3CZKAkbiTzhppPQxoaWKK1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMmS7dVa4P5ncyDVMnV6WvDDY5SZrMfD5i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMmn3z5eNXZMkNz4rXHrkWvkr6N69KMTK7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMmoQaqd9xSsHz4zVdTgqQNXUJLzjK1sbd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMmz9RKFp69Afbh9dnLEWBq5txs8fMVYFV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMn4Qnniu4GCXkuMrtzMtbxB3K8X4q4Fn7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMn9114BM7jqcjG2jR7T7r4kxEHjruE4mL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMnHnYj7Bw5Ci4n5pMiUg6eVJnysL7sT7t", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMnLbRc9VHEaNGY9TFWACBqWaUVDxGshN1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMnyjs6VCzimHF3N1d2hYbgp47fdwPFxhr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMo1UrsCaPuwPZuQDvZ3QjJzZuvTtSs9rX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMoZ47RNRXfGamT21UuEvSoAMZ99q18Jce", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMou8zm1a8Eonb9SydHvCwcBJb5iJopbNz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMozpRT9aUunfmPh7EtQ6LPoth2JFJWBXC", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMp53KiUgxhau1GiVSQUh5dNGiWvTN1zHn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMp7H7c11SqAEVgUb3S9zpJXRSSKowqsF7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMpBFrzm4g3pX6fGVXqwLQ3B88ZtSv1pVq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMpRzCm3bptgzxLo7xkX8pQes6ivKhskWy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMpawSmde4CYDKEPtVs5qAp43jWJrayrGM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMq5si6jwLXMuwzDkwxyzT7oFVxHhMtjsz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMq8xjsCPs1NyAfy3PAeb9dNJTxqeCFBTg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMqqE87cMP6FMphhuhjPPYzHFJ2MMtgnZ6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMr8FWTDCnkz4qcYPcWUvqfyQkbwbJnV7g", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMrECDFoNGvs9Wm2v6BD9sBueoKRHjYucj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMrJgqqcASJuEhLigGfTwvDnurR47DDt8X", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMrgKnAMDDktXRTP6c9qeSQpVgSL3BfeeJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMs5tUeuyPuB56h1t4qB6YZ38Ux9NcFQnT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMs6jeueLdbL1YkLYYAzzrdDDBPjCct1Y9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMsFjUbPYyCgH5osVqBVjwiB8VyVNH1gMT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMsJXwzheQpW6gZ3emiinoyETyJzLGHy37", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMsKXQAYKmR7dBH4P3kMLiKzYatK3h1CeS", "assetId": 0, "balance": "0.00000002" }, + { "address": "QMsRWiwX1rNMeQ7eGK3Xz83pNJTckkPMb5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMt6UeGcA8BLkLkfAjy8AShnBtu8ECgooc", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QMthSs7bHqRE4JHhnViMWB11hu6VNVNCTh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMtm8wVPHGE3qHg2hMaj6SZ78D5eXw3VWZ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMtpdJdmKHSLHdyTtckcR4F12zytp7uFQS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMuErsF2VqSGtU6fWUUJ5Y45US8hvsgm32", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMuL6zBSvyUmWct89si3AFkWtsv7Rtg7MC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMuWNAJ2tbeViHtBUN3yD2KARrrzcanLAd", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMvde5Qnk7qqHYmEvVENjVeHPera5fwWkj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMvjsi51RD4Ak6CYhPBwuWtcR4Ft3uLzBj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMwCuxfv1PgHjiNhZyuRgcZHNBhdcuoMa7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMwKjEvXZnfkPmu3vSAJomoK2uWUs9HZ6N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMwLyuDgXFv91SaLvpEcexFKD4fS2kLCrK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMwUoaAwDtWfghwJJyBKen2dro53SqFDkR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMwW4Y2am7gzc4TTPrjuDeTrCTSR9Pe3S6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMwYFVzDb3HtgY2jx2ahwJurRLzNYuLgsA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMwaNMPM8mwCmARxjAVVmGqLLKkvLL6CV5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMx3cAak8offGmfCdVkR5TJrECH2ZuifQC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMx7EPLzhattdACucbxGD9srry9TaVLFhg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMx7aB59PgorGck1ZMrCkZSxi8ZDh6TBo7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMxFWurtaYySbS5cuQbeMU8m8659rerK1B", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMxQjCPxHHtgAJPy3tKirLar22TSaTj4gi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMxcG7GpFqpT6GDfL6UwsvDt8251Wkbp9U", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMyBSah1j6mtN3f2a33ENW4drtwarSTGsC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMyFa2nfCPLJj8fLHQtp2SD9mt9Bd1fL6y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMyV3ZofyJrRTmZfpoHrPoNZo7oA9vnoZY", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QMycffBJt2gFUYGAPtDUq6CcKe8Wz8XzZU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMyvJcrwn7hUVjvUV98anN7vDk8jvuuPsK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QMz6NGa9TZzChT1sMRXjiM7uR5q6Nn34Fb", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMzbEtBrrjBDoFFz6n7bd6uzLWy3iXeZhN", "assetId": 0, "balance": "0.00000988" }, + { "address": "QMzbuKV5h8UJ9duE8fx64ut8Wd9fhzXRwe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QMzqP1WwBd7PzCveN5vXXXqYnELv4jiUy2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN11cRVCVvcVbh3WwECvkZ2esJKwSRqxiB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN148rbnCYpn9NUrq5J27ZCRy92x7WNVB1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN1gxAaZ9dC32APFJQzeLEc4q1BWaZPM2C", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN1nXnB5ouH5mradNJ4379X5NVEYna9MmJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN1p35KvaLnhgun8gSBDAajHy3QzV43DXh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN1vJXvKsD7kinNcxbwJfJdUuKoyLXo8ba", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN289qYSyeBD5jS3vKnqHZ4nNnpqbeh2n6", "assetId": 0, "balance": "0.00000652" }, + { "address": "QN28B17xrpEQzrF9WgJ4n7UVmojaumc5DY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN2BBSNed1ac3TZ7JcMwmb9W1d98RNMow2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN2L4bQd2PcHsVPCz7kv12sgtNEuNoxtqq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN2LBeacvuN8CpciFDpRzYg2cG8wSYbF85", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN2URrogxECjKU27spyce7PmyXF5d3xqie", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN2YcKoW1KjLquurhMMqdqSoCbumatc1ww", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN2jKjkxSwCLFUVWXgHK1kjb4rnJvt3Kf7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN2umoNNnRtUgS8HkCjtVFuAmRnwbDXra2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN2xq7QsgByk8eFSgd5MXjW1ePaf7F8D9J", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN3Go3jnny7cjb29Rtg2hB4BwVVTMooXiX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN45jnJrmwsM4x9EgKCjQZQBXCTuCWbrYX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN4Hv9N1i7KATRV9wwz1m47PctM8MVkzMs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN4fwLieWw8jbuGy9s5tNm8rZk6LwS17UZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN4ywh2n2uAALBZyVPgcKsaZ4X9N6CfWyp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN5GKW7kBAtHosvUsXti3tp8DidAVs5Mkx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN5Z2g3gK5yPmj4DRoTjbRfddZUPV19aPF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN6wPvL3pLV29csJGZrJ2HuALkMh55aSt5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN7187iKfapGp7erqXT8biVH3LF1cdPXxw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN7WBnZXanrch1dDCiSgPdhLfjFFtKyjWq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN8JjisnXrH7F5r27NZakjuerofs1f78yW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN8QCqn3zaACzAYZdVWz1okxfdAi4nZ46D", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN8ma6EeNSb3j3c9EFi9ec9d4kLfjfPex9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN8mo1JwyTNYo341fCV17NCStifAU8thFB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN8rKYH9AW1ycGNxxvbBLxm6X1wFH1TbLe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN8xDHcpmdhAmzPFjBsbYZ5VuQP4Bj9jyr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QN95wtwKG2yT7NZkpU1q1QmFpNSYcdQVZL", "assetId": 0, "balance": "0.00000988" }, + { "address": "QN9E7MDY914bqaw13TxLrs47iPzW9rJF8e", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QN9QrGxwRUmXpwv2uWErTh2nogScn8knPf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN9iS9uB544XP5794o1iJXkmt73SaWruky", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QN9nr9WZjmJgt1767Y69aBa736zcvm7Ba6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNAN2mkxbonJAvgbUaAv6gxgTKfeoxRu6E", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNATiwquNn2gVqMrXQr5EeeuhDjKTjhb5a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNAUXJyEZ5U8fpy4uzQ1QGbyL1EFzVHgip", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNAVKy8r1WXTSisFcdPjwwYHsbMW479MwX", "assetId": 0, "balance": "0.00000988" }, + { "address": "QNAZ54LfHHmgQBdCpQKUYTRTB2f1Qn61cF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNAiDXT2Fk7McKN6ePFLRYGH619ECkmjBM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNB8FRa6Q2hLBwb6qXwZojbuy96EXe5rEn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNBdjZGUpJPy6bCJer8ESNPb6BUifTmV1G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNBhcjQrsRkVMg43ksULWLrJehJ1cZz6An", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNC8Wp2odnKJKae1kQ2UfbHcfSh2et4EKQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNCHqRw177Ct9ExD7FiAJaN6w6yhqkNuiY", "assetId": 0, "balance": "0.00000988" }, + { "address": "QNCcd6MXQMFsn5Kcbzbo3bYKMtCPcB3TSG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNCuoWySQpbzYwfFqAJR3V2LD33bAZqcRG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNCyUA7oX17bLbnLjLc5gLw2ghpkf4DhJk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QND3Mqpb4x7SGiAPN4uDdLiG5pVg86LFZf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNDKS2cyfDBHMx3MAK49N4taD6qVod1VxP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNDSb2HDgTtdTK3GBPjVoGd6L6MxG9hArr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNEAeoe2kQD8EuCQkrC9trDdLrv1P6YL49", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNEMJKGyFXJ9a4iRMEbDwHG2atH1gwtE3z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNEVBaJrHFa5ShGQtLa2fissVLQrH8rTdU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNEVbBHAk8vc2ZgAHgRt38hAmGSa39tU4o", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNEiHzuFERS1VjjFuo6QSB2i6eDPovGNSZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNEiKBdSZ6qJ8G4tUzawjmPp6rkT3hab3U", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNEmCBpEgL2PNFStnB9LjMuzy8JPZbTnLC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNExiW4bWx59vrtL2texPfBStDzrTnRqDq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNF5PZCHyC2uPsUBdPEDqeEY3KhPgYvWNs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNFUHLPjnqycEhPTCFnrYc76dB487gA4tY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNFYCUT1PrkAY3L4eohrdvzRqo1GESDXpp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNFrn668NZ1fDcBhwFUyV39P9VDbGCnjxb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNFsnWD53JWrQJzVAj4JWr75qS3Pu4MMXk", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QNFz1sdF48jx6Sumfedp65UqBfbxUbMant", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNG2u6HKEn6RVeDs72XZ2j8XNNDCfTiWYS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNG9rCbb3jShNMjg2PeRRX4YPhxFA1XnkZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNGCAad3tkJZ35shmhqqUnKSXvBuxk1tgi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNGDYKSXLeTXnVXVAu2yZWyJAF8SHJfTXL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNGy7PzWJNGWBbk9WBGXAQYxXBp2MtWqCD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNHBFkVUopmpYyguufnSe6DUcbThtEUu47", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QNHWDw73dnXDaMHG3cYLAJocBHrEQbMEU6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNHbnikWYSsNFrrixLCQfRmUCGVjsSjgaH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNHcHyMCqw6LxtHZbibFHo8eoHsqBQHJ8t", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNHdGeFJmPcDdN8prPzPL4bk2dpnJ2ZZFr", "assetId": 0, "balance": "0.00000988" }, + { "address": "QNJngcTWTiR1EGf5PRWiV76Wgg56tKz8MY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNJoL8tTdcZRpcZzGrfraEmD3AZNNzPcoB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNK7foGSb8dfYCs19upvJg25iUYhmrQF8N", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNKUxgfQDNxyTwHPdpXRghEvZVSg7uc1wg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNKjJbR9ytqUKp5YVTssnN3KpRSWpgmhav", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNKm52SfSCVKqQX5HbA6TDggVPtpYTSNqR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNKttS3SEKbfaaeLYxriipJ5xQdXVUeBiy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNL6W5zY74tsGdKBSKQ6ujtfJLkRQM8EDt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNLib3FtzGxocshDkDqNDhAkPqjRirrSrh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNLvyiFhzEHi9z6dokkqne2iDEXpv2Keq8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNMDKE7XTujNQkuQorcHXw6hL7qRvyaTjr", "assetId": 0, "balance": "0.00000002" }, + { "address": "QNMDNuTEitMG1RH7txKt8MBy637H4Ta8tP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNMXwEDFeejzCvNN3YQedjUvpkT1K3CBNF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNMZnDAbYDzKLYRVNpJFcEnr3411rQBguw", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QNMbumDyanM4TV3Xg4mVnwjLCZLHfHdRe6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNMunzH3PhEh7CnCRMAozf1pVFs5z7kSJF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNMz5ncYLZQnFciDktcF4hT85tzWSjp3fr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNNUH2rYckM6RWi8kQbpif6SePMLZTW9wy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNNcpvi9btiSooxQVg8i9eqLM6fWKiTq7E", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNNmERz3pobNeXe7NVphQeik29hg8a13VT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNNn2RoGMMBFH6CYawGUxpqcYuALQtp7Hb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNNx1Q9dUwJnpSvWdfD1Fg4XnENXXDk3zL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNNyUkwYhKuDKuhczw2gPSj5RJWVNVLD5i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNP7Cc1XLrRxjt7p1yGP7aeZrKCdLWj32E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNP8Lkw56TJCvVebZsreq88uEeqzVgsToq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNPPCs8bxg2rF4JgxhCMp1zy6Jz1Vd8ZBR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNPR5aFaCi1AZeyYKKtYqysXtzrLYmb55o", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNPTRkJgTL4Uo26wVVy4UjF8B8UAayy96C", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNQAPsU5yWgRTBgc9ghgUmC72QvQ3N3Efj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNQAxMef1GRvDWGejXRmRaSgsNhCWbSdHy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNQBPpjAXQuBtmnCRYuSdGLmDoQtmkonzX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNQFPbkTD53w3k3LGKcjF61HaoYCsnHZRd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNQdbZ64e54i8wgMPKVpKkHd6TGTF3Ea2N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNRaAP6h7DMEEUfxtHMRXLTzR3g2Sxp7YF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNRdtZiFLBvXCgZ3UMgcokRSwCqEoznjSx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNRvQj4fBtLqJDetcTQYkwWPM2ojPrCF6y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNS3Zy9eg2gyK66ijQQnoYsWjSAN9K8r7B", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNS4dtY2KAo6b4mKTF9Fi799t4HbsCLTDf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNSLppsufjyssi15CQPzUp4ggc7G6YHqs2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNSMLfpGY4KPqgEnwVJtTquj2EYu1WA6js", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNSWBNQDJjqHY1k6SNwZfGuZndwTcqMNMb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNSqXCkXkCpxtgEWemRbLypdvFye9RHeCe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNTHW4rbb23xzDpEZYFtaeSG95aaJTGLw5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNTK3G4tDd3KyDrxsuFRcdc228ktCt7w2y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNTQRi5sQGr2bf2X2AJvEzh7AebW36MCCc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNTWSqse3wrabbuqTvLH5hjHqghK9T4Kvz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNTng5feLE8FoXC6YscbXDYfnbtpwRYw6G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNUxgQjmCvNLeECHDSNPbSX5zAnc84mDoR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNVKrjEq5bZdiDtgo64m5kz87rTHqCwvCP", "assetId": 0, "balance": "0.00000002" }, + { "address": "QNVL3yTc84biGMGGT2mD6WWHwUvP4s87CM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNVRmwzCmR8KnUskh9P7JhZPBbyEwjEG9h", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNVXEySnbquCPS2xfFGArj9GTpVwR5WPBH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNVZV4c3WxXyAVGFk5DBbbsC6Zd4qBLow2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNW9bWz9b44krjmSnPW3Wp7vod5CqL31Ei", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNWgVXt5a4NsR28w3BM1Xtn1jLtSK9Vz2H", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNWiNeobzhob6cqPPmJLC1FK8tri7HQf48", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNX58gikygDhzRjViaQdMYWfyT56v1ZRwu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNXJjJTkrbQUBz7TAFgeLm1Lp8KdhkoNWZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNXZeWixm94eDg7ka9e1x3pvTdAHHFmMFM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNXjR7LESA2RDVbrRDyZd25NbTdip5bgvr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNY16ucWtAjeSPBQ7oEygLuM3LkNPGLAqN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNZ41NFH6eVe13yNemW68N4pUz7bArsQAP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNZ7pJunh5GcMciFHEZTTpMmzhmhSsJxQm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNa1wVjSeiNiy5N6UnZp1kt4gQeGVhUTgF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNa2jrmi3y42iqUhCLQEQe7CZdyZywCPmx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNaKMA2kq2dXJMRLjbvei3LUEcu3mj5smP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNaxZ7zuuMW1BzNaijSicLth6DREvc4c8u", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNb2Xi4SBtkvFtVNrWp8TXtzuEwMEnGnyH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNb5Za9VKLMni3MzyzuxxjSAykXbPGSVU1", "assetId": 0, "balance": "0.00000988" }, + { "address": "QNbNgkNbHFrN2E7Zj5H3TRac7cwUkomw9D", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNbZXMBibRgCi46mURWspY1K7vqVxd6hYV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNbftB8VffLjsqHtoyBFAdGnecFDvyiRXN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNbmJaJNJyHuhH39QGo7sN87GW5KJQTFgC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNbmM5ER23ysLv6UsKPLccGGkMzwYHDDsH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNbog23pZDiC4jr8ipJf4Ajy1ykmV4gA7x", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNc3wMhYnkGpeWEzVbpuisMZRMSxabynWz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNc74g7oeuNbwSdM8FDEMx4VbXkVKhbVKL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNcE9qM1TmDzvuzeWruh6xfLMqZPHXCYKK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNcLZobC7ns63hZZ3KATFL1pkJKUPcPWHW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNcT5Fko3mBVzr7ur1AsAFcisNvW3zF2X6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNcmSPWay9qExP44iQU1xmUm763gzEhfYk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNdvNboCd2fDaPj94vnMrTh2HGAhvZ7453", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNeBpWqMPMTijgvBFL8iYm6BDGEab9Py8i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNeQsPWxHcey4ZMZSD55vkkPDdPyZ8UzgA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNeaW34SknoEfVYezMtxX48tddHdaf71y8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNekPkgz6z3vMGcR31wEahy2rhobAxsB1X", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNena51aDYyayYqwVm93C3tNHthKAfkpKV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNf19umJLqRLFAihwPJNNRGchuSoE499o5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNf9rZCe87VHHnNaA6jkRvxjzdGucNNDjD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNfAAgRqLeCCqbhCkZL298pxcgYiTJHuE5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNfBMZU93B2JrystZx5M9Yi8yhx4W9LRnB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNfXzNxZFUfLyZveJ7NxEXgMfrfwmm9CaX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNfgf7opGAPYUc9nxDmS6ZXiKZL4jnQgnR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNfp2sQLme3eZFUvu4MzFBFbGKKqudLqGe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNg3rNjf1VjFkG2Ai6tjJakpEV3c6i5sKh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNgX6o34eCehSBTtv7NieUZW7QaNDUsaQF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNh2mvi2mUufzbqt8RJrgyhPTZVGmFEjtT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNh6EEhSjQ7PN9WXUb5357nuHFixYLcMCL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNhFKV9ythQhvaBYkwQWQfp13rLcUgKHzs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNhKHcPhFBB7TpBss1WGdoQ5UjR59vGCCy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNhRhwhXCZmVjTQMsmfqD3fSR2oxeNG6ur", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNhYfGH9Sccfi8btFC3ktTWDDgojPyAWn5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNhhBSAJYEaHic9nx7jf2zwCtiJJmCBTSF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNi2eeruGPpmwuC1QbarV1JimbZFYjsJK7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNi8SbYK5dUmNF1Yg634i4oErxBoesufsa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNiTnonHpXTeUrgNdyYWVDPP4ZdjkLpW72", "assetId": 0, "balance": "0.00000988" }, + { "address": "QNiaViCddKgNW5QkeFLPT5SVzfwqDGtuXR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNif68JZTsCMXjDNHScX8Z7YKMptANBLjr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNioQUYxW9cE98ta21o6bWYhF3kSF259ys", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNiuxkAEGm1a5K3EzPhEvnFHVCQQWuoEoh", "assetId": 0, "balance": "-0.00002764" }, + { "address": "QNjiFTQsg3woWkU631wxN7jZE7y5NbKbxC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNjkKHxZhxxRcvSXwzsJxh1AcBBYKDYnmK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNjp87W6tJwSZ9symJmjN8UHNerrVq1Sb3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNjxHAvnNz4MnyoScv9FH1gLLDzDV4fsu3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNjzToAmAoMEYJge4XGQ1pKiv5VvELk4PG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNkWja4itFpYjTcUHrZRueV7yiRoVEnR37", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNkvBSUiofMQczZtFUygxSSKdEQS1gJfQ8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNkyzoYqD54Jos5uChnNmk6EvY5LYaBAvw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNmHVicnvfmfbDBmtwWgpFP454cpdHxJDs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNmTCixEdtybz1ZTDnM2AESrfyru2drbfk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNmU97EJUoazbwFxkdNrEnxBmrYzYbVmeM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNmfRWMuibNqAQPPL1LNbXgHRf6xgkKJoP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNn2Y2fcmHxuQxhUTNFLL9eU5LXkpt9oQh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNn8nandGtBquzEF9hWBGfcypm9McQk6Vg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNnA39LKVQ7dRYQH5YrmUzVFDSyqADsT9J", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNnnYdx8LmkRxuaog8hxLmARrDB9QzAtbz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNoHPRd5fZPcq3DrwLreAyD69EUPqKkGJ2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNoRAk6XmihoFnqP6SCEFNhR66n3McCaTF", "assetId": 0, "balance": "-0.00000959" }, + { "address": "QNoWo7Y1Bp5XL5R1hEVrVUScLhNXAA2NxG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNp9SydZMHaGNooMm8yQa9Xk7pA7dS1oDC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNpMB5j2up31eQ3EqG5G4VUchSCFmcV2Dn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNpTWkbVAbCM6pNdJyWNi1wJyfh6TDzu7f", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNpXhGZMwrmg4LR2hTkXHMk3WHV4KjNjAU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNpySbiUNGNgiqPQqTRDQS4Z4f8iawJ1zi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNq2vR4YsC6AgSGk9u2bamea3MRGZNbjAT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNqBFbZXg5STiW6F4Lj1a1v8dMaFiACpJR", "assetId": 0, "balance": "0.00000988" }, + { "address": "QNqVv4ytEhTeZyyUd6LRXM94xqFxxaUGwj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNqYsw8aeBqZLpoiuqgGW6f1MreK21SMyT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNr1gjj9xBhubK21w1jJKt2szeRr2vfa4U", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNrYqn8pMjx8ax7jBeQa6onzrjEEapCABf", "assetId": 0, "balance": "0.00000988" }, + { "address": "QNrfA3ZzNah4ySqz7TA3B2KsiVBGmrU5Tz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNrhpbVav1Nnq9BcKVch4YPdVWZn3GdTT6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNsYjWzMgpj3sZ2U8vcKYHxYsV3mU8QXKJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNsdmQxYrgJJU4SKJkfqVB8Bf54DfxA2Qe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNszEnhMSKBbgk5SFvD3gDCqJUgNGkaDAY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNtjttTy6HcLUq1uMFTCnvHpR5ovyNxXz9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNtxRuD7TWyKrpso4pPLYfRL5w91rj22MT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNuSfDpB84q9Xydrpk7Rhu5mNY5BfWSVcc", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QNuVewFaiXx7fUhMsAcYgYwCPSAkn5Q39K", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNuWwAcSxDJjfN9JnC8MNJAWPA71V3DsKJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNufo6fpSTkarQPmTdBSW6b7UTamx9NGUD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNuifwk95xJwNGnwUtskvN7HkmurbSfsCh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNvPLBD49Ukh7aNGSZScQ7oJbC42GowPeH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNw9xAm9TUerin9QsapCPL9mV6zmoXyJrh", "assetId": 0, "balance": "0.00000988" }, + { "address": "QNwVgYAQZxc6KD9FMUT2dmQBLaXdnxv7yF", "assetId": 0, "balance": "0.00000988" }, + { "address": "QNwtpFRw2QngQHgsJTpoDSSUQNS86j9Zai", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNxUetQJGSwT7AD99a2BvzJtYggb3aRpsm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNxiq8btya1uZjdF3fD8uVRTBSSHGAftP4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNxxcESmPyUMg3P5S768wv1mcV9rSDzDpw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNxzSupFKRNrH1xRpDcz69Fk4njEX7J38K", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNyGSBBYyt1MNAoUxmw338RTWJdCLPMtoa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNyQaS5imJu9KX8hwHNVGarv5KdfGW9h8L", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNyuNLhpVfP1f8V8pR4Cv5ht8Fnh9ChR4a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNyycCDrmaPcfKJxogCQgRJ4FujKVH3ado", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNzLhxLCo66BRcD6Z5Sb2dhzW5QwZrfkpm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QNzMU13YxVuueojNCXegU3cDXUfju7TLkB", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QNzj3keo5d25uhAjzuhJcfZezFRNGPQbVe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QNzsD4tLWafG3nnuXvJWoPX86W8CAaBKfV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP16CStPRJjb8uQj5Gkr7KPct54hW383rr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP19PeDorJjgRQ6ktdbXayPMnETt6ocj2B", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP1ciFjQHxsBsw7FpwXj29axEYQJ296P2k", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP1rZUzpGHHTVtE1M4a2MWAhBqwen9maTw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP2STcWvQw2NJMJLWo3C7trqmW2TuTy7JX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP2Sp53qW1dRgnrvwDagpvxhM61VTahhvA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP2fsAEDH5ou45e5C99ZTHvcFxiMbxj3Su", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP2hds2BNPhsK7fyMHgGApzQDuGbjhEwbD", "assetId": 0, "balance": "0.00000988" }, + { "address": "QP2nutKMxahMvDnWjvFtGD4zBmvqxBeBJ1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP2tdJcWXMPzsKwpvMekaiZdRKmTAUU3Fx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP3FvZNKyynx5TXSboPgR52t2EeVDGhiGR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP3J3GHgjqP69neTAprpYe4co33eKQiQpS", "assetId": 0, "balance": "-0.00000054" }, + { "address": "QP3KSp47PuoE99L5WLvmJCPfRmTGhRCPh9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP44EjsT3MiLqAJmEHkwxBLUkm5XcpF9ki", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP4eGciGZMmFwFhVyDJqs1oj7uQt2frqo4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP5Ed9KihiTXWB2QzTRScXpZxDZaZBGR4R", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP5fR7t4SJE6F5U2q2gPLEh9U63GCEr4pB", "assetId": 0, "balance": "0.00000655" }, + { "address": "QP5gwCEo3kajG7AiyHYTRjjXeQvgawuztB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP5zMk2bm1ThcQBn256zagCBhE9bUMpvRJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP62nSpmJaXyUfq3vm8v2UJPVPbZGc3yhh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP68pkkSkXk5uoMGH34boPJewLHF4x8mfB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP6CgVx664SXqALaCJESxJrzZVBGYxhv39", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP6Ebis8WuuQqFE2trfoH2PpjBgrq382t4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP6iPCacc7g8qaqfTesM9hhthqzXkwqxLr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP6nSRqccuUtfwptW9uzvjerFGXsv2Z4oX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP7W47zTPdz9F5LvBNz3V3AB9y5xthgDEJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP7o9kZ2SMk4zdoE5jyT9TRjSmqT4SGjtK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP7ogL4Uv6he8DaRtoandA1zKUpVXa2gaU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP7tJrHeYrgyezWBLb8VsxcaECKyY3umkT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP7ujEwS1qJkzKszFtMQr3nCBtRx4TpyQJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP7vig4bSggnW2u1JinCHVzzEtEeWzcwRM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP7yGYN9fJufzuFWRSjwUXhdnVokypQ3Es", "assetId": 0, "balance": "-0.00002737" }, + { "address": "QP7ytv3crZ8iaW7mWmNwFX7bSemHXp1hBY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QP8AYWRukHyGBXmXEgWXTaSdiHZpXhv8Gu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP8cg6wFp834DZfEvvADseyQJxP42w4YEm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP8gQAjJUxw9TxZhzMJMuGmTAEvSijpkYM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP8rHEbqCr8D1aUHEn3rKa2Jgtahcjf6We", "assetId": 0, "balance": "-0.00000959" }, + { "address": "QP8xG56L8b28h1mguSk9LuzNhxbHgAoL9b", "assetId": 0, "balance": "0.00000002" }, + { "address": "QP91M2haBGwDcayzvGp7wBBM1pugC5Sse1", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QP9HJTi6we6m6emtir1Uvo7TxG1cx9Wxvq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP9njaUBNC4vR9Q8vXL2An3uh5Gc5cbrut", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP9o5fQKmNHWVrq2WsX5B5UBU6bZV7uzBF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QP9vU5yTsBjuTSFxH5Cb9VXYNRHKhMNAJ4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QP9y1CmZRyw3oKNSTTm7Q74FxWp2mEHc26", "assetId": 0, "balance": "0.00000988" }, + { "address": "QP9ysvXCybnhWbrZJjjSfbwCiw2LHVGKfE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPAbZSbRzLTzxAjWwnZa4fQrx6jWnBH6x6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPAqWH78FLR8dJvVebBvXUNb36nHpiKax1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPB3nDxAMCsiKSpws4U2rtfdPTQGL7yuMa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPBDHstqsjuuDqoaYbD6ZQK5eUt5VbmxCt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPBKh5ykVtWCxB1vjcBxmagShTnH4frg9w", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPBLrsyj8pjbNAcxwDnLNZGYNu1VXhyTXR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPBNKd4DDDwLmHTzNwSieh4nKhAJWzD5ir", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPBQry3ts2yHj4FRorYpm5GwAuWiEKF3e4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPBaruRN42UNu2fJhnqUJvqBk9tuGXYmze", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPC47mQjdZYZpPbrjWfbxhk7Ckta8huvmy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPCAe274su5BTV7TYpHPZhEX7qBRfkfZbE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPCdxRSGcMbPLPCwdR8ZWhEpcsRDC3ZvEW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPCeEzpN7NCGWZocDdu1YQ9TY9RBCda98a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPCjzD4qYwJ9ibU8PttqV2btUBu2SXF1rA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPCmyBQXe7CktDPdhrDkkVGRh6MM3D6dYf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPD3r5R2m5MLHiT6chNrkd34mP1E54ChDv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPDKAMt9rXgz9HyjXaURGp7P8Y4WRHJvcg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPDPqpEVAjSnDtiLQBU9wTtnG69gWpuw44", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPDf5uuPjV1DcxnbEnqtMPPya6G6GhEfM1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPDiLaet6eQg18hSbNARQ8fQHCuZr1dCS9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPDnuD8yR3DJY9ToCuW7oRCgW6taMENQEZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPDoYhfjJrQvfpwAv2d9CfybPnZEBcTftr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPDs5RqKu5UALF4NqGE68g8aiJoGXD2pSc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPDwiJezW2WGH6TZyDx2f7aquGnZLhyuVn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPESYVs3oFLBXNDnZW94Rd9mGYkg3evmmn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPETaMYziEXZcojWkYKP1en54t86Zm7Qa3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPEbVxXNm1SwLur1UuJE9BrPZqRvPbYBgy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPEbvVBWDG7qgy4smY8nWiie78Vec8qiT9", "assetId": 0, "balance": "0.00000002" }, + { "address": "QPEd3HwgZ9w6W9eYnEMuS4NmjD8iR3DMQM", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPEfj8GoWFMgVFxyZqoPfQAi24cvAGYiuz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPEnhpaDGQd7B6zNsCG1JpQJabBcB3xTJr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPFCNVADn8Uo7DwbmVcemMztVDt1HsG2B9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPFHjczMQCKST8mffU596sCzfFBrRXgkfv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPFjb2qjpS4FTb8HYoPoTfDUqsnxyXYcmi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPGCy63i5MHmpGzjrdHdcY4WY3sQHeiHUu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPGGWibzQKkGzMLLaLFghJeFVKx13KME1n", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPGepNzP1uU63Pnc5pMrDHKZ2y2jSVZrXg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPGfKWT5HPckrxUeEhpt2rofWFqJ9ySegn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPGm1nHJhMt1qjWRHoVvwbSgZeVBA74omc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPGvKDAhG86Z9UDyo6pSvDLUQkSCi44JfT", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPHKAbXcZYHxyAtaVeYJFQWJJ4onA4M4Ww", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPHKHjZ6GwLETsbzpXk7kZ7ooeLagRrrGD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPHoNMTkpCL7z1oPSj23Su2wF6s8UwujEW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPHopujyiTWEzgLvq4a7qBFnoPnJPqXvrg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPHvDVjgFZAhWWbgS7Foe9W3pP2ha52nPD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPJfTRacu7oFR3bAY6kE6k5piygJEKnk6V", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPJqFCGcwpmLY8c9B4bWxJGEgEc8VjRv6S", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPL7pg8tFZJzfHRQVeQ9jXiefSFxnZpgGV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPLYbqHMuAxadrJoFRvA21viCP7jdz4JQZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPLjnTcwDbi5k91iJDCedbVe4p34YoDpE7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPLoqpwAoytvpQKwvJ6GRsaRcVZ3xnYgVB", "assetId": 0, "balance": "0.00000225" }, + { "address": "QPLutLJLLv8975o2wqaLLa9FxKCEDWiBkF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPMCQJoqwEcw9V2YLkcapiH6vpsMK4R6yk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPMG8JDaR1zBVNfK3eShTNC44c9b1JFwFC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPMGygKmvrHKF8L9cMQAh3jAdH6Zu2Jtxu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPMYq4tVykGLpjSyFysCDsd3c85bWkKpFc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPMb1mvbVMaQevmKsWDsNZ3QDbv8CoTEQh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPMdfBeG5y2uKsDyHCEYXbjjkFEuagVQ11", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPN9J1T2vDi9WipDHoXGmonLNkYqzh1Jzj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPNQVunbtVS53NtsqzEucg9LnrCib7vZvj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPNuXTLsQaBzUTENu4mmhLyqTAKAVfdVym", "assetId": 0, "balance": "0.00000655" }, + { "address": "QPP1c8K1X6zp5n9ki9BNKhP2fgCsaUmjju", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPPHnZBK5Xt2ciTVQzrLoF7V5mqeneePg7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPPPZaDkQ7ufZk4UWrSphGeYy6kSSNfnbi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPPQTy7QjbeJWKiRuXP6a45WeXW77bBeYm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPPvgYBtCqzfCzT6nzwjaB7GJX7BugrMGx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPQBAJnZDFESgZR3A4SxZ6Y5Fkjuuu5xY1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPQMiMzMtAcZJVKxxoYcuwo3Mv8TeZiZhH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPQN3kR2vfxdTYAAPaLaTsYy1mS7EgaXzX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPQQ1NmBNo4AZqpG1vNidD3C6p4b5w9VHL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPQaXAcVz6jtP9X5oCwUhHrC6PY7jpof7r", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPREQjU2defiYdgA33HDiLNGBpxtuebeqE", "assetId": 0, "balance": "0.00000002" }, + { "address": "QPRGzBW3jdcdS4vAd5yLfPsATKYHqK9fAk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPRJwmpAh2A9ed1V4ib2GYat4XEZTebPqr", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QPRzLTatT6vDdKtxw6fJ6EWs39P7UrDfR2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPSC7msxHhFGEdUQXZanbq7pXVSmfSrQBj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPSjZQCL4b6Wxobv8idsnRdV15tjkS1WGV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPSma1XEzrwdK7Y2RPQpSCKMDpMqrnLqf1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPSmySgBsVbiUJYZ549NhGEgrdzyfJTwKr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPSuNb29ft1SeUPARSR3jrhutsG93mVcvL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPTHcfxWxq11KAJoHUPPDomSeq1vcMdJqo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPTTJTxvyawnntchQnkoMiLuYRHUjeqUih", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPUMyJ59kkrp75tDzDPxSyw1GWCrbC2cS2", "assetId": 0, "balance": "0.00000001" }, + { "address": "QPUbreeKyiTU71BLRSaJ8fbNQovNWTXvdt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPUnYMtEGy3i6UZw5L3X4gHH5YMGXBSso6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPUsNhpC88HKbrb4HgKmUM4DeuFAQiwoKC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPUwwCZ6Equ9m3VxQBEA62QYnvv6xVpWrn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPV6pAxUghP23w3KDEv3PDcD9EvAypdvbJ", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QPVKXqLtQ6YPXU1bvodmSFYmSQMZJcV476", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPVth1AArSPv3V4FYvusrNWTAcMaBKX7Kw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPWiPbbZt7wLSLXmVBzt4kUrb8ccmPbVGT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPWzseBZj9UDGTASKeub5QTGwhpvTvGrAf", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPX44TR5CM672bbfn4xDCFcf6CjLS595xR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPX733fkwwSr1Lyv2idwAyCiXKQ6fjzaZ3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPXnBdTJZGEqyhtz8vg2o2pycbT89RHoLf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPXuRPrEyb69WrL8Y11Z1j4WQ2me4dEcmE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPYBoSu8KdPoNGkpjZo7FNy6br2Etzx7q9", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPYfRd1uhnAgqkZNmjNCjgPhkguMnHWuc4", "assetId": 0, "balance": "0.00000001" }, + { "address": "QPYwmTj7vPrsFvyDxNu3XekQX24RFnTgrn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPZ3kznZTa4V14szPCVTodhnLfVNMecfZb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPZ4zo8Cc3rMH1YnP4QdrtBHSyw3Pz9cvY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPZJVL5PD7ZDEWa2TfN6nx8h55MraPk6SR", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPZZKETuREu8VMfu6ChmBSYgunLUyBKAZJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPZeRV9S9cEyg5LPLK7JJzs7DWpH5ixccw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPa4fXMMLrh13p7qZdVrYXwEpRggwihqbF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPa4qoD5yM4LaTST7JTH2VkEomFqxH5b97", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPaEBUREEYWzh5GzubxZwCCQtonhw29W4N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPaJv56E6D5kZT6zoU4CfUC6TKhZA81zdH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPaSos5RqVuFR6wpdnrLhCz2Q1eGgKQxLz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPaiUnM76cetvAKb1eefpEeauL8QTTAdLH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPbPZxSZJZMjDY898Tfdue5vrqjcbR7ZFu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPbVrDGbELt9AeGJRK1pEyy5tZ9mqH31bi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPc3HCqJVZ4s9azgf78fhCPEkyGRXoBL3F", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPcNNsPJAP58J4QTNNARQuRaqpqMy2q1Yu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPcTWoAhYWmwjmWbQAS8muisrQVaLJMbg7", "assetId": 0, "balance": "-0.00000002" }, + { "address": "QPd4xtzMCbgP3GRmnN2Xg7sfwBTadeAfgT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPdfWtbhScw9YQ99fa43kbeHTSaxYCajEx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPdgqiJaGewsQr3MUMjkAEwDUqnN8eAn38", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPeAtvsr6YYPVyZw6NmUPyPMuKjWUwRXjp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPeQFg8DfXKEFcRd2eM9ww9uxKSARfJxZH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPeVEqWfmfxPsRhY9tDhhE8LddA2J26cBM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPezfFbkX44MbtWuDL6biWU6U7EYJRwzQS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPf5DCe6V12DZJYXZsnRK7asTTpSVLfyut", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPf8wRa6Av46C7uRR8GWN6Jy9knBbvANUs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPfFfh8daafzPC5Tckf8M7LHU8WpbtJ2Yr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPfKdDUaJxUeMFiL8m8p8Ze8kSKfuJy17p", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPfP9syFgebRP5A1s2DK7kC1L6hWFoLjoB", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QPfQGXGod13Bup6vvxDWZmE46RAEzasdDM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPfXTNmZqQqXgRpZx9QXemMnHUFFTi1woY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPfcHyRchqJZcw8QHmv2YqvgedQMFgJtxs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPfqtu8EHwDBaQh1mtGLfXpWe82AeFVs9B", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPg3JMHgDq8hRbUD3oLcuwQc5GekeajWVc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPgPxgjDqN5Sdh8m2eQV5BZnzJZKKiRBXh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPgeZxCcwcc5ZXRu4xnwAmiYqz5Fi9o5C1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPgnP6ETZVEsuArCwzQ5hpaVnDTWG8xdwq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPgoBs8MvPBUR5Y4KuJuRbpV9oVbkUj2R6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPgpyXFX7b726AGfhHThXkK7Z9d19GUV4p", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPgrUAwnm8T5xXZyn1PkW9egJUeixRvFxi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPhHs5rCRQUVKx2XUuU8uDTM5Sb6Au78QR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPhKcZyyX4Y7BiroEfayr7vDEMGdYqDHjP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPhwipA1NanqJUJx9B1DVZnBMkDcpAnT5B", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPiBHhXQeTTY9VjdvKUATuZfpR95RK23uA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPicn8E8c4voJ8pDJNGKKvG5UUqfjyyijj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPiwA5Mg9wki122sVLYQ5HEBRgY13xeSWP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPiwS7yUFHBCgsUkeZEpt6QXQY1abGXxMG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPj9yJAkqdv5xjbXgUV2gdf8sEmqw72jLV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPjWvD7xkvvWwTLAr88F79vBrmypGSKm9Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPjv4s6CpRB4f37LisHQtwRLCLxbEcjDSm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPjwdWwmtde2VP6tBrZiqdSwEunGv72TLK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPkcijjTAWXHfjNjMHqg6Y4yfJWFkw3Kyb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPkmDdZNWpd2ytZy7eAYGRWCECPeDKhH5r", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPmNcXdK2EVmxZ2KSeGS5N6s8Koikwnutc", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPn3gfz165mJvE93V7VovdwUiVC3AwwZ7P", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPn6RuMT6PzAKuDZQg8Q2upjmd7y9RDz3o", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPnH3Kkw18s9hs4P2bgrMnuFZLAXXxoPXt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPnfdCQNDDP4LTpUQPEiydgmp734mXNvb5", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QPnj8wtKMHkf6Ded6e796QTm6HqnsC2Rj3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPnmENVKRqHdk6dFYXuvwEKwBhJ1q4B2Rc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPnqRGJkkKK84LKTW8y9zdse41rX6yUNv2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPnr4szuT4vy6biX66f4qq4KbAmqbcaXnP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPo3gt11DPHL43n7E3j9FdNJFzGuDhmPbP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPoJ8wnkXDDh5DpmisHuxEZVy15iB6x2ue", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPoPq3PWhDQbbHCEP17Yi2M2UZ3oxgwuix", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPoeKgqGutN3BSgUx5dPNDDyPqpRqsLwzC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPok4Eg6kHtAoFaT8D9iCifU3ann7VDLh7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPooMv9upeTojTpBuieHa4cAE5JXC1S6W2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPoyAHwvZqfnJUBaauDvwnd8mhRVkyWeSz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPpVc8ateWFuRU8qWmTuA9w9rCsgnYhj3v", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPpVhr4FEFfJokYhfiQpdNZXAZoTR4mqds", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPq7R3xnXq6yTFRC4FCBTMMAyw8fLTh19z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPqUh3WPVcD9xcnXceULU6LCNeH5XQaxro", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPqVTdXET8fa9e87g999qeiF1D3hFrjTSF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPqXoYhTPiDdSuwcAj9JvrnBuzTYDBJEmv", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QPqfuZpmyA6cK6WUFwcGeKH2Te1aegkHBM", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPqhXHVeiaqVPNFr8XfKCKFwox4Uh3koSw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPrD8xeQDwcDVLE1WnxFMWZH27WqwNj9pD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPrcmEfABZZ8DdoDRTg44G7xWq9zCVvrhy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPrh9z8gNmRe5SU2zmBHSbZzXawkHDiDwy", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPrpkLkkLpYPbwQWWW6m4eWmXqU5k8VMjM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPrzhkjPypo5b2QsfETBQgMzhxRBVFo7Dh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPsJN6vbnhUGKRvYqWRUN95d7FnUjGCiH5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPsKbscxXy3A8PrRULiAR2MpXUTttf1SxJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPsMKCwhDuC1ScFMhU9ayFXygnTQoofZty", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPsN72pYqihdFg9C6wSyXDgPmuTwYGEHdA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPsjHoKhugEADrtSQP5xjFgsaQPn9WmE3Y", "assetId": 0, "balance": "-0.00000056" }, + { "address": "QPsxFxTJU8bSqtASKv9QUv8xEfQYhivCHK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPtGXh1Hs7JeBdkGMwzncxgEH5vDYsHXWU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPtQJfd6R2T2JDKQctVmH8hu1rBtdZu8e6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPu8s6XYomnZdonsk7My5TbLQn4nUNhNbq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPuVHcfFc2mewxf2FLtTUYHyysr9SYLF2e", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPup797VsH4mPTbRJk9JfERAZagT8N2A9D", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPusqAVBVFGAAeE7RdospttA18AuyLP7sB", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPv72yKLhutG9hT9QnUFN1hwpKms2qP9Z1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPvDNKkTpmbk7odiiQYCjKkrRgmCSKVmqW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPvV2ohgTTPbzvM112ri7Rj4xvXJRFAfYU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPvYQJiL18Z3EtYUq6idkGg4fV11DivHQR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPvz5HPgFbk9WfJM4mckJPLnurvEspWepu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPw8rHjtgx5ojzQQYeXWE5Pz3WqNdxiRCc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPwESkkM7hCQ8gSa3cgY3sXB27cQMMrkU9", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QPwUAfEKcYer6h8KoPQEq2spddLPX1WVWf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPwhabAHQckhQ68sfQ7oXWKsCmX2V45HWN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPwkoLe44shMrHSnA9dozzxvhdyypG8Gge", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPwqA9Ex8qkbXGQSUxmRw2weUW8CxKrpkW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPx5ZtHht9yiWarGgf4DRzcyChNvMuSWG8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPxBnUzmVC8c9ajLSMQDHQiBTxiMFm9YWQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPxU7FfMdTAvKn5irpJBhdQXhyHnL5AQAd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPxz7QiHRmnxHTqCozLhu7fbNPxHgjFASJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPy75L9DFLCusT5xqBSdd9cZqxA7fLf3UZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPyEgXbyssStHvWasgZXHCnjUoSTeQp5Zi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QPyZ3yfF4BZ14oWY9vEipkcuik2WVyF9Hz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPyoUW8MH5281Y6PyVZ1dwisZzPyNa4Mbg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPyx2bNiAnJEjitfeAh8jZXzQVKio2B7Mi", "assetId": 0, "balance": "0.00000988" }, + { "address": "QPzc5DC2REgrVhwVgsafYDH3kRfNfz3WDd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPzoabvLjzs36GVXdvsqGRX2XtFk1D6565", "assetId": 0, "balance": "0.00000015" }, + { "address": "QPzrQzrWfUGEhMF2gh8xiBZDRMfbTucc61", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ1DeG5iMcSocSLAmMVa7H9X8viv47SjyQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ2GWqjDAXE7GwRyEL724asEFQtLHzVdVz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ2UhAqH7H5pF6Tw7xYoGmeuxc66HpEhEC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ2cDr6DwMPEL2Uc22DXkKUKfsMwPgmVer", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ2rFNZqvTVQBtoFDFfU94qAe9j8Vcoeq1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ2rcmsnpoppjw5CMHRrDhNo9iBAhdNwY9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ33LZtDXvEPPxW7zCBs4LNFiRKj758Xq1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ3LFv3q6GcdkpaK2fqZLqP97EtKw2inuA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ4JGC7qpw3WRd4QLhwrt3ukzKFYra7kH7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ4MwTfdr9unngYY9rYChsWYonxZKDaf9h", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ4UL88aYt9DmVCensMpnwFrZPJYvvjx74", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ4WZs1KfXDEDk9pSPvChpfvhvWHy4kfbF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ4iMEtt2Xvp1bhL1JPedadQK85krLZ5vw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ4xpEtyouTbJnAUP5itCRNKLkpRRzhWoo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ55L9ZKvJUTXTbFZRCJ3pWPxaunnGDbrp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ5ZBwsdDVL269qaXLeFqZzB1QrFxJtiN3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ5qnof5pUgJem8NPsAPgYdENL88cNqSj9", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQ6FA4TgpqPc8kN4Sp9LVUZ7Wcix4kT3rc", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQ6gTSMmGsqyRBVhGk595P7NRwLZLjyQtM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ7J9wKDtg4dHgfYooWc1ynKucQZ7THaXP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ7zCFyKPaH2D4imZ6pxhcBjBWCrhLwiP5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ85yRiZa7znUrPEDHSKWu3akhRcYsjN1f", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ8CtfSUQi24oFdBuFD1U5oiHs15aRd8BK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ8Z1PDDpVJvGPnt8iVAWayy66vj6hqmba", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ9SMjSJX9Qqe1HTxdksowMnwj5SYJxLcP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQ9fTRst5ef2HFAYyVLr8hyVn4Vk6rXTZW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ9hqf3DZH111qPEaf7Cb1sVQH6139dr6R", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ9nGWSApLvmawCJPd4N51CFnM5uEbiZvb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ9wfoiCBJT6GEt5qnUgze2G8MPmH7t6V9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQ9yTAbHpSicVGVEguhFNodsnwnDHCMoKi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQA9eYUWGsGqsq4NBnY7LaoKSkhLMzGbgR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQAMhbHun5GwJNUCpawMRdac63q5RsxCNf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQASmz4WKxQeFutVt7gwQc5VfckR116AUT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQAaAphqfKAAYi6mPK2fkMMmmtqGeyNmSV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQAf68w6HejF9aA5pJX8gbKqEM1fHVmqxR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQAuaqYCU2XfTuCkNn4KPbNA7txNN2om62", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQBTovvU1WB4sPhKa4GZonz9ChfXAdHdj8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQBc63iwkRabq4SaDL29xJHaTv3WurBBat", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQBk6q4pYGxPn2GkBnbMXfHvR29vZdyB4q", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQCbXqrhPMVkghukvijXHawqpTnfXk8tAG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQD2mezybPere6CMP4tpcRmTj8GoGFwaEv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQDGJfPeVrm2pbyJSvdLAwHqtrdHSuwJ2u", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQDyqd2aDg3nYFgF2ZJYGnXYHzKu39gL9v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQE7uvvdTvy4vfbzWZSCEmQsRo1eJwjVE1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQE9aKPzgv25t1vq6v93g5BXPwpoT3rGU2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQECjLLJWzSKjvSatnLBfXcfmKby9bNSGc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQEDF8eAn5AACa3rpEv5h8YEranRwH1d9d", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQEWYGZBbmdLL4HrQrAtnyCdzsm67GxAhr", "assetId": 0, "balance": "0.00000002" }, + { "address": "QQEZEGWt3sAPwEWYD2RQ6tMwnpkayG81dY", "assetId": 0, "balance": "0.00000002" }, + { "address": "QQEyfsMpGjzqKnE8ikb65EqEsQJziFhrEJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQF5e1GUgBVuenVEs6pKV6wTSSACQ9CYzc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQFNC7BEzhBRrLDAiQnXVvSsAW82oWiumd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQFUqQrGbbnCSykGRNpQAj2kGs7PARFCkG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQFabMW4DtU23uUhZRe47Q4F4h2uTHvgcq", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QQFtoTNCYhQkppYUxzy85W3Ux4ozCzaV5s", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQG2NgBMc8eicveZbBr91RzKKPNDKcd8cG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQGQvYRK8XQ6jriHMzbpvvQHg1vDHEGfxM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQGbF7sHv5PH9iGA4biWt6Vkeu3Lkdnh4U", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQGtPS1efdLX2XvkPMPymvnS5Zbp8hinaS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQHWFSJpzuDupPfcTvGMRNJp2UGz98Kb7j", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQHeNTmVGFJTKW9ikNhECTokZtL4fVeDv5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQHkRv83Qh3JfhvcjsrKALmxBK1b8vFLvz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQHnHymxSHcUC6AuXMDMXa4wdsAUNwAkkL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQJ1yowGLSexQFnok7GWnj5yrhV6s7mc6d", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQJEGJjUKVj1Ku1zvGUEqLXQLdo4zKNM8p", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQL77nYBJSjRojYnffrqgJSeG77yhV1uBA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQLAsyKWf1Nf81T95XjHTD644PJLCHL8te", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQLEwoCWGwSypKnSx3e1sEmF2ut5EfPwmJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQLMw1YwDpGpQrmYAezuCqemLShGPvao17", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQLeRsZ7C21Up2armMLDX9v4h7gGX4LFjB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQMDrwNYCsM5TKeeFhfPGWSGsCXqeDnoSo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQMjUFGXMf1YRJHWw3pUk5EFfZ9KBqxcan", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQNCDXhdDQ7oYuYQ4KshNxQ67zA2MNuV4Q", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQNhZcSKhHsrgGRJgGbxgNJ7HjBAVBQUf3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQNiuJaxXhm1b3KWfkdwfESGHnJ9vyneDf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQNvUb3rPrGP7eRBgkRHtyrmaEGzsvKbFu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQPHELzcpqwq2QYAJqjcD23neCwY8MZFyG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQPNMBWxBMyaCVh2SwtT5DfQSXVs8mZtgm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQPQaVWsoEGJJWZbmbXqrR28eNtf9cs6hB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQPYQTvyYQQEbDHTvzw6y7iTn6GjHvSspw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQPYyoE3Bm2vh8Wr5aaBNyirC8dd3BhBGH", "assetId": 0, "balance": "0.00000002" }, + { "address": "QQPsrqJRTVjT5Px3y8LhyCViMJz12cH32n", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQQ5ENLpyYuuyvTKbvy6hgDJ4KvNJathUJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQQNSAQHn6vCfzDScbzkBbLD4xAxtJvJ7Q", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQQQ1PVHM2vrWcmtHddPq7SwKTCK7S7VbV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQQZ1XuNt3TWo7QGpgZpki5yyXM9jK5HjY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQQh65ndUZiSPpHB5QobQutJqVywU2DP7v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQQwR997hM54CnW4riWXz6rzgsYf59SSiK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQQwp47PxPq1D66xVhefBuYddibWoGqKZy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQRJpxhiYvuzVz57HNRsaHZhKvmzBMKTMN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQRWcX5SPLV4Uukxnqwi5P7Br5n71s1AY5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQRczYjFTMDq1jAuKEznj9a4cGi8ggczxw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQRfWR8RfisYWkLfm7bF3aQeNMSH1xTBZk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQRwKAbAtVFVbydiwAFmoUivVMPxrND78o", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQRx8WoX9M1eKmWtRtCdGXCKKAtGnN1ALL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQSC5uWtQcqwcdCRXTitsGVg7gsH7NqSfy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQSFur8jQWR6HYBsqgD4bVtGVNwR3eyis9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQSUQTdM7mEwWYKrCwwi9KS3MvURLNRUKs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQSt7MQ2MGa7r8datRMi7UCe38YTrER1zX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQSvaT1HuaCxzVfKZgTvnU1e7vzx1YffuE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQSz7oakCPi6w3ePGZkWje9PQdfw8jZ1eG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQT3BXnGfqfCrCKpfkCJRLBg4Y6oppkUkm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQT79atgpqfKerLvbys3qBsvXwrGMcm45Q", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQT84LNReX4LXib4C2expdv5GE6hi4cs4f", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQTAzjGAqGUDTiJN9KRwo2RBNUXe79Kg5h", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQTBwAYFbe7JmdEuw42o4QByJuvDHBS9Wt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQTMJ27NTowHf2jxehsK87WDCRP65XdmzY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQTcGai8FzdPVVGpMjzNJiCGAjtXXtBDfx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQTg2nGtBx7HHSs1nTD3Tbs1qBfswQaZCP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQTj1WCXacAiz12AZyjDUyXKHfckvea62B", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQTtJpoMHUuxMWnpj7ZxkMmv5K36XCDZwm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQUPFkb2bRLKTPv1jyzhAxsCKBBDC8tqzb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQUkLQD2PK37pcLtCLWTZxH3srwAEJiCi9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQUtW5dZPtYCRuv4LzTiDw3bfevfp2ewMZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQVQp51BZYPiA8ZfNsxUwJg4RTWSV1ZsU7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQVetppJmDktnBvg4CqgSsiTRiQ6tEU5A1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQVrVBYpxzede9PgEmLLEEABLwHECMpEgn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQWQxYCGio19NeNGk2X5bxzEkaYps4DjZB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQWYCqPGSEGyWHQeDt8yPA1jfDgHZQRw1j", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQWdK2aF7DVbneE6TP9Y6ZryS1hXqfdE9s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQXEcXtZS8TnKCSpCsNXTgeTGn8CxLCQoB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQXEirVtkqPQzxKWjbr1Uhfv2CozWuK9mZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQXVraUmj4WMycipZcok2JdfUZx6UBkWgC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQXgH4CnQCB76BbXhsApu6ShhohFfvoXv7", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQYUYBSuWbdBTLEgiwoXUTfZvY6HWYp34Y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQYX4GD3HShteEUXkJ9qJ7rc2yZpMTuJJW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQYhhpiyaAnAvZFQoynxbJLfd8fG2q4fWs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQYqPQ4rr1fDht4ggrsMBPJ5aC7z4xi6Ya", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQZcyVXMNUJJEqHTssXkpTFxTKtUfxaSFF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQZqKB1BUM6uhPP9HML52kPvZmpVuANs5a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQa3MTgdnru5B7wSqPcq7qXcZcpbDQ7oyE", "assetId": 0, "balance": "0.00000001" }, + { "address": "QQa6TNdiUmszZtPJn2y1qgaPLQQ2iDfYTc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQa7BgRGLDvhpV3tcYFiu3AvZpYysfLjM6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQaKBSjAt9RK2bqJoSriR77X4ULstGzrFQ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQaKFmQ2TywunQaAiLN7hYmKBNraMEJ1T1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQaPVH4xtbVAQeNY1Cx6rCGDeLQJLdCgWr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQaScAYnUaiztXnS91haAJRnY3hMGCLxJe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQb5F11VD5LTZumspUHM8keD719X2LkB1L", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQbzLNiPHMqtrjGYuHXNgED4F6Pc89t7am", "assetId": 0, "balance": "0.00000336" }, + { "address": "QQcD6gGRfrQ9tQ1DnR56ZvUby8k8K1ei4e", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQcLcRcunrPyx7hgjWRrJPuWcuF5dADnHw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQcLmGSXBvEzyjh9mHTvgiW5WfWSEZpKx2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQcQbKatKFouSXkKAiirTcneHf1wHxDATX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQcYd3fkiqDweHkRpscxrBKfem4JGoEiDR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQckMNvNyBVGtzZsUN8ahx6U88LB5p5pMs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQdF2cmPf8ZDk9hrPvdTtje3DEm1iz9WF4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQdFcpS342DvTe1ynqh8QFV8YGRN1xDLjW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQdGwpKUDMskqZWXHTKknTpGiJhwvyDqe9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQdPHesdQVzXcA7ttSZU4DSDyAAqFfsV29", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQddjbGe5L8nq1xDot3HC88EvYiSoj3GC9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQdwWjA419GfcqpWAJGfrXp7fCLBEpCPBg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQe56SJkw7LGgVNCKaJ6pNdwoXWjxbFdxK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQe5CLQyuBXsyyfTAjNY68NLRDf6USNbBM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQe7PBe4dnrrng1ZvntDSGLWs5QrtT3HSj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQeMyafCL2FAAsNZYC36Je5zZLj27KtsGu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQeePzxXYfZ7RfchteKvPVJEirVufRQ7zo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQetZ1rqhXyFiri6HoHJPNCNB3gJLBP6iP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQetjbRk5BVRWrpd2QwEiCqfrcWKsT3YAj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQezonTC4dWFrKNAohiTDHqzbvgdRwoEiP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQfiM5K6L1zQZPkcDH59GRtPh57qLuaJVA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQfntRnjaLNvQEfpA6DSJkpxLatooecvgP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQfnut6VAHfvYMMfaJVCb4ACWA4PFLdk2J", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQfwxmBGXXU6U88DeYqpp9k3j99g5deGD4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQg5QvWUfru2MzwWt7yKSuMZojagYFn8P3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQgPvXvGaA3TFb4jhbbkoPRhD3f2maXeyp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQgi5Z9fcugk35tozuEAZN6BCkjNtaxCTt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQgpqeh5ZwbnAfMDsagb9AFXXPbXdUSsVD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQgt6VXTZoqrj1xwN1dwbvstsLBMYH61kx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQhPA1Hx3iWvToCt7o5YirtvuAM8hRnNjn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQhggSyZNjSWudw1tLLifFY4nQekhqJwDB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQhzzKw79cxMU7shLn9eZaNMXsDPxidjz8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQi5CyuNMKhBwhCFKTCkPY9F1WsXoFRFEU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQi9vjEiRyiuGAs5YZEn5ficLZt2fCXsWi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQiBjuMHby7gVnMQL1tGh1NsC66M2oQVpV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQidqu36bjbzkaEZKpzjNpRjFwZwFLC9T2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQiwPzdngo2auGfkLapPjjn7Z9BmoCnac4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQj5xtvD7VEkuFpUhtLMtFpHz931RU3yrK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQj7ESZ5fwumUey8vpBqBpMT7mLM8Geu3c", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQjPBueDPkq7UakTPy6gRJUXcWR4T9SrPH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQjiCpwLkxEdvYa5EQvrKoxAL6dA6uJCq7", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQjuZ2pY1Z8Evh42qH8boNtY3QmNDiz7fE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQk4WkXMzKkCZ9uh2QVh4CAmnTGvkwa2SB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQkNPXjWpwnywKJiupESbrUEfrVukM3acR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQkNqVkFZ9b8isbKo9sgKTn12KdoPVR7Tv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQkqUaJBZYjBhG1yjVxWaHadookzeom834", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQmA2e3xsrNQB8Arh4ZAYRMTccGuqGKjD3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQmHGNXcbCUzTpoJqNJrMYdmY3cj4MasSf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQmW5BjrMBrm5KPrmuocLSHGqWq5avSqg2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQmekrBhzkVges6DCquei7R91CCJ8kWS4H", "assetId": 0, "balance": "-0.00000072" }, + { "address": "QQn9mFwv3YsJKcdieE1BUeS27rw5GqRhL6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQnCq1AuaF3exeh8b6dXTUjm6JTzKNw7Ew", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQnmb5UaFHg3p9LaLoicXyHr5wckQ97n9G", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQo2UKNh9GAkbshkknm7C2wp5xdqZjJV3v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQoENGwx2bpj24aF9cuGUFd7GVWPH8Led3", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQogwagc3K9zhFeeXe1oUmYNizgB6VbxhH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQoncCFa51QQhGqGTgDvfyfuuo7pKrKsgb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQpVKHbeYphqhmVPURdV8yf8yLbtDFJLX3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQpZWU4PrL6cvuxsr9iBQfjwwaDLsTjRXJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQq1tgyEznoUaGKwtuh7MNnw56iMiTfJuc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQqRLefTMuecaKzvFhBjTEKUYWzAjBpXUy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQqWywByoToLoCy87tueYbkRNcta3kdXK4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQqxKQZynhuwVVkkgFkoCa4ja48gbXhWZ4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQr2RkCGQXUuY9UqhFj3BSYULQAJabCQsw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQrJsfLMyoz1BUWSQjhJXPwuKQ7q5pQMA9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQryEMUR4Fw7humUC7qMaqora6CFSTy5rF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQsJ9vUPFU9fBVxA3gna9UHUmFX48E2Tme", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQsJjj5QQ2p3fuMfiiSQsrKJ5ra7ePKf9s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQsVYecTNmRrkdiZgzQ7Xr2HdWXYM4u8F2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQsfz8ictDJRvpPobdyYYJMAo1DbNRMkPB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQsuxxaqvQSGh7cAAmja8D3ECm2pAhiN6S", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQsx6UJjeJzeLER6HfhRgV959anySj59GT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQtc1a92NvRVYBttuRa85XyrfHAksSDXLE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQthDj76BNvjUy5SSTaac5w9FwnKyTa14i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQtvKA8P1VcrkbLVyBoNYPYbZThs4MWnuS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQuKerbDfGkNLz1hMmTzfnrLvGR4FRfJMY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQuhcRELLCkgcc8UTGXKLQfMGY5RWMKwf3", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQuvwkf32ezYDp7N59VaPRtrwULP9d1UyC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQvG81k56L1htX34ATaj2XDitq33t7gQ2Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQvGuyg5oUCLSCbniGkRqYK5AGHukYsGDg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQvHNF3shTDgQryY8iUEWixQejk7sWTvgs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQvLYVAmA7NuTWueWvTBhYHSLZaQGMRzsV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQvYJtmd33eJ2W9WUMP3GbF59iRXSPK7Hi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQvxFDiGPS9p6qLt6ymLYYf3dfQdPmRQAK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQw3cDkQoVf6wfwpWd5Ziuud6jVWuCt9U6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQw6Nd129xPZG3o5sjEWo4URFxc2ZxX41o", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQwC24t9fRTgWtbafVLR2F32nd6Fy6ZbNc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQwr67FpS2LUEsxh3QuyaUfSBVFXJCuLc9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQwtjh5P75SsDYh6fvrxB35361ajb4sogF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQwuGmGzeYYbGjVRqtMJy8KssJBYkbiYv2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQwuT6uFv6FnxS8jhYZ8WEsC8TUgn4DaYw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQxNvcrEC6k5zE7jdThRinVe6B7FoNJjsb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQxTBETRZFPoxbMaJKHmMsbNGXTQgD7rVo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQxTjdxJXNqVWJeVpyZBewv874dsBxw1k8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQyk9TANVCacvpDXamwmsUULCKRZwHUrxj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQyxhTXaA9JQ7gswbNRmy5zPncwYeDVES8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQz3tpdjmXSo2atdkmSQ1AqAccoEii5xrp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QQzMut6erjgSKCpZ1dHDcjKcj9KAce7cug", "assetId": 0, "balance": "0.00000988" }, + { "address": "QQzS3RfpJnbyBSSWwHL3ZmRCNkCGstZyXy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQzrHWaZMYL2qCCP1wpjx5Z926yCs4D5c4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QQzxmfDBwNWTrLxTozSeegVUdCytfssGSJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR1AKeH32KzDk2iTzcEGp8vz7Gi6EPV3NL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR1DZ1c5ie1jx1bKipP4Y8ap4yvUab4k3K", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR1Mrr6o8VEgXCJp7ZWfUbKSRHFxLNDK9G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR1NS4Vbfr6cafQCW9p4iB2DBJpRY4VEkm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR1S37VrpnAak43pzmkChkeCa4tr2b8nxo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR1Xr2dWDkjwCoZhyZNYboBSkVShgNzzzD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR29FGj8ApaeePixGupjLF2B2wpUiUCGTJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR2JogB41KTd2BdEHTo76bCmhYJx28vWNC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR2gnfp4rYXXUnHMSCdZTJQz61yCbmVJr7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR3Hv7ee4aThxm3v8MAikEnoQVtVPE8V8s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR3XDSGWPHHJWKrZt7HqQ5hbkyEeh7vW8g", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR3bKLUCFSdtmQABda8tCLco3C62GL1UFa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR4EMKeDbBSZy2JfV2ZkphbJaRRJwkdcyG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR4Kbu5FoF9GmxGFCwj9raEXyw8Ujp97Zj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR57S4GXyAF9xeHym15krRH4mW5n6qxyhy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR5Xpw5tUKinkK8RkcbGMcktXJ5yMAjqrL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR5iCbq4wYs1WGfryvGeLVnMeZDvbfRMY4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR5usa9XnePWatgez7wSg6fMbqBwVwDTZS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR5xsQro2R42oU1bcXoZoqxqBsaKvoZkPG", "assetId": 0, "balance": "0.00000988" }, + { "address": "QR6a77dytMpNHJi8hviXmJpExDXpmyQgcZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR6aRCUKn3urD5Vc1acF3KDQus933qXag8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR6yuvhHeTevtM2LQT7641BJ5RHirrNVjW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR79yEQf7QkWXuz3Fjkum9ZAshV7KF753d", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR7QME3vAJe4FceyqKkjjgYJwLPAN2p7ip", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR7XxC2B9743vjr6oMn7WftwHUbYgThY97", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR7XzWC3urCsZweDUivAw1msPdk4yFnkt6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QR7aXZpMMbVsH6sZHznJfsV2XjQqzAG3Jx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR8An7fX5CStHGbKqfoadfNRrvQobn1VzG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR8BkHAR2vHJKTF7itVktXnnxLgtzfHqFX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR8U6v7oiUtmB6HbqH8cHk7ckxBuryt5kL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR8aBYZHtnqBWiyJmP34KFxh4p9pgi7Fv6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR8cXXJ3jJ9M1qssbRuJnw8S94bHzh7FPx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR9UR5QUE7yAwPyios25WQdworma6k8iLf", "assetId": 0, "balance": "-0.00001850" }, + { "address": "QR9UWFfiAzcwQVnimC5iyfc5q9xMwiN8UB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QR9g2ivu7rX2mZDDMLosi38GTQYdRwUCsu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRA38DAzpQb3YpN4UeFeAtGBX1cDd5fArJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRAFfscNHD3BN9kgk2wR1Nb2oQdLgGTNGx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRAavW81sH4SC7sB2zfcHZxiYmiM5SnBXx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRAuqF1THGbefemEDSjgHqeZhmpEe2cQJB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRAvn8JtSCdb9fGjjyYu92E2crAq7NPPdu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRAxW5kzigxvaJjKvVvrZFwyouHrGM5gaq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRAyU6mhfJwZLUT3HDVpMoKTAqe3jLh8Cc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRBD8jZoUetndMDzHixM4wAzrV3qWNRypp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRBDoapSsxPiysimg5Q9DJWkyWzUjvWmHk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRBEKNCXjVPkPUKREbD4bNqDCH6GGSAza2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRBkkhpuXmDVvGMpscFvRpPz9F7SnwRh4V", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRBqNyXVwzjqumFgEffR9Vh4MfMDM9bq6P", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRBtyKbztiJ4ZmvcurU2FE2BDetK4bE5KC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRC66qndCJNjLryUNigjcQPrVvBnUS1Bis", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRCAqWNeSXy4hCUv8MM6o8fzkwVr3JZhzb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRCJVpk16Be2jEkP6aSq2vhfgPAgvsokzk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRCgVH7dmEgvSvGAJajhSjnJw2keBaTWCf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRCgyBpsAB4zVv9oDDjAf8QXxHbgWt38if", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRCkZ5zUcgo7mMthsYznkbjxRgeqyKDKtD", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QRCowt2NMDF1UXRgaJBHcVd7nVddoxn5Zn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRCsR76WGsrYFn3QXujUaKedTKQ2d3tXHJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRCtc67FTNKS5zVXM8omw8F55h9DP7herL", "assetId": 0, "balance": "0.00000002" }, + { "address": "QRDBYQaz1rqiGa3dSaWK9sLZUisEP9EfsW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRDBpvJ8tA6i37pyhmJr1FLWRNyawk8VfH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRDTAzu9d2RDTipUFoTPfeZuYHK7jcGZos", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRDW6G8RS3cKTMyRXcEjkNfDoYqvyRuJxo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRDeHHCcAxW8CMChXCwP8qgMV2ydvepPit", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRDqaC2Q9GHhiSK5x2HbvbptfBmgXJdrpF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRDqqPuQaAdJTzU5jWuNrHYQJQvhKcq9hh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRE3fWnTFgZZ91eLd8kjozEDYmRJ48f9Qh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRE6NSN44YqL2zsmW7a7BVvUgGdYgjt2pM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QREG4MmbVsh5EzkudgU3oWooTUZo5Natmu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QREtYDhP4HkpeCCZroemuGXMGVFoZHH3Lp", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRF7DEFpzCn4p9RvqxVJH4gDSVPmxtTLrc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRFGgYzEBm5WgYgYHgNwzK27xuuYNTsapg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRFHr4jnVgvAsPTubeSrh8bPy1yzwzYaWD", "assetId": 0, "balance": "0.00000002" }, + { "address": "QRFW2ijpmYYPwihyxrX84gfsjLunVhjQTp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRFWNvfYrnVGHHWuQGAs5VBwJHzPcCUdMU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRFY1T6vB74aSMdE6aukadhPXL2WLUYcqx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRFbQA8j5LAXsgjYae26egKzP2PZGcbbWS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRFfExtrzdDt7Bu4nxv1rYRLeyMkghDU1j", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRGcC1JGfHNYj3kfJtPXxfFnDjzqPG6VQB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRGtvKDyhFGN7tEhJPxNJsmuWbL6AYcwdA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRGymYXGFNpdEBW6Vc3Noms1N5pjrcxFRw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRHCneJApSW2qe2uuo1QkFq5Xb4qx5YfpK", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QRHj3FnBzzDM314JVi4HNcvAHit5EXamLo", "assetId": 0, "balance": "0.00000669" }, + { "address": "QRHo54EeeV1wsBR1jytYpmMKqo7Wb1K5dr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRJHTyMDENmTLvW7WGKjARgXpcNpqVxtHM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRJRPTC431ortbmXizawh3JM64Vd1qGWhu", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRJSQuCY5pixqi4yTTHoJKEfkzdVZcxhsn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRJjcZGf95XkkX5X9VpQb9nCwyiXhfFasW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRJkq3Vtcjj4Rv9i6EXtoHmL3AXcSnJGMr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRKCN3YiDam1dNWiSiMqfhb5stoi6nAHKQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRKRk5HVADsN1LHygK7q2pA7dWnYKnPpCT", "assetId": 0, "balance": "0.00000002" }, + { "address": "QRLtgRZ99iSKeTdQ7kqpvNofXjkHdGrj1w", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRM69mz5u17hULBygfD8ceapVRBfLKCPpr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRMX3HkbS7xAJE3GspqXcYciq21A3Trpp7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRMYnBhWD1ncLWiMrMeRiMpcTnc4dv3sTb", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QRMkU6jVFxYYMYDZo2Hf8LQhPcsim1D4w1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRMrcLwTywoEVhuUbuBfnioUTkYkiqHwm1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRMtFbTThi9jNUFeGPywaEvP2GWmghQseU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRNDNCB4VRHypVYy5f6H767FsKjw1fX3uV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRNJCJSEn6Xy6FJ35pVVU9Q6KwbD5A9jwe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRNdDZrNzwsfFfF3gigdgYo6rZuZAWDGaa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRNg7kegH87vaqwc8TTTjEDs99Aesike4e", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRP5BTeMLUqWbkES3gRqFHsWh4ey8Bot2v", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRPAkjDoXRikWseRFq8mZJoMjJAqP7Nns6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRPsKgPAutc1dSuE88BiTEtkk8Cxc8FsBg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRQ7fzc4Qd2qdApfkZLWFuFYA7Jh5BjDDM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRQAHbrtuEcpiAaSa4e1JPSeoEqS4JByPi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRQMvp2nM2twxhjRxmhdjVsMTz83iyig5L", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRQWVSnZLvAebhMNvifLHrrbesrLhF9icU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRQWa9XHGHDaVfFRKEomyquDY8J7wDHTaY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRQa5tidBjxWd29s8Rqvmcv7Lm1irtPnio", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QRQizMT1mMQYtFvjgpoDHuh4z1LD2rcWL2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRRidGjuWrkdRVPArQvBnZNX2ny7D13uLi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRRn78DR9CnYTsCdrMHmf9fyTFfFwgrGRd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRRxEPpehJ2VK9MpTnUMWKzPAqXMBivp5Q", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRSQfR88mp8975bxNg7w8yUxmv4T1nVF1B", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRSTPApraxcThU4PrueofXEc8qaXck3Pwm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRSfHYBEXEJKLKjzLpsXfkwSFsBKGuqPcF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRSidsksoPu69cjK9ktwZqFXAkVncMKtZm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRT53wEoSMwdWtg3Jb2aRQJYxzJUrVH2dP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRTuyewWmZrtfEtNhbhWF7CdNePBdY4cZt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRTwFxhdDNCR1eUJiUrSwaHRDn3r35HNTj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRUbzEbLd7fRjAx2fBdXAH4QS1WQyetvDc", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRUckjkPN6nQbNynijwVBmjv89BuMxViyE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRVSZ4X2eMZt3sxKL9mGMBxGGeiU5Ms1P9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRViAhwyGycgNbRZ4ywQHEWtVDcN6L1e5q", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QRVv9ZNHWyuLV3FCxaipnLw55mHJh8Q9HT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRW3F8r11nPSH7YLch89hS9G2KbdfPwAUe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRWEbcRnLoGccAndtLcGgpeQFH2ZBcMqHo", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRWEbzH4niUcu9dL3Yq42X4j89aqQk3qWw", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRWN6GUAe7mwrtYMEYCBJHy2Bgj5XqdSKB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRWYBfuKJQDwA8o9DiybfLXNEUo8gTNKJZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRWbRNewJwpb5g9aXnB6irD76BG5C8EHGP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRWogFrCPpdHc6UhmSEY7rY1YEXyf5WzhQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRWr5QX56weRY72k88htKuvRjdvkWDKFxJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRXR44TjBdhQePZc3LSRRF1YPcBknuRrC8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRXTbFMkyWUSFbsjzCrPDJJxzRqWen2bqb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRXwuV72tHaakrBAj5Xrg2waWsgQDBaPsj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRZFxrLubKyLQQcMufee8dtBgQbCj9c4y4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRZWZQP7Tmi4orAWcHWhXpfmjtK4TdCuSu", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QRa1KqXbF35BonrGYwGFEQ6gbz9tqviXZ9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRa24vGJiMm9fxPTMPFQdfbaScLnNizDvy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRaDef6H2zYfefqLwYGmUg7T6DAqo6DDqc", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRaQ4JPcfr9CALDF9ehiUuYqzm1cntBCEi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRadkPrVXnEBCHW2JHsLPHvtpu5wKBxkXQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRaij1RvB7Vst2WhvXG78YfQsj4TF7FsBg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRavaM8Kg8rCkFMExkdNcHzmxb5Wk41MGU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRbmygS4H7EUy24g1FxBaNgjdtpZQW1J6T", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRbudc45wSqKRs2oWd5Ph5xGQnwo1JnBk5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRcDhL8wH3UyGaaUAu2d111Wn9Kmgko3ts", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRcNza9G2ZivAbB8xWhJ6MUsxC93NPCd96", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRctujZqsh51nbvfxmJzXcCoJDA5hRxdmp", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRcvSzSNezuGkLqAoeAHqVvQDGUP6CTKeq", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRdDVmDq4BaJiKtVw85GExHWdt6Ffu8XkX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRdZWWjRDh8YoEiTSfS4mSW6FJnqniBsNX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRdjuqe8yNpcv5JFjnU5Fy49W1tJDB56pv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRdpnbcnfYA2AKyP6Yj1cQhPgsAbmmdrKS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRdwXQYXDnX6YZeypmT1ap2gTbRdTKh1Ew", "assetId": 0, "balance": "0.00000015" }, + { "address": "QReDYaYC4ccoyzku2UV8TZo5JopLEzqFgH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QReJPsmnX45QtmXXGG3v4uXmL1kJVR56hc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QReKNAmKNTF9w7KpFxvs3u7roPzyjhZrC9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QReYdwiJkoiiLLTxe1XyjtUVMbDNBn7aCr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QReedLp6wdsiV1c7TgnffkXNxp98UmvWEs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRejrGEzXjoonsn5UVCbfxDCjHAGkEWGrx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QReumtdrs6WNdRau4NvXHcLTFSjQrciLyY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRf5uHZicK1BxcuKoQg7sdWpJzHWx2B1JS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRf9aVimw63kBDiqwbMQtg9yRGLzXuhBAw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRfKotWNBwoa1kCnXCZgYJpHXqk3jrAhZU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRfnJ9i9VS3fZQqiLsyqproKdBCZ757tbu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRfsvobSaiX7Ts8Nwo2dxS116Xgj2vY7MJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRgYaWQzTDP7BBz8p6276JxBpZDJr9R1rc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRgaWzebruQ4ajWVDYVN1zXqeWdJjuyWVZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRgnf6D5VJzEsAJZ4HiGRHLt4tGdXzSkKn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRgrKhR4SVKNib94YNiMyn3C2cXtfq8oDs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRhTSYX3qMwJBQi17jYHcFfDbiqyCSEVpr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRhsKViYXJK69V2bf7XGdPAKdR93e9YJ6t", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRiAaFKLPgScKPUjGHEA5uTa1gjt8ZRXSv", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QRiERWXeZQieqBof9BGHcqs1X9LzPNXh4M", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRifYmZcnVaFeErNYrjr78Hz9bFfg9x1yV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRj35fdfmxMLKA5q6TXcsuBDAA2YtBX1qF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRj3ExgDwfAx1azKy6eAz1BF4b2kzye7QG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRjLXB4i9se3ff4uwExPrzz5Mh77D1ztoQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRjmzV4JbNHFnzwGBcZdGELGFXfre6HVw7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRk5TG57SQGLkybXUqxBnobADTFGj9GR3Z", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRk8eWfqhoyTBaJyVK1JtXkCrxF5XB8yXy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRkTgFGvB9XdnEB6KLDiibCztXAmRxiUbf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRkZf1hNfEK48jd6emcsaww4VgSXTQ3PLY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRm3fYnNkqv7TW9ZotYA9CevpvqVt6xbs5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRm8b9TjkTrz2op5bTFUVmyrvfc7KJaYDd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRmLPoA9Hj2tprmcJfZB36qRuEWuF3GCCg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRmQV9XTEffmEpZRaERmyTPRKcW4Pu8sTY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRmQsENoxod5LusfsJZotUAcA8WqmT2Z87", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRmdkrmtJrjXyrGLGEPB981KLw5GddvXgw", "assetId": 0, "balance": "0.00000652" }, + { "address": "QRmjVNECiEZBzZaJDxveLquETib3ux6cuS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRmtaPXg3VSfp4UsdHH9xCV2X6RNwKQWpQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRn78KQG6bA73MVn5sjttMHfgcLYo3KCeK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRnKWJEPoSBPECz9qtGbFCy6oZpp7HEpZ5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRnYA4C18tKnoEivGqVshnEUqThwNkbEQS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRo9QaaABADAGpgX9573ji9nuTB3oMVTfA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRodvkVQY1mU8KDyTc19zpeD8hTUkNfHbi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRohcph9vgW2d5ud1onNaheHKmzkzs6MbU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRpBCaksF9GQyKEBYdmvSfiVCWkxsqcuFN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRpEtHAgkhptM5S2gG4VP5DGTZVKWhAwnQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRpXAhkSh2qriefyVDwCjB2nDfvjT4t6jj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRq15HuDe5JqZrfFrFniYtBUiLzguhCBQs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRqLe7CtCCZd8H4gdPnjdpwQRB3B9MKAgy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRqNGZnPWdPJK6bTAQojMc3j2j8zgmzyxz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRqQS3ZenQubUF6yHg3R75F7sRC1yjn9zz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRqe3phJfR8qBEjdX321kpQH7cQKPdEJEh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRqgxMUdpAL8VLbuzVjn1gAWC4Bu2nJG4K", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRqtdcyasCw3Pz9BPR5bWofvdUw6cyxqb3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRqvaMVBAPdgFVLKUwHbUakysd91EPbQ7F", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRqwabzkpYL7xhu3ovvg8PQub8iqXR5Nup", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRrK4NsHHTekiGNZz7BmwPVFRbcxowEnBx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRrRmPSQPkLeb2nFzyMCAsa5PwgenYvvH1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRrUhQAHJg2N4L3pqe9wUHZAqa5CpDytyf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRrZkCFEvHT4B2UeCx58TJUZo4zaWtSC3V", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRrupgeApmokgfLHQwnmT6USrtCbUdkJNG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRrvsUPv9Xv6EL9M3uEWiJiSMDV4uQc1zv", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QRrwcQE7jE2H1ft4oBvARhZb85smNq6Maz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRrxc8BkcmABfyaWTpC6RLq7cyWoL6acj2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRsY4rJakARhRFdzPV4aRTtSZVN1FvVbBo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRsz4sZg9pUwE5gRkW3Ud9isxt2kkhYaej", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRt11DVBnLaSDxr2KHvx92LdPrjhbhJtkj", "assetId": 0, "balance": "-0.00000054" }, + { "address": "QRtRELSSASzqiYy2FtNcrePH6TVnqJkv9B", "assetId": 0, "balance": "0.00000002" }, + { "address": "QRtXkwMVKVpQCvZAp6n2c6LqXmAXKFFv3z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRtwMe8xmGic45KkXJ2mADFmbLq4fnnY4g", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QRu1VXJb4xQ3da5LZXboj4heZvW3ZHMf41", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRu2rd4V1wnQ8yifhk18JgCTyvpoZMTg8j", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRuTJBSGVr3XVvzHz8efd89tg5HDmkuT7N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRujE432ZXenznPkPLvD5XsvddTQ9pkjsJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRuu5ARLYg343koz6S5ugNWkJY69ah19xq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRvKMzYoeTpK4bZ6sAKX6tdhYJyHW44Vmz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRvUYW5CGg2rzL94GyScJRtdQpMU8hobSq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRvYAA6gCEMixGNj4Tu87LupQCgJd6qa3A", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRvfoYmgx5poX1d8fWPZP1CDRrdfVhWDhY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRvrrq9EwKJnzPsuHrY8q53vbdQYNqyYk9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRwGmvddZaAShr9iLSakLXypUm3SLN5gR4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRwoakYu2q9ZVLjNWMD6vhFP9ar84xXVfn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRwxg5iBC41G6jQ8yEapTFkWix1FeV7wkK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRwxvvk5UBLjwYQTXxZG1Xa8yYssGKTUKj", "assetId": 0, "balance": "0.00000988" }, + { "address": "QRxPLikVQZmM3F8Qn2XCBXjDkG5dupvaQr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRxn7Qq3nLYepUQRHB2gpCoFA5gGhmR4u3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRxuEpYCpPmtQKs844zHGfhK9SFWJM2mz5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QRxyy7YLRathQpUwAydG4v9Q4ErmU3kVqx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRyCssXBqS6DUR6to9iSeCwBZSBBKhbYCs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRya8qQHvk9VCdxcKWxBjvkdgFKbdJCqzg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRyupRNM7SiXHTmgaV3djDvWpMQfHeVgLh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRz3zacmnkT7N3hyJ5kS4F2TTrkmZpzX2c", "assetId": 0, "balance": "0.00000015" }, + { "address": "QRzv59Vzk65oL2N4x76yBwdJ1GRbF3s33o", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS17kaisUDDc8E9r7rZFuotWBTBmLhXMHu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS19TQ94ZffjjCyMq1KjySN8fywWH5DmGH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS1PPWru4ExntzRCAKCx9Wu3Fo4yYHsvQ6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS1VrNREF1Fi595PFYpwwNi4JCD42a69YQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS1drEWwmnxYYBzQduy6QiqY84uJsVt32a", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS1gwhBWZMzBfSNLiS5nAZVm7ac9ByntWe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS1jzNymJcRXh4Ned54gY2t6vE3pdjGgus", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS2CYmTn1gaf8MEc3ttjL7vtcUDLGVi7Xx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS2grAxErPschupDmS9Uxgv7gS7uYRfZcF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS2kVjxCLUmkafue4M5U9Jfri98VrEPjYS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS2wQpMWBXaN8tV1hvWoJVSXtFKoJ4jBDJ", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QS32MjZTAM7idFvPXrEJ5WVh7RxyrtfZkn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS35WaN2XsBoqxC4UnM4FkCSw8GuCSrxDv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS35jcqFzNaRjMHiY1uaqDFyL8VSfUwoyQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS391WCvr3NwqzXXuv6QsPrVxUvRoYX18Q", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS3kGsoFhyPeeyCbQcGiMH3LvP2KYNaKxe", "assetId": 0, "balance": "0.00000988" }, + { "address": "QS3ogADR8MfmRwURgiZ1WR6rR4HnkpyqFv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS3yf621FqnEgMyUpZPdjxnLHsMuwYnq7G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS455fmC4Tp6TzkKprLwbRhS2E6xrpqe7E", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS49qPLS7w4xqBdcbYqwUwKjY2wN7AVmxu", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QS4AaBpTNPTc17MvtBx3no9nZEHxyXXe3B", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS4CFi7nWkdjeMJuKWj9VEuhqneneJkEu6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS4T6k4NpAq3Qn6zEeSz3VrxGJZqGJRyWg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS4uhsoG4BGCd3vUeYuHBX36ehmcHZFvq5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS55t3DwKnpHJg2yiEPmdVNxuGRZgN7jiE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS5RtMAia2KFKQVTHpvvdamV6DBM9ndxCQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS5asWWKsQpLYG6aoZ9XtQ9c1P8MVScPM1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS5hD6TtXpcFERJNk5uARBS8jijxEm6oph", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS5iqG71Ae9vLESdE3Vm8sHX4xcmitQEnr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS5nXLRNHeUpF3xz6W5oju59HPK6DKM86Z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS5ybP77eyVLsgcrCUHDsKqX93bJ9YDafA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS61C7KQZPGcQjLepCNj7nrferiRWfiJdz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS6L9ahvjQFCP9PgeBfYKZVH8i5CKTmbHb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS7K9EsSDeCdb7T8kzZaFNGLomNmmET2Dr", "assetId": 0, "balance": "0.00000988" }, + { "address": "QS7ZfZmo7Kgr2wcNMQ3UCV4SHLKUGWbPpf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS7yD1j7hG5u8RCxK6PGaLNVeosPLR86Lb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS9Q1obvdghUf2HjskSxtVvmvEowx6Pf2x", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS9WENhKop15JAt9zhzUYtfesnvDUCASET", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS9gMNVm76LM7y8trEg3qQer31rmaa9sKU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QS9uWKJ1sbyrNVWutHCwmuiPee78Fb3bao", "assetId": 0, "balance": "0.00000015" }, + { "address": "QS9zvdghZYd7C4snPUZvMdmDM1pEwtnvnt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSAfndAWjVE9t45UoZUUtbDG1YsVrL1LNW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSAwmrycLPW1TbugGh3yGt6L4USvnw8E1z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSB542YHVj1VCKyQtSKSyJ2h1VuFGWsPuQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSBVtqNoaM9pfi8zMgMY9pTQhaF6XjMUZU", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QSC2tiz35gfTPLgzSPJDzRLwbSVtRuvR1e", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSCHgko6w4WrQz39kE3p671WCNWyy6kWVL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSCLtiydGxomTEwALrKRkFodKEr1UdRyw9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSCWPvxyGS8xftRsAo7c11xctexaUEAR2M", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSD8fGpRPDPzVDEqiqZqupTwiAfe64edFi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSDzvvkt8chbTCRvJd5hUaZn6ypr8fA3md", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSE7sy84kVtiB5tQiRWcSXZmQX5NG3tM1k", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSEF4SnUreo9bHnC1zmxTYuk98oKEQmhBT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSEKUznCXwf4aYuZzbg7Rk9SM2Ddy4BTa3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSEpHSqJUricXvUu6mFgGUye5mWjZxHSYb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSEqbsHWVnPR97HRXyRuQ2sZhMQtLZyxqx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSEv9JrC2y8jkdPDB6iBPSdbZAb3cK74GX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSEz2Rdat78yuXAb8AeEh7otk4nT5NXJQN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSFgWbmdkgjZHccXJcfpKZT9PW8vdaFWSw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSFmczBsw3rCNKqXqaeiSZuAHRb9nVEy85", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSGB4Rd2xhd6UmA9LALTQ4f89Tfsz5VajU", "assetId": 0, "balance": "0.00000002" }, + { "address": "QSGVyyn313QfBE8iiDu4u3xw7sd7sg19MT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSGYA9Rnt1NumA681pd8iKguohVv4mWwJX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSGmPssKzDUEe1Kodjicr1gPSmFnLmDMR2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSH2q3wdiPjGc2rNqS7DyTBcuPEtt7vn5f", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSH7dFCpRkbxvfrAeAxK81u5HyBbgbUHs9", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSHLf7MR3LtKN5oeWewqJPEmgMBDVRB6Pb", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSHZBrqdYx4pQCzgm4cxqrVfqYkxdbu7SD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSHbicnUC18KH5qTWAcKqrsjp69rEYXRkk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSHyUf6Fk8SxTFW4H6HuvETcnEgqm75ZFN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSJAXFtSmxgUedddtMDHCcnHeWA3raeLGN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSJPGHN7ebvN1XktznN7BwwYKgLJf1ZJWB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSJZALn2zw1Fcczc4gc9dZX1YhEQRmC8X8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSJieAzBRMgFYUrQM6SNpxwa3RRZsVrEFo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSJmwFYNx8mGn1n791WFzJW8BqJqZZZwRt", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSJuhfcBNwkG9xLqhT1ZLxK5DtMp4tN2NX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSJuxUsAudZ3LUicPiMKDx4nYWRuXB1kZf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSJw6YHvMsS3s5KiicDQj6Py5FhdavoKTa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSJx6PV9Mg6tpMD86YE7yh9dYpbxLZmXBK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSKEWYZmLpwRadAZ4fuV1gMkd7aCMgEcVC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSKNx2QFh38BMcGidh8T5uxUQRGWgZ3kwN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSKRwCMWjrdtts9j8EaMWncf6PgMfdnart", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSKaxQHPYatp6YcE33CmtwiovP1qZAJZSe", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSKkwtMjyzqn38Yz8ke79SXxiV9iFuzYEd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSKmsobFTq59aj29wepqsr2J5XMdGmcjQg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSKpPTW8Jz3ozuerZo9LNmpwGjQ4CNJ5oG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSKw5wEsgM2jteXNGPsvnCJeLFjLyjG15a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSKyYrZVY8R61PnHYk8uYEY6HG4YihaW8S", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSKz1rHPT9vsTxn6aJbHTSKvrz4ooPdmRn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSLEUPgNhyj43Mgje5XoWY4RENGURow3dZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSLJukfD66Djw9R1PBiypLuYGoVNQRMDLP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSLUPHqcxAbhnQvgd1ARsLR61VSp2rhTMY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSLc2wVr5uTHguMtwkHKmCYBYvLhbF91uL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSLhhrFdVtdkcHQ7934dexn4v4dVXrQJas", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSLoM9aXkvWs3L6vMXPGkMScu5ppCWYD8v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSLtiC4Qo27k9n8fr75wGmmFs2gPb76gA3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSLwxtqyL9pLp2Ld9yPW1ztoC41ADsVwrC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSLxPeNaMiAVgUYrzES1FA2p57qmcx4WgV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSMZpdZWbMZQa7wxcywzrzaWTQTN216mjk", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSMbeEvYmPVh6HUipfmGp8zLE2Abbu2Drp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSMqdy6cQYy5XesvWdwG4dGtkJQ2DHxwni", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSNLQ8F1d8tV1f1YYT2SgnxKRaARXsuaPi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSNZkPBLAKKYgBMDEVajcUbZvjPk5qV1UY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSP45aTNgWWyDRAxNznBWDHKqetvnTga9T", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSP93wfAVC4CKuop33wvxtu3KEQurX1nBw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSPTGMwpoLLmZbtdksmVNgNJ3fo8fZeHEW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSPXWe2cUdaQWcX4iWU2esz4irh5zi2duK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSPgEr6b9NL3Zj9WzQJMC1pcKEbaBAXeCP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSQC61A6BjPD6fk57hCiKe2kcoYv6ZsCn2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSQp84vvbTFnBgfomGLd2H9VcdgdsuB7S1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSQvdP8iWP6ojhN5NxVdJB8qq25GugWrV3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSR34TLmyoygGA1T3yhAPubnEysMhuWbTp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSRyFJsjNCCnwNtXhB4h5iGBMPyYCtbtzr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSTLVn7NjorAobCDTrzHyT9JaJXfeyDEo1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSTjrFvt3M1fMp4NUejHMd6um8FiRhksBC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSU5p6Q78dQM44hCDCJwnWgbVyXPg9wMH4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSUJfv8WHkmrFtJFZSq1p3G5FXY2bvjGdu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSUex7Gq2uyNMr9SfMJMjnf9VYDSuRwwis", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSUruudcrhmPuM9v4JAoSnAdeQpFjQUtwG", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QSVAn7jdHDme7sWS7gCXZwLehPF3UR39N5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSVHBfWbvPsBh52TPaRvkfDZVjfSLtQeq3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSVwse1URFMXHRpNhmf89BXFGGtSgTwxgB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSVyMNwrJhbJ3qw8jJ2Voy3CJd2MUDzhSr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSVyPnf1PcWKizXJwKCx1ZitLmi2Ax5kQE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSWECHmUy6V59PhgynBvjmbP7ZDUdxtNag", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSWKZgePBgxJXdMSQZoqbTePgAhXp9xfe2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSWLBL9rxhhYch8xVr4w9MVqtqnhARhpSS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSWNgd1k6FwTnJh3zZf6jTocRQgxBn7FWx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSWay7zGuLPsaZkRknvyndLKGvmHyR9od2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSX4yNJe8jP52Re1skr1sQDL1V9TV5V5cG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSXEk5nzk97FRK1TsG8f5Xie4QHzrkV964", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSXZ7UtAwsPa4BTBys3uZ6Bp4xnJNNnNGN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSXgs7B2WZJb7pTQ4z1j42E1RdzWPD9Rv2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSY4yDUBCHSHUcZcUCQ3KoTZKzkFCeU4JD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSYgSLocAn2XqyNK8MPhUGtjVrCuDJYNYv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSYsohrMcv4vBoJb5pjTnxGmfrtjDwUxKW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSZ664xTJWtcQZ6wDLs6AF1Qjc52H45Nw2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSZGvm2B9sQKbmCEHueh8EBL1kAHCdNave", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSZSkfeNcaK2fKLJiF6TwVuZuEt4opALN4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSZe6EtwbMtPe54gxhkW9qmwUJzYRG53ff", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSZnbrN6YrzRFjCrA9hCx5tJsKcphdj3Wb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSZpXWCwVa1aKXhfJh6VjM2xoSjFuhLkMD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSa92cJdoHicUNkdgqiHvwkUG3rw8AyZ7Z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSb3Wn6RPkods6NBbuypXHq7aqMhMzHXgK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSbHwxaBh5P7wXDurk2KCb8d1sCVN4JpMf", "assetId": 0, "balance": "-0.00000054" }, + { "address": "QSbPNoPmMeJYqD8FuiiaCZ1ZFwKTtvZZ1k", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSbndAmSssTNk5UxjM86DG2b4VA3UNK357", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSc32k5pat4ZeygcqzXuZo3dXb9HJS6yfv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSc5XBGuZacpmpGRt1vLM3E6a2kb9aUzAk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QScBgSw74MquesXmVJxerX3YgyhtShRr4q", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSd8cfPoMHLg4mqiHoAMd3Wz7aPyqHdoNR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSdBntWVUFqzZnZChgGyWa2RuRKG6KZTG1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSdfdsmQq8Yf5MLCxm9fXqDnbzNRC8MsTy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSe4dcDE1q1La4Wn9RDekwJ94Vxamc6aHq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSeQz12aX4KK737HGAHXA95DJ4GeQiS9WC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSeTxK6YS68UXY87VJbnGWwk42aLkPPCHz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSeWyZCThxUJGhWufqx3TyrUSCCqmezwVL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSeb25qVpPzhnN5VPsA843FurPSSPuqmEk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSetUBLwZhitMfJC4wdXhLEByBRuBhLs1A", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSf3yJWGp8BWpynnmHCTGGLvvHq7xgD2q5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSfD4GLXc866iDsx831M6WPqiRJWoS52BX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSfMec2eJYLcN7rUKUAR4dUJ5fX12sEm2m", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSfYzkFTPVbsP9YR6eHDocUQET9UoiADbN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSfbRzGHgkcj2DyLuuaY4uD5hdsBxbcNPg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSfjy2PRkJDoG4cayKVArWU7su7SDXFnNe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSfz3dhxj5FRCpNPUESrTbBcHMhXt97e1f", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSfz6VgoZ9NuJdEJu1j3zj3efbYKmmTGrL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSgFEURyKLVh8z84Wxb6MJxDSvY7DaRuUM", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QShuCfN4xj5ECLGzc4s2qyXqQLqM69pqwv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSi2RyV1rZ6dXtXdxK1xSVvmLv2FAyC6NZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSi4ou2cmUcYHXvzAx3phoDaUxmqfwvWrc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSi798zstvinDeBScWn3uAfCEDSqFYR81C", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSiBBzqevbxmEiJqovn91BjNjt8USL9wrJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSiBLJ153rRdsGT9mhyPx3Xs4u8J8a9VBp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSiVBWRfcLeF1XsknVUErhvYPxJF4KfBVa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSijLaTeszG46jotZUUL2Fc2CcheJXA717", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSit8wgEX22gqAzi43zqrAjMhcjQ9VFoEh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSj3LHo57KPzNiSTQ9WZQYgYS7h87DhD7W", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSj3Tpj5RN8Z9ncLAyjdLh1m6To3mA2Tys", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSjBfQsSvXzuWMiNoem6hvfuoRcC9Co4Yh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSjFqadRWUsnCaitLsRf483tkR81z5BzDm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSjQTHv2526NQrkbvVxntUGSvhcpkoUT1w", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSjd9TAeMiyVzzv8nyMX32PK8E3bsSnfqg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSjess1Cskr3PaiE6hcKRAtVMiEeQX7ojr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSjqYaBA8euuKZsvJQu9moQmaPkPgjnxUL", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QSkGuf5v29UiS6e71r4wrVXfKew8tpyd88", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSkiT4RGhUkf677BZC8Lcun5eriyRpxG5v", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSkicapNH35a3UebSxxSMCfntBhwwi6veW", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSkjun8z77K3KTSqTTvYwTYZV49UQ3SaGR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSm17TQL2dd85oWUyBK9TDe3KBdq2NBMsT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSm2xg4nkLoB2Zhq13t4YZvzBSthHqinF8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSm7koGPPbjQ1KoecySM9Z5BCLbKM5qi6o", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSmMfCfuNrkAQJz3WEnLrrH3txQqSuyXaR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSmfuCKQcEQiZtTryLLMDuyix1C4hRjENu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSmuduqkKaMLkrRD5vs93ucVMeaT8KXMd5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSmwZzqg6hR3qVAvd5nrGh3UvLe5h3HJiP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSnC3dy13qhxSt1MPK2jLSwLBgHV14scyJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSnE4fVk6FA2qT5JsbKyxNuahFMpofB1Qn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSnGwJ5y2bFeKQiicbJYD1zZewJAZmnLKe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSnb9EpzbsXpdSJxAF4kxnxjTqjiCVxdJY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSnnBX8xrQBvypTEadRjVZKZC3CHVSNeGW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSohMWUphRwtEuwAZKqoy8UGS13tk1bBDm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSpQkSxvXNwLzuXBpJJ8cQxRJTaSfnFcnW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSq4QaEMvQUW4qhBBnPKDkkbxei5P3R71M", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSq7VrrognfVmGLrPhmpRVZZmHw5LApnD5", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QSq8y4ZrSbF55ZddWNcw1ett2LDtjQEvNn", "assetId": 0, "balance": "0.00000988" }, + { "address": "QSqKL6vq8wuF97unfwbu8AMignTn2sLMGT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSqQXgzY9vA4VCWpitC3uZxHE4L5UXP536", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSqkgBbnMt4NEaNYdTkp3kJLRdoDDKwHxC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSqnwDy4CFmAS3X2jd89TViQUaupXWkpBq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSqxbgxFchHFimFhpuBCKSFL9Au7QtnZpe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSr2dfnvrYYN9mjuBkuSDq6LiK27stWcKz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSr4u5Rbmhg46CG2B9WRLpbhh1fyxroaaY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSrKfYy9tLgb4nGPC276XE9DjyxTBgFMgA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSrPgzKe6RnnTyxj4SFZiUjD2mxqLVGqJC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSra2XMVEC6EBxk7MZDYiHtATbFA4Rm3rN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSs3MjwpzWMDGtRGgGf58Pfhb2dGUB5JVz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSs6g28Qmk5kFnznAbq1oNHRX8MM3LdiuP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSs8WEjQa2pi1uLmee3Y1jdiV3yr4afXG7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSsZBkwPMbs4LEwMzafiQZyqx7B5qg4yLB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSsdSDsjDdM13x3nU8xKggJ27cXBNb9J95", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSsfSUkgXCmXWiqVxdJbXydy8ox34XMxDL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSskaZwf8pMyHSkLEg3ViGoycC6bnw9HnL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSspLeypu9T2UN6bHcxWFsVJNTb25q2YC2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSt4JQUyZpRQhsA4oAqaFW1YHpBQv5diYu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QStUrfFy2NZrZf4otN6aA3DZYuoZinAH5J", "assetId": 0, "balance": "0.00000015" }, + { "address": "QStY43bB1VvHvzYQpGvvpyA1HtBXRHToDB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QStbCjDkvej14xCxt6sYTZkD9yPtc7rNuK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QStnQAswN3725rycDz4Ptp8NUDJiQ3GuaH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSuEX1U716UoRTGAVPtGXQzFxveXq85pmQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSuGbYpdrAq7WryfsDAkE9VqPF2R5MGfKz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSuKeCydi6yhFH1zbHAir1AtB9Wr6yhdXS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSuUYTEjMLrwjtqz5faJWGsBq9pDsE51ek", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSueESoGxGCJbZDzDyrBngS54wxVv9znUd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSuePjxDmGmhEXJv42ENBgZvpVe9MtBsJy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSugV4NFgBuretWEz8FEXud8mfQzTwDvp7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSutLd1ws77D3pLemKTQfRF9qoWdAMLaWq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSuuRjUNTDGrBixExw2L8Tipe8kQoWdEF9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSuvXJW39E2bFHnRrDqNH1BATMSMVhPy2k", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSvA5LZHUqNcYPtmKKYnSX7J7hYoGW4Le6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSvE9BRXaoDWR5AgHLCx4pLkFqVmjsuEQs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSw5PmstL65kg9UmCvMxn9k6K2pW66ngUV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSwJirZwKn4PMmKkXsAyPTRhQ7o2nnVUJr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSwJuevSea45uMSaeg1R6EJVd7n4Crka1i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSwrwqaGmwNwcGW5gYTrdavoi7goMoL35L", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSwwCXx9hJgM7ZAVvFb1oQjrMcSfgBcDqy", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QSx8Ya6MThZ4v8GLVkXHfZm1gjw4eVozsc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSxQo6P9KhSxM7Ayxvj5c7pK79U73AJmGs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSxmqMbdgw3h4bcBzv5y4WLtrq9XzzFb84", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSyLBn3mx73WmsH77yYYiYyLTzmCe3QGjc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSyRRxduGAfPvV3X3iYKgUzzWvZCQPCkFA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSySWyR15LjrpfyF6JU4watKg51CnZHPt7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSyVtVSnjD6f11oif9YZcypvT696FsDN1j", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSzPTKSLdk66UiV7LygB278DHHztNDxna2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QSzmZHa1iBqG5QT8hMhMZxawW5HuNpbt73", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QSznSVRKaaqktcicBQCigBEQ7RuXhFK8u4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT1DwaPAzQvpQVCht29TRcEURmjzjd88Q8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT1ZatXCnKzwqqwyY5Fd1hbpiseVzoGb99", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT1yoNQ9cF2FaQu33bpjfxoPWFuF5x7dJj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT1zeoWhXvkoHAitAeATQztvh8qiUr8WPo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT2KjjJZ1WtS9P3MvbuwxN2eXnt12etZg4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT2VHUe6H6SrcmDgceNBAMe4zbu6VFGQcL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT2aD3hLvJmJkJf8E16Fb8TQkfgV2TuPCQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT2hh7yn1dDDT4aCikkeVahrHJnfrQkfVi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT2iaUPJ4yE1w2XtN4tmpoh9cUxV1mVhJs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT2oqzvJTwLGLJz3Z9qhmLAZvP7dHfx9ma", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT2vS5auNuD5mQtUmqj3PRrxQTCn4wwgoA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT33hnKSi6wRX3xuQ7ygo4bvJQ9saAdmLt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT3N3raVLi6aobHN5AdyKuNbPmjYJMUHNm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT3N6MMQYwraadd1T7roDDNQ6qj4QPvPgR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT3S4DxkyQJ4HkLXBCL8X2fzKb3yvDnweP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT3ctY9RYmWUBLePJgvCEc7kR6CNuqxMBE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT3kxCAg63z9Y1EEjdeTpEVoUhHW9LqGar", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT3uf7bcnZexeRemhLd2vVbS7FXRtLt5Pd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT4UFixPVTyWQVqR88zd11ZjZB2zV82PyW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT4ftPtFZcEnbpbbrj2jTWsPF1Bde2p8AJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT5DxPFUZgM3nLe4aSRU2cJx2JwdC4GCkf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT5Lk4RwSGsmzV4jWabANUAboCpAQQtPyi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT5PXfTynkrhckdqd6L5NyGxeWVBgXtMQC", "assetId": 0, "balance": "0.00000336" }, + { "address": "QT5cjKjZ7M45Ds6DYjmQqtmdQju67aZ4FH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT5j6JiVESq1gddvjapfnuuLuBy2Qpq5Kt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT5pDmmuGiooLc64dGT9wrSwuMUJfVHguv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT62RfKt3Y9i2FY7Ph29phG8o99QGW5WHp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT6622qoTwuy6iMrSMu1QTW9GFkY5aen4R", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT6D5FA1f52VqvscsiFYPwn2XN7hm1sbST", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT6K1KJ3ED3wm7Fdc2ETW27spHWdjYhAXG", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QT6diUodc9vxbUyrveE9vCnYnuqW8A4aJ1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT6p1vBPyW49xCayUn7RTFBqT8dvZerqHb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT6v8stjAsYeB3yNoC1PetWePuL3DLmDff", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT7FNRmYQgFgfjoxEu9ZDPkvFgF88m9R1E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT7G9mDdyS7gFh1qZyCDSboqTdryPXqGyv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT7GMgEbZSXbHWFQQZ6fKMmoozYNGXGbae", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT7a5L6QcRmZhx3e4wr5qFJzCrq2dYMdWk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT7f9vwrKk4rq41w2r19qVncgXqERg366X", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT7rzT651RDfJi6bEtnUkh6ZZNUbiQFTLR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT8UUa9qdMXKG9vrmWt6EqKu4zinxjDCGf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT9CD8wmgpLGvRy8Z8apay1rJxa4erA2M9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QT9iv7z6PEipRCLfEgCXn6Zvd1HDfPPcCh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT9jjRKXiLF9V7tzniDq4Fv1BxSiFcWFS4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT9qHoUpEA7rN8znUneVrzg1urD6j3hZSv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QT9tXhAyr2zAtAzSwfjWdtZxsEDH18GS6c", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTASwAXA2b2EKkia6U7ZuYrzikQC5rHNfX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTAjoKfHGPLnV5Uoo62dpPzYxH8uKX3XMY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTB1wxjVsK7HmYohoDXXkSzpEbDhhaQUKK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTBChNV4MxvBHK2zgw6cHoAYS6rDuxiESc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTBG5F778g7j2yw82ReDZuAqyLC3xe1RCu", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTBM9994DkZjhvPx9xLmcchSSw755LofRL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTBU4psbrUXBpBMmFmL1gyikxLWcNL3Bia", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTBhnTof3pvREPJxHx9ZNcYbrSZvq7NYsc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTBuBbpSBzrwiUhEZNX1Uaape3BaopZ8Bv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTBv7faF9sb9avWxvph56A26c7RXkrvpsG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTCCdnAdahRWsoDsmSbQFCwNCZQtAYuuTw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTDfuXnQfLVs67m45YL6pmU1yU1vu48biM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTDxtiKd7ZJ5qpdgcN7w4nk4rYUYpf9PwW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTE6b4xF8ecQTdphXn2BrptPVgRWCkzMQC", "assetId": 0, "balance": "-0.00000072" }, + { "address": "QTEE4ZJXv68ke4841HWjTLAAU8mfccxwbE", "assetId": 0, "balance": "0.00000001" }, + { "address": "QTEcoY6foPMrGnJaWteNaJkJk41SUZNw8D", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTEfSwQrcVMJmwb82acVMjsdg6s271UUVG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTEfij3LmzyjLaeyvA9GBYbiABg5FDU1Lv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTEktor9XWBJTZxf3w6Bo453TtmdBVFw6P", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTExYVr8s8zunDAb6b6SgxVTLkGxK3eEQ6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTFg4go5uJ1oZidqRCXqu7miyUKiqzWuD2", "assetId": 0, "balance": "-0.00044487" }, + { "address": "QTFh8uc2KHuW66b98k14w6h6ro1bJ4rnma", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTFr6giT5u97pVXLk4qbw3DuWGUCDmdfPn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTFssCBKo44i5VFw5cemCtphRJkDGpv5Zp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTGBUbMv9cKMxbrrCQBiXtj6XUEyYumNns", "assetId": 0, "balance": "-0.00002764" }, + { "address": "QTGDH4fpDfMfhARX8bCWqycvTAh59h8Ucp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTGY65oKtqxWVuZndKRr893yzLsygujoHT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTGeQqn3XEFdnnCqvifCFXYdKym7SaHzTd", "assetId": 0, "balance": "0.00000002" }, + { "address": "QTHUnkiESrQDfsf4JBphjjmKceiETZjzmU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTHY1wcn73eUd8BjhJcuMZZ44KpNz9JvLU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTHYjZQf4t2HnfUY6sRMJUL2V1KMDcW7C3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTHkfn9jtGf6acZDago1a6MFWYGPXxYtCM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTHmHDrVxyv47ESDnvZqvHJCpUazgUARYh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTHscn1sP9q4Jr2Lcpf14rfXi4KkUdPg4S", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTHvcNwmpTmVuMPHGasqeEHq2MmLuvnBXe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTJCTz44evKmry5fVYkTTAGnMCZVZKDDbM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTJGgoAgd3y7cX6NELKjK9R5PQitwdjfaA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTJHYGSyATrhKrbB5atBcoR4FDyUocoRX7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTJdoseeyiogJqAY7MJdgUCbENDMQZ8wbj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTJwBL9NdHwFffADH5vU6BGmUYWFu8PAVy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTJwebfJPhYb9Yq5VVCdjh8EwQtxfAqHBV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTJxemZXuxeqce2SCtZwEeSmqiQa8jsbdn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTKKxJXRWWqNNTgaMmvw22Jb3F5ttriSah", "assetId": 0, "balance": "-0.00000055" }, + { "address": "QTKWwkmjZc2DkYnSnnrLAEAVxfqSchWCBu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTKe6nnMzDYHbMJgZ8rvRL1NQURBVoTuZY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTKhY6KN94ZQMbA4tqvnva7EEXXWsPhhkq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTKnuZkRBXUXA6N7U5Uhs3XZPen1MDmKXh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTLSd6PfAESUKdehgWr7T8DwTQj5rHjKXW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTLXaiUVxHBqbb1cJz6A8bKeNtjF9zTriV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTM9jb15o2kU9fT6ARQdYJGWfH5xC1vtCt", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QTMDuqVvi2kwA41QmC87Q14cKZ7TGMDxkg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTMQvoyhpxnH6es6HMUKYPCoMe4wxvjM8x", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTMTFswUU83XVmk6T4Gez7qUJCccbAad7S", "assetId": 0, "balance": "0.00000001" }, + { "address": "QTMczbPVBQ4Yvr3GdjS6YeLjRCBn8hvx68", "assetId": 0, "balance": "0.00000336" }, + { "address": "QTMmVfsdqzCkX52iBthykqhxGXMm9VMQTo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTMn3d8r3RMBjn9vuz9yNbGZ1ygMNwy8sY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTMtSYhytviZt91waLPxNe61roWgc9b1Dd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTMwjQpBwCgmPHca3RvgUY8AKfFZNtJx4f", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTN94L2f8M6cuHoi2rWw5QkhDKLinfHjtQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTPKDMTA65WxEWWNX8g1RPMA7xsqSkZKpu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTPuhhNgCzdrocgKBNGRFyAQJdnRnKoe3g", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTQ5U4ix5uNy1a8Faq9y6YJ5m3nqbza3T3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTQERdqMcECRVv8TyhxtrqiLCYTuZncwCQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTQFAP8NFu6mahGe59rSmYJbDGwtzBmY6A", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTQFp2XBUhkmzbXKtWc1kJ2huCqhihthR6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTQUNiEutG3F4GoGG9f8MCJHFH1myN8urE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTQtKGSPJx2ait7wrmMqTRmozfCzrAcK8v", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTRAvkMBrHEt4sDYAa6dHUNeGjmcfAYtys", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTRKewz3b4iqbdwxQXyaTppK3eRGmbdYJx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTRXvxjXTjXkwbPx9KwGnKqYhd2CTSGYxV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTRhxf2tjryUKS222fNGDJMAVcRsHWnsH3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTS9BnvaMT9KqWq8XLzcVcqn6eTNxVoJ4T", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTSCBNJRqFD5s4EGGQs1RQXHb7MDn58gXx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTSP9ecEJsEDidXcH9MScfSoyAkgpwvBqR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTSUkchhr1eSQTMmfvi8t4aYQx34SYP2n7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTSWwY16eatWF94MYv3hmX4TejgeDGZfJn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTSbshxvXkgnxA5vF4SjbcCt51MyBcwize", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTSrDNbWFxFUzpCX9MnoGuBKgwP1oQqjsg", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QTSzRSxALCc6TCX9NNELj3oLtRMtKp9jMn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTTSUD6aPZZhBr1qkYVSSVZt6c2CBXrpGh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTTebgk2nooaecoswBHRmEXoz5atMWKLa4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTTicFUgkm3Vc62YE2PXMfePSktQMaCe5E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTTrv8SWR8huV8TFYUEQhfZ1j1JmtL5p8G", "assetId": 0, "balance": "0.00000002" }, + { "address": "QTU1HKTUivDXfHyrVujw6d9ZVfvLugP93B", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTUfz1z2C3vp1t7F5MwwZmXHVFrijyHUbU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTUjkoZRRsgqXcvmbTH6KBjrU3VPALkod9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTUm7wNRJrR8vjpEah9onhNNa1raSAPjAU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTUortMnJQ7rPEu9hRpYpYfbMyRuF8rWEk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTUqAvYV4ztd2kxeEoMqETBS3zNSeumSHA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTUt2NR11Eo4dy3jkb8FDQUKzTnNdYfUZF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTVLRwoopfg7qSG9CMfCPJz3UydnT3jDxD", "assetId": 0, "balance": "0.00000002" }, + { "address": "QTVtXfSV5ybtZgLrw7cHNb5vsvLGWZhmaj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTW6NFoGRQFYxg7sCqYBiAaPzxPL7NQYpc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTW8PNJPtTNGi7QMWbh13tcS33ibizNp3x", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTW9TTM7fM4ghv1UAfa4L6w25D9PsKeh3f", "assetId": 0, "balance": "0.00000667" }, + { "address": "QTWAqu3RzwhX4JEh5G2tNA9JS4FREJpBN2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTWc2J5jHUEeqyxSCQvnZu1GXuEWdFN8U3", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QTWj15r8DtMuiHyuLNBh9TTouVpXvvEZGC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTXSe9mNWX1PLzAEx9HGKHa3eA4MqMUZtr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTXWWjSKF1Erxbc1c47NFLJWc5eKayySB2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTXdSt7HnjBSyewGdM6aZAFYN4bExW5aWn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTXgt3opFxvGWRH4STmK8AoCd5NzS3Bek4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTXrWYT2QE69q8u49B78xkiQH3gzcmoEUm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTXvDeK2WxJZVjdrYkxzSx4PdRfHdTMLLA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTY5DwnfZmDyhdUucJqeSPJ83BDokx2EDD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTY9mYx488BASL1qCDiNpkoDYHu8Hnetwr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTYCALezGwgSHGzoSh7x9ymLdhU937vMfr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTYR4SLXvQWevbPABS5bRF2zQwaJwhDnD9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTYRjPgCpi5dCU7KBNh3CRcbkvghVV4ApB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTYYRvHvye2Wu4eF3rKxASowZzrVxk9qDC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTYn3bNJ8y97p4m5n4uBdFLswUAiFThhMG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTYrHAsejPz27rEsh5TzAhthRBQ4PZbkeL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTYyjfU6NMcHdgrkdj5nFyyquNhjtJcnYC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTZ2Gz8fji2JhmxxVpSxzryn4bFuoQBKk3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTZNxjnxKaJH8cUQZsiK7KgqtLpHNAb6yk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTZW1hUV2CE7dGM87KutDbuK2qZppxrHML", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTZXBvKt7ZjDeJKxZcpAjLTgBMJKkyTJgq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTZfpmbaDzFBG5ejhsVQ1FvBAmcZWJmdef", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTZh1TbhwyYWUdsMaTLVnWikotRBDvRwVz", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTZv5adC1TXthnhvrjWWQFg9Ng5cGWN6KQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTaJ8ePHVdjndMhtf1pvtRhQtkzLusTSi6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTacacnM8AkgV9BJr8cKZ6iTwjGZT5cFzs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTawSuSFX7o5wiLm1kmBbqsqXhVeSNpevC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTbSsva6bnBB7QJCLFoFVr6bbM4ZJMmLKm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTbkWqGY6BBkq22DPCeD1YtYvVpNBBdWmy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTbrzCB9nnAv7Vno5dEAw4NXxAfVoNwyWA", "assetId": 0, "balance": "0.00000655" }, + { "address": "QTcAnaQCsmDnGyvLbVAsT85sRgfJFkZ67e", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTcScCPGoZbuoBGJybPWkorxjDKYWXTqMF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTcYeWYWJXs6tx1Ny6w4pKmrJWnrbu1mSX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTceQHpjXRAcatu9xbi8f9pchjwGsHByn2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTcoT1yACJmDUT1JhcXtwGeekVHFGaDWpu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTcpmNgtpKDZ1ep3EXbe7bfGqiN6TJMEYr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTctnFNpmNNF2DGXx2pFPHFT8TNxej8V5u", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTd3UzSuTNqGsf4ZrWUchEwmPELmj143A1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTd6P8ZuoG36VRE9W3VhtvRWQgHN3qTkhT", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QTdSGHWUaEjx1kW1AZdRAZPCkaNwcqDCPe", "assetId": 0, "balance": "0.00000652" }, + { "address": "QTdWujAFt2ErKotw4cjiorqLUqCiWCMz9i", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QTdX2wSGrAq4sdodUg76c7Ciu8n6A6crPw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTdhGSM8Tn88HcNpiug5hLrGSmSyNn42pG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTdrGKCFoGYsCb5GxoTEoMmovnqpRTCKy7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTe4t3M94e2DL3DaGajDAqfUSuagbUp1ic", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTe5r3P8DPP24vMpMwWs6sNgPh86XVts9z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTe6mwnL1qneyuPJ496mzKE3XRPo1XnTnd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTecbuir4YPxLQ9c9Ht1TVrkHTKfnAALBd", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTepeZgsnHiwfp9vLw3xUKPJvVmrHJRJpq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTfgyLL1KrccXk6tYpHjDz5pDbdEcN5SKK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTfjufJYWFMopeMgvNBBKMzF9Bmm3pVQhd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTfvp7c8bou3emUYnGN24KRyB8kwYW4mLd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTfzDpjxMBuQxaageKBTtGyNVPhyqWRGxB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTg3kBgbGMfWa56wuJU3Foz1QmJRoPn928", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTgN5P71omrA48DDXJ3BRoUW3qZcXvEYMF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTggcCEg32fs4bfUzHRrbLb9QNNwS29JU1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTgppgdJvZfdA45Tt2fktpGGZVGxMs95wR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTgtYSdWErieArhJ7eznKSv451TqCxMYxa", "assetId": 0, "balance": "0.00000988" }, + { "address": "QThFpHAMGRpnF58ihHgQNBp7uU5HkaBq19", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QThnbw3aF8eZanje1iHfMcpP53pEZr1FX6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QThvS5dG85tvka2BXzVS9bhKCjSghF69HW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QThw6Vv1fNmBGHYwiBo3Jg9ZV3f2oHBQFU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTinVnVhoUGcvENUBbX1uwYZSz28cTfn2m", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTj9C3B7urfz3ey3k8c58h2wMakP3asHA9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTjW8gsFeZEvwwgwgyLum6EZzPeayTQFvU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTjcxhbiV1zLfgkrWXgUfx8pYU4rQiYsLP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTjts6HKE2VL6BEa1EcHXmWSwYGo1tCg6N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTkZtj2r3w4es1huPVPWtw3VJbqdkx8Nqd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTkj5jdruMgm8GoMN5BtDJvBFe9USCDwsi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTknNe3yRh85zp5kjdLJXmVFvvHuvYmpFB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTksgnyc9NnaYyZnDatiEhwwHPcXzYUCw2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTkvaFmgh5pFDGadAwnjddeQioahUvQ31F", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTkwYGh8AjdMoYEB386UzteWQyRcifwzXb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTm365pMb7epqy8Tw1vcsqw9Yfe4mCTsYW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTmTzMJp6FDzR7Xy9Fk9546BgUTGLC8WGh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTmyUGxqqTGJmDJsEDi2cwXrVVmMhNKT18", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTnmB7RRrhvXh4WZWChPDU6sNP1uRSVyFY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTnnhs3THp4cmy54Vaxop4fjmE3vRWdeUV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTnrUfjsNavNYxqdgBpU4NZd2M8V4xdBpY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTnyzZ9jHWGLn6g63ZLFeTS6EFndrJUTUz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QToJRgC3eC7eipvwcZ99hjuEifs6kf2sbq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QToQjkzQwNeR4aCbK7Qcf9z5rUMifxB3SX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QToTX2HQAqokzYFEgPt67xqXRbgUjmkPLq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QToXZykde7QwfPEiR7hkcuSREMMEV1wJv8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QToahitJGh8TiH2yM1pNVJWEepzPnZoCho", "assetId": 0, "balance": "0.00000015" }, + { "address": "QToiQqq3Yfgre13UX42KCwDKHMo8BCFfKM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTornPobR8egQDV1k63R9X7vU3eQiCFJoR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QToyMEAn3w571VKFERwYfEnHDu8fpaA8nk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTpBne6Y9bsSYRMyKTsi8eD9CW7kKvipJf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTpPt2H1Tqmqfoe9TsnysTwedWH4dwZLML", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTpYQqRyMekaEuECziirzy3HvCVofZS1wJ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTpgN59vzaFpoGaTF93ZXwCvP3iG1Vt9vj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTpzxTSHMtEn7CnUiYVUch34paNQFLWrVd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTq6XQDy9iMmDnYgU2R4TAJtozoZ74YX7p", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTqkx3N4ggUpgN37D2uucgUdtEmViXnApL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTqnNyDrUSdeH8QhwXqB6bxoGtLUjCvd4R", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTqufaE9vzDifCaK6TUDTKBckPQ4gcR93F", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTrJucEocNy7MHXvVqWWN82TGnm7ssue2H", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QTrknfAS66bt1xL8GGsnBRBNKdV3c3vwCT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTsGS5T1syZTaejx9coujA7xZKpLTWgi5N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTsH87tCPR8xpEXQ93JEF82d6mtUWQ2xTa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTsJnzAqWDojbtA2tgpXadykuUbsDQTrBp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTsXE9q3Z4iEcjojoERuRs3VqxWn7H5qHX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTsutcJRvjMV8MhTvuetGL6rPEAnvcdYZB", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTt6ZC6dUaDze7ENnUysQ6AgchBpPX1t7v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTt6uTQZhny8LxLMVgsjS8sKoQb43L3ZZ8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTt7HPFMgZ3V1JsUc86mAfik5TufHZJWBK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTtAB7D8f6gkYD7xYKoGFgGe78vj63rdcT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTtHDUuEeKmuQK6TKmftkzMZhTEEfR9HeA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTtXS6fZGThRLq4qgkwM4ngBYkLoFyZ3bK", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTtXVqZvSLe7T4hxGnRW7TeYmQekgAAbQQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTtkQiKiMZcBk2zxua5AcNE66FGcHNJMVJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTtqk9KAfTAGmBQ35JxhgBQoXCuEXBbcph", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTtx3N8tpT2Rvk86A5wHRJAZJyKx6pYwyi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTu574fVK8QAAob6ZVdnJpQN52iYUgLVdY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTuKq9bkTxF4opt5KVJfVRUeH9aJiw6n2z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTuP2yHXyT6HTrnRs7deo9qAXAutK1HT9f", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTuUPoKD1x1JsdbMMFkv9LHYnibmzhaUm2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTuUbfX37TzxrBZ6sNhwGkU37gaGy4Ts3S", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTuVQB1orPRgYCTPmzpYpdtpirDn7L3t3C", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTv85R62UnTupLCGtuAsokhw6onvoCrve9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTvCaH2qKrZqkqF7pwDpEc19EfvcfCWxna", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTvYmUSuca2oDiRgVq7mprBGZtXdGNsX7F", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTw7QZHrvq8LdwwMndgS6jwWZ3uVRrHhww", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTwDtZRQYTfjKeWRnfJMWSy2wiwmh6WkLU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTwLAWT45CaA5sraGKvXidSzUy8nJroKob", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTweLb6HsX2YH8ZBp4NVebHngSZHSY4NQQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTwgB6nXkUaZ7Jbj7eJEX7KMi1Gvhw8nVU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTx2tgZ2LzU6bPydVGbHbvigHnjWwGoX9E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTxK2iBYyj3Qwcfwo8vjtVvmLQmZVVME1D", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTxTugUt1VKxcdQxZivi5KNQ1e1jeebo8P", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTxXWpgUXC7sjf2XzjrEKixAjU1ngJHaFG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTxYZGNy6xKDiEFB5znSBNDEBM1uSsJ5tK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTxasDmXfaSR8bk5v3U6pJBG15aKbEEV4S", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTxrhrVvHSajcZWCu99RAoYHe8JZreRX4Z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTy5RQJQCr24yQzUXR8sQ79q6xnPsQk8Uy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTy8J1dtbWc5KFBYJfLcoQAbzktV4JsxNp", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTy8m4MMZBxrfdWpDwuZoPgUFyaswcc6Ko", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTyPfPJyGcsHrK5Dxgg6AWSGMFukDQHbEU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTyV5vZuTxhmqkZx5M4FyNq2a1VGoJSJBD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTykpSG1kZX5CUqvRDncykJ9RYo7C7Uqsx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTyokTJrR4b2y76An3BFUEbqQy5vvg76iN", "assetId": 0, "balance": "0.00000988" }, + { "address": "QTz3kKCVa5pL4QuttyJQHSAmM7t4xYN5rF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTzEg3sykZRZo6xjY7DhTptNhisVojBXCP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTzKK5fPBrGzudV3NyAnYARRQf3HcT2nCL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTzZZzG7CGrEv27er2MFvxu4ntG11ALHKp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTzZdp9Qcqoyw4VHW65bQcYM7pm8Ua9ZSi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QTzavowzpBbw74my4ZsbnSBaEJeHPidD2m", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QTzwDReVFs5BbDRRNHz1vErAqhtru64hRS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU119jKmNDmKEEMjQHopZ94M5X7Taj3pA7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU1361z4Xhkc14oY8puy7cYy3rsD8qsXMB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU13pTWMMHLKUVCqdkJ1oTbH5NdXL1ovip", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU1MSUnzCKJUdXeSTmuPNyAxVb4bTQoNSL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU1Rf6eq7YTNPSPVsgtoCatskgSZq9wG6H", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU1fxx1d9EbfFRgAv1MQ86fQ7BEP6rcSmW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU1o4wXzCokQeMHxnzpRwVtQiRYppYGDpC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU27EbtSFUmGGbHGGVuiiikKQ8k2Pki5vN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU2WzrCbsMLnDy72g8iQJxFvDDTCPwyqia", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU2aCNpuMiF1TYz9hgQabbs7YCXrdk4ecH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU2sYjPiEFXPCjQDJC69ymuKt1ZHGpzaUx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU2x58wPu56aTXVUTBcnfDtApWVmeMGFNE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU35MDQpm6qs9gSiRoaN6TQY2uR5VuxA2G", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU39sbweYGwWsvEsJWRjyKomjnFsKGvXar", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU3eX8RFm6SoTVGJvCUJ1atfxbf4zW23To", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU3hEJuKvfazxvxo1cxAcW81fmoWzR3AtU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU4zHvoZfbT3MBGUwp8LmBSYsk39zbbxys", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU51B4MmszGcMhcZvHaKfNiGYiV1H2npPE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU5A9Hmv4TQddnoUZYDc6kdVfs6ee9hLvq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU5CWGHdWoMSp6Pezb7gL1ycqwrUWFg1Uy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU5WQ3fbExSMSEhfpnTHbktexhmRQStwem", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU5YkePmpmcG9cEVxe9gyp4gS7MSHoraty", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU5fhtjpjoQunVdzy6SUeydZiBsZymbzXe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU5sn5xsq5CwtzSQ8bqbgQkFR4CVUymtA5", "assetId": 0, "balance": "0.00000988" }, + { "address": "QU6PwaY6YQCk3NmZirgQd3FQvN6ByRQhzD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU6hEL4oL2tTxywZf7MQj5u8Z8GTox4Mcj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU71xnfeasNQ9ptDZSVKCnwBNzTgTW7hjX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU7MNuLyWQ9J15hh9pMfohZQx3kZjTNrSc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU7b6pJsebcx6wfh8b5CGVXxzU2HRbrUkD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU7mCYdBQPp6HS49V4uLVAqUomYebJDXUj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU82DEMxPg2Zi7fhiLs5XcR4Bxks5Q5u3B", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU8TaYGo1oKWYTzUZLXhAHD5jknPKBRUds", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU8X9jgGYbuauWNbspaCsbPLf4Vs9K1mEh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU8e8v18Sqb3XnAaTmknUyFkN3nhAh3mWH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QU8iyeXpNxo175Um8cXR7s5qA3cYG3EmPL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU8ouJr232JmvqireuMcRSviyWatS8wTuu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU8vFmk1xUoRTFQuRupck4HSeeuYFAVMjw", "assetId": 0, "balance": "0.00000655" }, + { "address": "QU8yyX8F69EzQhH15ot3WJTv8pyqN6uYed", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU9QZd1ioeDxKwdkP4Lwy1rBTjTEtxa9EG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU9brvPCTvvA57H2C1ucArKGfDVJPM8LUv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU9cZCqMVF8TKGNHiGfZr49VvAMxBbDoNG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU9dme1PYZorPPa6nassiGnHedEuHiWBBh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QU9uSMryMfA4R93Fu97xhf3Hej2P2S74Fu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUAeHx1aGL6FP7jVF9CSxpVyjKVu6gGQYp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUAt1amC2kj3fwHozs5ZPA4W9teU2Rtpqt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUBCqMDEdFNSNn4UG9UUqFV3PL3Yeu8tHv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUBaxwf6Aw6qR9kdNAHvEb16fzBEBWaNzm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUC4TRdYw8icsLyxCqzuPWe6Mt4U7qZdNT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUCMhrzjCPYXhYk3itu17DFqBojaghF2GR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUCMoLMddbZ3yy94ioerGpZq5ox5xtzPfi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUCW4D34FRjdTxEXjKru5Q9RMPrSHijY2x", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUCWJnUAFeViFZteZGUvnKL6AHjsVLaiXh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUCbFnjNwYfugM29oh6syCMnpr68vXuQjN", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QUCi54UzVN1aFBC5eL24BhiyvyfAyN5ev7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUCiwPiJH6zPE1dsYSGd2Wt34xk9HK7Rpm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUDKyFTiB37rqYMB5yeUzk6TyRomXFFQaa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUDV8a6USxBSHF9LvrpYftvrtSYF3nyKE7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUDX6t1dqLd1cmMgm3opGjxJN9hXrMtYMT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUDttGuVqbfqYqgCpjmY7RVbJUKhRC9fR7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUE13TomQ9Eh3mKfz3UWjUKepLELQPykja", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUEPJDPWDVXALG3PAJwZMc7GDDsrJhjQsm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUESYNjnCz4LZev9QRxfcgsGvoDwLxgXeD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUEtM4M4RWpHEWPvxyP2CjbngNbyJm2MN3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUFGbE3oa91wUWrb2iYsTQF5Qy5AdiNaa1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUFMHr6CyFkevdzAxMTHkJiLrzVfCEow4U", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUFbDZWSt7yPrU5Kj7eaMzAhemmNDNxuhs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUFuqitcZJhv8XxDc7DKf8xxBRe7vSYFJn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUFxFTizgh2FkWo9YaWg6Ub19NxabMz73P", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUGUcisV2CAUpmLNKCebLJzdF2hHu888im", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUGe6edQ3cMoeePY642KK9U8kTgt7UAXSQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUGo9SErgc6ceB5aBzcSJDNqBkQ9eaCKZS", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUGv6f5DmXhuM7Ty59AAvufs3ntcMW8LJw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUH3qL8iEjmVfVrmLdPrmFmBtuggSxUXiA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUHMf2dcQb1DpBzkMWVjzudbbDMG46MkCj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUHWPAwfHK2fJztKeP2EAKX1zpY9zLuoZC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUHrKMnQWw7pYBD38pMzRGhFE3W1bBy66J", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUJC8SZ1aEyZpBuX4snssotsuReNSiCyA2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUJHX2DYeHqNwyzqhWVC5MRwFi3Sk4ZAKN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUJMK2rZmv6esUsaVN2G2T8s7FbXewDCDK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUJQv4pUsGGzT27Ftcujhf1gBb45sdXZZS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUJVUmoq8eFqKsQhNKcp91pSxXgJbZpLq3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUJtW875R6zFTSF6s2zTE1orhrGcw9HUFE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUK3nERcLLV5vbg9KZL4tHYWEyZ5KeEDM1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUKCv7fGi1vuupxYtGWFhFzcNMms5rGukx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUKKwug9PNai3DBggXUXP8Ag7WmR5SVUR4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUKThV9GQMi19qmm6FHcoQWu1DahU6pUxo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUKjh3UXVvncEhc96xQAE9Kp1wmxKAGJLH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUL13v9zSeEGBJ7kZQQHsJ7MCywENQDmms", "assetId": 0, "balance": "0.00000015" }, + { "address": "QULPYq6HrrL6fSYjBwRCQ15twesjkWmVC6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QULWo6NQtbiUj2wfNHDiARNM6rML6toAf3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QULp5DgYRgThcev52YaofjeVD1rgWxtfGs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QULrGxjK2qiJUpbVbi56uz5ZBKiVHhotdY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QULrPRAnExPHsH3eCCC9xYy6o6H1hPm4bM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QULuNd4DQvrVNjpGZJqRqKnyyD5W4FDBc5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUM2AQZds2cD6NhTtv4nkiCWp42tWuawap", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUMAZPieYbgNA8FzEeasKNowENbeRQayhA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUMDNsgRqF5WRNd9zAwWAM8WjTBmKzVgAe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUMXzvTWDKmvZvjEUH6mcbzMKTFsPBgtmW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUMpZPoRhZqyrEazN6YnWVnGF3Nhs3t93m", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUNYcKorTAjcFEFH2kLuGzTHDSXHbTm9n4", "assetId": 0, "balance": "0.00000002" }, + { "address": "QUNmxY1RtAJjSaYihBnVf3y6koLmeKbDiQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUNwUahKvZz7D5p6tVFQmPPxuUiT9x2gco", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUP1DLFob9qbwHtXj4ahRDaR6cACMTvzR4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUP1ybxjoQTNhP6MAgap37LFXfy5jRmCDJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUP33rSX4Wy39qczGup2e3SymENSqkwsep", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUPBwXRskqPPE1JcfzGjfxSeuAGrd6Wj3K", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUPr2o6WL3tJDV1D6KVjqkpc7LDGZVP6ZS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUQ18k14qRj2mFxaN2nTdHcZzJw5UXgKos", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUQ2CzKic7AjT6ZfFXub58BEgvzk6ZLe3J", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUQ6TFuVyCtzESkNntKQkrU5zQekFZnssi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUQDycF8QQS64gMPjFbuBXonG8QrhpqbRP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUQKGX9d81AVg6DVvvSwMSuEEPMnuNSMQY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUQPCtAVByEBpgUwWfwB26ySyfAnwjtDJp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUQdkr2SVCBcDTXVseG7MuZshQxSwyGZB2", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUR21D9ASqj7ZtYy5JFpGGTeWPrKzRUhLT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUR3ajnbusJJ9QmbvWLyfMoFUTg9N7wmbB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUR3kwp241T5JLUekBf56cuw6sxCyhieRR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QURDJJH9bbHHzBQAtATT9aZrRw5D38P33E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QURKQuV621Ac7DcMETKiAjEyYf8BeXsRsT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QURfgocd5eeZkjueVwTGNeVEgeUQbVVUqD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QURoSHnZ8VLzBRGYqXAFcw9e5UsxCn2NCk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUSNoDERaCSh81vKhVYBw6WcFadM2Y1UkM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUSipw5rVAfDH9EKda1Z9gzmtcTs5c7rsa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUTM1cfWdFFehQx2MdNENSqZKh1aqR4Z7K", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUTymkq9aYKC28xbBTXk2ZsZE1hqbnKwPy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUUADCLkQvajxM1QPwyxxhpyioqbAfBZVp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUUFZRgeqC3Bh8ZfRHucEws4N8e5WjdVqE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUUL1HKrivXBGRfApVPdd3DMsoqciH1LtJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUUR6S1URHDCxRkun8s6H9VMma9Mt2gxtv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUUXsKC1eAYe7pPtoMc4DC6PfbrMChwENf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUUfSGXPwZGKJUvYSMw5xauik9Sg5gEeDe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUUjRqufL6vanBWknFqDdvF1HS9Aexgjgj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUVKn1iyjQvzkndq3qkdGLq9NUSpo3jYQb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUVYHeRqKiMy8t2n3SKvdrGkr5apnoTvD6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUVhKxFX1AUHKBkLmSVJ5M7pQQWtJzJ9QJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUW9Nu4ArS4hmMGJJR1kgaY1xKyiZ13mhV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUWHhkMa7wphtWzHtbcU84DAhRfbbvk3W8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUWWNrYtqQ99XxGnWFK7aEm4XSjTxxdCbX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUWjyb8QUjfjHUJPPUQ6jWvjrLgK2GPyPJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUWndSmWtVttq31vw62NMVzY14bW52Vibs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUWusoY3fjr6t1LgkswWWHjxzBDHkw3jmG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUX6cNfQDAPHKzuwGiFvcMBHwVvuxi2X2k", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUXBULxwvFXA9T9qgm4rnhRT6ayuKcb8Sd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUXRtWwkNrH2u1Qz72UAYNYgnbWNcTnwFE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUXSVLkrCwGUV4TyKt59fQrqAN5DsLdjcJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUXUyQoP8dn88xdQP3qEb9Bis8cTvswCiH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUXga5K8nzd9EqYtvEesZWEYuA688h6D3d", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUXknMAwCkRW7YFRnNvRZkjA5rCu4rriC4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUXqBSukt3Lmp8qBdCMtaM2P4qFGTBarCw", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUXzWEVm3KX99hBCGGR2yVuNS3T4mNUXmU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUY9xQy2nybWj5774yZeVXQQYNnJYAoUse", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUYF2HhJF1tF4avi5xByPwwWhguHYXXLWL", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QUYfELYYXqHxEELSfDixQUL6ZqxvgqCtxE", "assetId": 0, "balance": "0.00019378" }, + { "address": "QUYk3CEfBT3AeyKunymwuSiw878WQR5vZm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUYsNRGdYKDG4pZgDPQD9fgmJQwCzvXRpz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUZCxNDBcv74PfrP9dXk1SbEsaQnKdb2Nd", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUZJjzDLGQW6L3BEoUuMuxF5JittZtLZHi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUZQPWhrxpze32vGiux6wa85kg9iwuhCDx", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUZWgRftekmZUvodtJFNJHShrFLU6A9dup", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUZbDDgPXNWhqtCJkqXkEkg5eZz1PsX3Yx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUZfxwxbZ8hgUmfmgYrQWz8rP5RRyHZ6Kf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUZoFp2UdWaibHBTkF8wEWcEHBo5SCVnsg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUa5N5tL4Ux4Cr8fX9Tg6wZwUm57CwFXrE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUaH6kB6Jk5mZfsFpdyKvYzFA12j4g2Bss", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QUaHYoCzdJyQy76J6hu9a3VN3JBaPGQNSm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUadEDPLWLgwnj2NbNsGL8K2cpTonDDJ3E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUarL6gRit97V8DFdxJY6kfVnZwyb78tcq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUbNcLwDUYPUsowp8hQbPUyzMgjBhaP49Z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUbWzfn2TyFNvAutuhuxuqeaTpAPPQ7VfS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUby9qqHxaVVbmatNW7T7eBbj2zCJ26fHd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUc42UArSyML6ykQQDXGWHY8jQjc2ZC7f7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUc9E3PqMNr1ysYNUp2EAL7KN9kC389GLb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUcLUJswKAowWeMWoLxvNHdyphnToMbVMR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUcLX1JPhzmC66PRoNYLEwmYNoHHRjZspr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUcPCtLb4Nd4Xx1DZjxw9RYkK6jdPzdbAj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUd4XPxNAgS1QeeHxDuJ8VcJKL6rdyPkV1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUdRWUS4JRDrVoyQbzwiehpQfC4fkFGva4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUdXcCmvfD5B2Y7Sax8tkWKDALEyGfC9eK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUdjqijDoyc83K4WcMW1sCn7zLd2t1WTqn", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUdnJT4RJetbgaNLSKrpWj5qKSMDLDKuZV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUdwsX74vBV9sWjgeSrWPmUnu86KDJavRM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUe62JQ34bUfAeCw67SK5Ziw4noHspz8Pc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUeAKnwbrc2k4q45TqwiDx8Mjre7C2P9Vq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUetP8N2aMMNviAeVBVbaeX5aCJYM2VrRK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUfUYGZHGtByMPZ6MMFHJQN32kCRFaPYvX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUfjLxoj7ToYGKRWwortCoDW2wdqQsia2V", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUfqwkVkChp3NwguhfhJALpYkTfiMigYvU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUfsWvX6vjjTEaeFo3mQR7N3c43sM29L87", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUfuMaCeS5LFBrMn34x4H5PtXuYcumioHj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUfwcAo5mPo28ZSzgeWCQ3jHLp5j1ARXUh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUg8q9m8ayocbLac2vjWQ6ThSsSwbQUWng", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUgUMBsdauY9p7ahjkEkxPnH51vqZ6fEit", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QUgUhTd8h7p8HwUujnYtBwBdjDfTrUyDin", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUgcGYoTz61SmN4FLd2xERHpd59w2cUDin", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUgcNmdPWaYin6uurmevZRdFb85oYWWawz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUgzZmP4Sk9CWrKeiHvXjfTVZWez63HC7f", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUh68cxDrTPrYc1iSJJ9VgmQHwfgXQ6sxa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUhHUS3Zku4jhA2x9uWAGcTA3inSWWAeA1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUifdqP9qTii6fsDcUWZAdCMGsuxw4X6vw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUiffVfsV948216HMtJZ1BqvLGyyj4r6hd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUjPGTb4gmsMgHwkg5Wj6ge4gwUL17Z7Qa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUjRmAAoZ218UW8b8YFbAFpA9SGnQTLiMe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUjcZYLfVsmJdB57w4rbvPKuSe26hoX8nA", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QUke7cJWhYiyT4mcFhA2xsiUWAHqgSbWtH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUmKLkNVPWGvHh7ww6MpGBJGwK6i2dknku", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUmQqngPdz9ksJMc7J6pU2Ampr2WtcVa3Y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUn61bVN9kNMtkvWSEzTpKSeGCmFBSStgA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUnSoYcBz5KguWSTchX74NxN8duUac6sNg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUnWgh2ur6WsTQX1HV9REzAa9sDW9fcnSM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUnzY85gwqRYWub27npfuuFWUWWzxNmEiG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUo3i1Ae9apv8muRUZuKaz7oTbRdKDWKgd", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUo9NrzxzFGkFQjtRP7fm4zCHvKnSpNJDN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUoJDPAYyVVTVLehGxNTo4ARwfMLS8AVCm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUoTNVWBSkaVZQ1U6eG8eQQ9pS8nbwuBHc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUon9BuHPfvwS74tju9apvSioPGRh2R9f2", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUoq1zJkWNruHfUdPVnbgGJ9NU5Up9YmAm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUp3VaYf1dofmDYPcjdkdkwChW2FW4Fcsy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUpFfqanV7BLKKK5mBnAkLkdAKcKTSrdk7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUpU2rqnDULv23T8RptHC8kA3jTbGBEn1R", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUpWmq6xEdZaR7RKiwdQGTfwA3dFw2VQbq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUq56owCN7Dr2Z3to1zeLBZRTBWQAdbA38", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUqGiVHeESGurSFbsFcuXE377TBFDiGWqD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUqJ2QNRuhGetqAT5RbYPJMhDhU3eVpbmD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUqTadd76F3xHyugdyyYWUedZn3pkdvNcg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUqo6NRF1EPCSx4Vm5syoiZYzohYQTRoZW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUrKUMoPdfgjdpfiT6Ec6Cn9RaLWoqHEbk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUrSAaXt2G4P3pLwzeifmjP2U22KiCNDbP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUrUkSZha2QkRiWVHyvwLieXzoBaEMkpvU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUrtaFdBvTGLjDRAfKsLXoj4fULbZxB27P", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUspK6yj1QXGZ7E4X3qY49a9gotNzoFjzR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUt4pPZnFH3Sd1NhQNg5CEbKhGZHceTqNb", "assetId": 0, "balance": "0.00000655" }, + { "address": "QUtL8J71psKHE2FmkpweRecjrjw7PeudkH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUtPtrwpGwYhTZsYpt9TDSF6uxLpBo7UX5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUtqFkbBtdouptqQp3yu3k29JvteeFqmsU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUty96dGUW3LKrXAtNGhEqBm5QQgrJCZgB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUuErgt1opHsHMki81zpMXm9Bon27UtnjW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUuMqPmdKPuurkzXqLcjuXqfhdLVKmZC4v", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUuNWrcSCxbQbv9BaP9oMN5tUM5g5aL7ET", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUuRP5UgBQjNtL9efrUUab8CjesrdtL4qg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUuU3pjmsKtgGduteW2xjg3Qd8BobRDrqA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUus64jCSSsgQSrN75zvEkcFzCixTKWwJx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUv1TiSLCVjMg5rzKkTCcBqH1T8vjfGWeU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUvAwEaJjjLfTcKJoENjL2SyHwLcUhoMrk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUvTHfkdBtTECjauYzQMVEzK6Wb2PSrJpn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUvb2AYY7MjQ7RTZhtmTWMb8J7UGG6ahFr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUvoLFfkuVuRe1KGMLQS4nUHry6CBTuTYz", "assetId": 0, "balance": "0.00000001" }, + { "address": "QUvtYEENi8wPXqCE2kereZaNxNgXrVivYr", "assetId": 0, "balance": "0.00000988" }, + { "address": "QUw11tpoaCGqYvXdNoLE67vbTaRGvkAP8i", "assetId": 0, "balance": "0.00000655" }, + { "address": "QUw4XEZP3bpKKYq39PNnwGHiogtDZ27ky6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUwdTXDoZ5BPMeW53e2epqV987jWej2Nk6", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QUweGPffTkHCJGpxAaucVEUzmC7E3eTzHL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUwf2aFsfB2yhcQewGrYLGg7XncJ2CGnur", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUwu99mmTMMnwxsR53wQaWoHvBfLEBM37o", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUwzXQ93ye16sJsMPJycbSNDFVajURUQno", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUxKVvrnKnyVq36pFUVatRQHKo1NKdNDGa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUxX2mXeYkmfWPPzZN3vQi5pUjDYtD8RJg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUxh6PNsKhwJ12qGaM3AC1xZjwxy4hk1RG", "assetId": 0, "balance": "0.00000011" }, + { "address": "QUxhxUJkiaNw6tBDc98CketuFkZJG2bJho", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUxm5uxD4dB6HgFfzLzrqF94Ssjt9JvNJi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUxxuGuZX141B6ZzDds6oojPHGqEM3cPNV", "assetId": 0, "balance": "0.00000002" }, + { "address": "QUyFgzc2byMyEZjsZe94yLSsqCqBAXcxnU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUyPPuLt2p1CwmofPfkSfbNgrtTe3r87w1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUySWV2GfDgbZXmBj1buo9Ne7uAAZpkhxV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUybyQBLZYsdtAu6YfhonyyPsCL9kWkNgW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUyegTgFUpBdNHpKMr1h75BhMnH9qpJ323", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QUyjyu6ymk7PqCiq28mHdo9xSC5F9Ejnik", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUyu1kpEL9EYGYQTtByYbu72xbjfTADDBg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUyvkqoRU82NNGm6Tf3yysVqgk9YoucRRc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QUzUCfoakDqBaL5zBgfvTKLHcuxbUfB38Q", "assetId": 0, "balance": "0.00000002" }, + { "address": "QUzr8KuPmSEre34fjC5wFz7hqNU5gNm1Dg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV15ijWauSCUPdeb1N8NG97bUmFE6cZGoK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV2HChYd7opM1r6oYaX7KA5VUoKdiUuagg", "assetId": 0, "balance": "0.00000001" }, + { "address": "QV2QDuf5qJLH3rVaTdorjNBU3yptX3n4bv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV2Z4mvoQynFuaHF9CLndJvZyNZGGjTRNW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV2qQikeaYqmqFUx3u8mzA4jiLJVEadzMt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV2yQXdrbadkVSZcXkb8q7yS2Gz3fmPgMm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV4496JU7VU9hwfZBwwprEGUv2d1RedQWz", "assetId": 0, "balance": "0.00000988" }, + { "address": "QV4Ks8dHLxqrBegNpAu4aLVLQSM6YNyZer", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV4XcVGmeSZeMxhB9fp7r2oMUGFizyNHPh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV4fBEPcvZiX6FSDsrobSZv6KckhrmB7Jk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV4jBKrRyhHP4p7QP9bH1sBPe3i8jdsW5i", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV4tCjiDUEpFGoTd6HZLZoVdDcPDu5n2vQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV55Mf7covc9K29jTDABNRtGMfGpWmmYVS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV5KiWQFQJvZy8tff2MeaCjDRedzhYzEPD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV5MMqrsjKL8A317HUPoMbFfQat8wdP7JL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV5QX3UDXsmuLY4vAXe3tc7RHY2pogp4dt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV5hLSYFt3KDJka3KYxjbeqDT2UgUzrsZ6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV5pyp2arnEMuFiU9aw8Aam1JgNXt8Puim", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV5sCQAJuJ8eCqFkt9QsN9f4JrCPbgLwsv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV6Az1jXvf3TakJTSXabF1frg4AXi3jhpG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV6c3iF6gzXZivFs3jcCouc4RVdbPifwy9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV71rshuBNZNHSpjvACSp4wDbTd3sCdYUs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV7A6MZJnQo4kr2SuPDiiZyo7Vf2rxUm8T", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV7YwRNweDQ1QazCGi3ZVwXQhj7UxMgYHv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV7bS2gnJnTzL38eD2YjNvBZFwQwe4Mw5U", "assetId": 0, "balance": "0.00000988" }, + { "address": "QV7sSBUvyRbwvCkkwrmrBw4D52UKGHbqtT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV7yhNFRChBSU1xxXrNvXnAvDjd5pCrU29", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV87piRYNCEGQDpPsYoVM12HLX8kbJUBDi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV8C8v4mLbmdyq9Ssi5PEwYFFaapBEjTfV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV8WS9812168PJkHhCKGPe8uUZrCVWxzhR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV8gEB7usg6r9juuKHSm2tV3FXt7Y5N9xS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV8t879zZRwsEww65m8bd1JP5kVq32SKgW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV9GJD6ae6La6b1t7Gx9UYm8UtjZJ6kfRi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV9fTMkVQbMyQo4BoYA7bnLc8tpT9CsRr3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV9h7tUj24CyNwzS5cQyqXMdwqVGqe8whP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QV9mZyMMZKGFCegpkguung53z9ot5HKzfg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QV9z7zEMSpBUCxYkeGkWt3JH8HHZj9hYHt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVA3VtN9yYQDuptoTRCXoPDtvuwgW4pjH6", "assetId": 0, "balance": "-0.00001457" }, + { "address": "QVAdfCVwhZ36YkK2sD8JcufzKX8EhWtUzG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVBCK12WrfphiseiLa4eSFE2v5Lq7NkADS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVBKp4XYmHUbjTgA86tiAgaxTGSYMfHbk6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVBWwms8Goc9ruUSPFtBt48b36uzQmKVdo", "assetId": 0, "balance": "0.00000988" }, + { "address": "QVBYR422jYktNU4gSYDRWi3YdBLyuyWByG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVCEjQ7eZbueSgyWdBTj6rvvw3tzNRAxid", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVCPJVK8sq6nunkneRpZZtBai82gQFeRBf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVCSpYkETetJR54BARzPj42dAe9KnEvwDW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVCaA6u863fAdRigaJaYXnVnFTEqaYE4XC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVCi5v2pPRCfPcd6DLdoNukZFj9kc7vrTT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVCtxreiM9y43ZhbhoEeCcCH7FNHbuEuNe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVCyqbDQQC491jmugV1siV7b7uhDGVUimZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVDJ9FfpLka3DM5thgxkGKZrwpENveTLnT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVDXpHGtMnLf2fzHhWTV3CmYnkdgNuLc3k", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVE2UfV3dhHve5aY8NUHuW1BiwhVQUUbFG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVE2uqmbmojFudfKFFi8tBioQipkQLyd4U", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVEbzXy9W7JLEpiFdvsmgK8cxQRqfcaAXd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVEhKs5SQ7FB4Yi1wfnNdGa1hVkfk948Ee", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVFCKs9ettaPKgVsZRKXbR54pF7d3aPSiy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVFcV5SJUSyBQtYKKSXHbqdqhFABTBHakE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVFuQNSVUSpGbueaRM7afmnmMod3MgDWoU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVG8ucfqMiZyLhQ6zBU3St6hKXK5q4aXd3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVGT1zc7CznpxBm1nwu6e3XaFNEgUch7dN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVGVJikpMRN4w5W4CCTTeagAwrXEoEsWEX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVGbpUc42RWQC6sgQrP6aCHsJ3nSi5BWJu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVHCZdWX2CeS7fcWaypqzeJDK61fK3scWQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVHPRoZ8uYjubv26sCZWYhSyDdrPQQAzSV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVHQrP6zqa429PN357qx98m4ghnKT47ZeZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVHWbjbpjg9zPXfyET7Sjb7JA9BBMWL9Qr", "assetId": 0, "balance": "0.00000655" }, + { "address": "QVHm6ae7ApWesfaTEjqfMB7Pzbf8kFKDfx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVJgU8TWUMKg9hTh7GqW9SCQURUrhsFZRU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVJjo9DURUtMUkJCKh31gMV6CdR8KeSotV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVJu8ikGvVX7WkJ15N1FJPbznhDu9mXidY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVJxGy3KzSmxBUGkztP7fBN8iJubnW7HoZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVKVwtqFAxon46HupW9VYdA44KFhsrZVJH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVKWuzNtDCTk1Qqn3PisSzKP6HFrjXqBBx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVKrCsT4MYqHRa3yhSFq6ANoiR1GnHAym2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVKzn6T7bx88bjbdajmFdFV5JkdjcWJaLk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVLMJA7f4DEw6T8TohgW4LgvNaAmQiziB9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVLPVFZm4PLALVAnwv2mvrKpuoafmDBjjo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVLQaQoqFg4yNstMV9NWLMGXwgJD9ML8TV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVLWZ9iWiY92oua6grr5SySUxEdLUAzPkq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVLWb39SnxxJAKxGP48SjpSKPnoKqaG95i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVLXo16NZchTpQz52E5NPE47MWJsEiHWN5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVLYoxcqNCASTtMcic6iVbNBLMvtVRMKZe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVLgonR7fysxHxy5QuqeG3siUUt21eGsDc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVLuvt9krmxXwQPAeAhxzhuMF5i8F4aNs8", "assetId": 0, "balance": "0.00000002" }, + { "address": "QVMNqeYcChWGouMMEUwN2GFuMLYvtcASVq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVMPJ76rGbBynYVFMgLG68eLGp1zSNeVwA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVMQBBLsqRzpC3Xwx7hVeCCQ6W3aisYrjU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVMapF99hAvGyi2j2iiWNMAKr11pBg2FUa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVMdwh3FzcxCZRZWE7xuy2LVdcputMf7oL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVMrghJ7pgebkgtZqKSr6gYeXZpBoR2hRr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVN4boMk1GYKP5os8bXwhqaLGMA3fqP98B", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVNgopNUZUFgBnWDxeH9yEAPWbsjKGK3Sv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVNhtGXahRsbmfZJWNbNeKXHq6duFLDu7r", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVNnjfPxpCvmmWLBBjJmBebYQ6b6ZqvkU2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVNs6z7dnmuQA1Vnjes2hRt7S1kYnbQTQM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVP12vkwGUw5Z79iTXhNCDfG37Jx1o7fBr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVP6mFurusySpnKpBfv66j8XNv4a66UuVQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVPMKtfYjVcfUJm7gjPnC6XLcetqetmhJZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVPSkhxyjcPUDy27JznjjFW6AiN4Tp2QMq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVPadZQrSafvgQXdLfDsJ8fa7reZu2SVyG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVPfJvNVgB6qpmbFtDq54s39hnDbUnnbnh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVPgmKAN3WVSpvdWLth2szk6AxkFCEnpTp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVQ1mwQz6EqPJfzUNaPZun3TXXMAfkacDE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVQ6VeKyjZJwLvpDt9JaREAMGVLHcwHu6c", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVQeuwpN4ojcAw1sq7rGYKTVk8aHnkJqTA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVRJoDiKDs4GdtfaZ9VG4TxGDNieqQpMtN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVRjhPcexQvx8kVvuC2oxic9iRNAnwZsDs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVRpWNEGMefWUbrSCAZXjhxTxV322wa6mY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVS98Ujfq2AS7A6CNzwun5dy1Gr2Uv8rE1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVSAjQEJvm38oUFAuf8sBJk6bogpJHo4KM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVSG3fYQXRGr1s4L3xQAsuXKz5hkrttb47", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVSJBu84jpMyy2EqsGPqLjVtpvhenV8anQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVSTJSaTiYAKqyoCw18CrN6epr1x5rFzYJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVSVdqCwskneYYwJswurwhLXm41bK1U1tt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVSo56b2nsrEbWzi3FBkGQCLJNyk9b9j7a", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QVSp1MvSTBut7shWdddfwdsWf9c7snBDwS", "assetId": 0, "balance": "0.00001003" }, + { "address": "QVSqUrNFR4mPTMa7UdVmNKZTSaDVAv8XXF", "assetId": 0, "balance": "0.00000003" }, + { "address": "QVSyVmWneXZeVyvaxAui46cuKXwcXhaugN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVTnbvMhq2w7Vh8X6ENnVuEtGtPZYK5T4V", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVTpnfN413ZPJ5SUNw6YSG9yncEP2Aw7yf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVU9kPQkym3QWqjhj3cPeo8wxxow65XFw1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVUPhF94wGasGYPMnPvpCcgqfqN3o6Azy3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVUWNBUDaPLGgsfEbCzAK7KSju3MKHjzbd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVUipEhr4s6aHZeYkNQqFdMBq9hSp162oL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVUzABzqt2Lv2WL3Yt8fLiw8cZAALiRxbs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVVHezpAkbST2pUwCpKXzHnJmez5KA3F5S", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVVTNQpKWmtPThW4sfSjbmX82wNZkfxytf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVVTc3UVLiTePVjirBCyQjzcAjk38rkvhM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVVkNjBvRKxbJoGMBXhmuDqdSTaHD6SfkC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVVwgPkV9VJrTL3XPfjiFi4WxDjhRqTTkj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVWiWLta9iYFUt1VqEAycVhL6Xf4aQNw69", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVWjDXR9NLH4o79emrDKbDmB4CsvHrrvPE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVWjZvoNmeB9Li2L6w1XBBmNGzE5MotCs3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVWkVq3pNRWMnSHeJbK8FqohSTXntMpqct", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVX2UBZcstvhDGZEkqFuC64uRhUGW6LEmi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVXEHUy1jYkHKz7Ak2mgkTZiSFDm4JXr1j", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVXZ4CJiKjMMpJpwjqXrt9raEP4CK2PiAC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVXjTuL3m3rBZjMso1ZKqrubHAAXRhXHr9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVXkfyhpDGVX9JxGoncBPC4rLtgXrWS2cD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVYAhE4GyTBQD1HJiL3w8tBqCwHfp1dqww", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVYTxDKoHUvEPpVvVMEdVeXCsVxrfc9TqC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVYyVY9n9dyLKGWwoBe6YRFbczQe96ZXtR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVZG1kYPyf4Mt5qr17FR37LzBhoa8fCrfw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVZZtoA4TAZNktKxrSXPXBgHfhy3sgU61H", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVZhuCkxaPzgdgJD9gdaer3RFq1XCA8BPU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVZwgUNABhbMy4s1jTdrsgENHSqgAQjReo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVaTRrcZ5KuDdn7aAAyATsgiekuV4KTtZP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVaeUJbQHTamCcbULtSiiMFHsM2fqQunsy", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QVap8yDL5AsnzhNmemaVTfFfPvQsXHvQsF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVbRtMMSmEvvASZz6qRr9tq1sGYkw6W4zc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVbSXYN5wdKL5u5QZnJiYQgY9BeTGmfs7z", "assetId": 0, "balance": "0.00000655" }, + { "address": "QVbT848uGQQe3mwdcGfjyYWhLhtLVVvev8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVbrsMbZbV22B7T5FrEZgYP3pQJ96q1cA1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVbwBVxdvfDTTBpvm1ghVPQR5HGsVnu2rN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVc4oMkniTaAtubeJ2242pWUQv35Fq3nMB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVcHcp6BuQ5J9kMXnthxP2AEkxDNVETJ8P", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVcJ9GUSzzUwXS499tu9YVE7sJhZmYP8fN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVcd9uvqCWHpdZtdH9KgSQyhEhAj9nbA8Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVchuzuoTKxNi2aMhype6sS7HCRLhdDrvw", "assetId": 0, "balance": "0.00000652" }, + { "address": "QVcsrjLtpgv9msw65UK9hZoPb1GcseJsyf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVcxGEvFehJx21t3pkowMjjAuF5vkAMAAZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVd5EsjYUn8msqEQFv3hmUqPvwR7ekdFGf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVdUBb1q3G3t7kyCVzugwL1VsAUN5rqcJB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVdgF6mRfaoN6896UdHfC6PX8vq6Ygo6ph", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVeMWdDqDHHpJGAMkV838LC7sZ9bRhW3xB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVeSskDtxCQz7xj5GQcHrPgK5Kdtevjgc4", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QVedJeKofaikGChuT6PTN5r4aRbKtKFekF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVehBgZSpZrTVMVyY91WHNeUKeck72pjtT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVeqEdhXVLvNr3guGTHdqFRsc4dc94KZzD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVfEqcpp7nKijsnUUsEeevzG3faM1AdTuP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVfM6b6c2fpJLSZRUWuxaMf9MXVSrEqzyn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVfbMe9mRNhPoGnjrXTPajXLkV3AnaxSDz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVfmytVEedvYtzLRHoBd8z9XSPPH5P5qH6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVfukr9asvqNwPfEGMvn82gmPnPMQFpSAA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVg84LY7e14tbVxfgK8C4qNa9rP9XYenNY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVgFYrrQV9feh45kAT6DyBonxdiJxvpmzh", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QVgxjLkFV9CMzT9BxzkwFW6f1AvsrmXkRV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVhENvryj7tgR1oBMj2jJHWjhDP91XiHfW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVhyChmrwxmnFnJYqEebycXorkVyCkYzMo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVi5jjTjJNoUg9kXSKAQPzxNA3yYsKBnEE", "assetId": 0, "balance": "0.00000988" }, + { "address": "QVi5vWEUzJ5QDz7U74e6YXfYPQBbgKww4A", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QViKVZa3M3ar7RBRSBMTx8FdzLh1zxUhN8", "assetId": 0, "balance": "0.00000988" }, + { "address": "QViPTQGYNRXN7SQQEoNKvFnEW56X2sBqj8", "assetId": 0, "balance": "-0.00001850" }, + { "address": "QViVUeHX7yVaB8J3ghW4KFzD2f8WcwpXoP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVifRVyPyPGbh5QoVhA5d2qLLZoqh3nYY4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVioPMcFpTa73gwqckZwE49sSr9KdR1RZF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVj1Gkb9295M2SnvZHtvjCxoPFt122RK7C", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVj3oxmQt8WkKirPnnQHtpoRi1bRq4P38S", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVjU7MKJeavXbmsqWJZfapWSX4zKaiQykm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVkD2RCCbur61PowzZj98gQKyDFKnzuMZx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVkKqaRSHerZxB5wgf9KNAq2ZgLBhrwyzq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVkrm369omdaD1CLft568vezdoZenxmWsZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVm1bM7YDBYw4VkdtZzzBMUWs7hSHFxpL7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVmM29R53oWaAPe7kiZ2XyKSNvvcuHiMkF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVmeQEyyKfXxoUFfshJJZHeQZjn4frVCnK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVnFsmbRS1pLggoHffGKsxSUMCHM6PKJWB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVnHHnf5ZPBpbLZQabtjZBzi9TPgtqABqc", "assetId": 0, "balance": "0.00000655" }, + { "address": "QVnRFyMQpc8FfKEHAaMH9TBfzUxpQqTGN9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVnVdD3DBm4wAHuL97aSNzbCNcBYG9P9Zk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVngEppBG7DE1vhvivn48LyKYMLz3n15Ho", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVo5wuxofwD4QHHUcUZyCggXdZmyg6Brxw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVoC1zsSqy2dgQnSK9iLYzRGr7wM2avJMT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVosPrE8jw3ZNMEmNV47mTp9wyyNwAg3xA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVp4qvaMipZ2oRFye183Kt3xBpegngcuUY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVpDkpsD2hkvUTY42wWwEKDeBMstEa19WA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVpMT1EgdYUznGTZyvT3QUtKBrTaM4iQXX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVpVKaXziXmP8qawtxqaFN8mHFAqvuiWzY", "assetId": 0, "balance": "0.00000988" }, + { "address": "QVpWerw9mycSdaxHWYuJiaawvdDJxdzWcd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVq9CmzK5VGACVKxQuAA9npQ6zatdYVeiX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVqGbKkEyhHnya6bdiMAXSEfBvtRZWoQoQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVqN7Xs6cjSq5PFogXntA9MCLuc818VnT7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVqbyMjsyD8AXVhB8DV2PDFRxDLP5kaz4o", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVrXC7m6HB4fFmb8RMp9soG9hiD8F9pxA7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVrntdr16sG8msw4Be35geE3SmVtysTkME", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVrvy4ac2jBTfxyCKB7MLimqJooTDBApmS", "assetId": 0, "balance": "0.00000988" }, + { "address": "QVrwkHVNp9fL6FTJTZsSB18NecuyKfWt7s", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVsdDGK8ioJNEwMPz34bwKjCkm9L9FBooT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVsktTHzfgGmh8sj4w3PZhwV4YpnoGqUdk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVsr7b2ix5HD66hiAhSYRQt4x6Gg8RMiEf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVtAXU4NpHqjD9mUZWDaUbSvLkn89Xj71t", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVtGGyaxasN44QBkGCJu4W4Muaq45fs3HA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVtbb5pTrSQmY1V6vXdmYkDPZszZbGdmBA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVu9nMvBbyCySrZqRQsdLQAnL8zqzCkVyY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVu9oYVYdJgoN4ZkDYPCzSMuktL2yMtt6a", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVuCU8KXMHbWsqNx9pwvDub3cjKYs9q1tQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVuEje9jTke4TRGH8FAHJweiBV6FcxXbzE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVuQE3bRPwt2hFdtfRKMp6os2gKogmy1AW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVuQdkR6tjQJrvdDvDRyZXwvxsXfaWNn5T", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVuT39VGiUHHrDSX25qXt4u91iy8kxn698", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVuksgNt3QAr7KCrkxtE5FWrczfgLKxs4H", "assetId": 0, "balance": "0.00000988" }, + { "address": "QVurebcEbe4USR4xcS3Mbk12mhxsjRX31u", "assetId": 0, "balance": "0.00000010" }, + { "address": "QVuzEEB4uKPpk23941csesUSmTiiPx2wvY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVv2pF4tuLtVPWjHgKfRw4rSpEPmrokCSV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVv3EcnAc3NeFzwsNrjAMy9opHC8GbqRYu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVvDyvFnBimk6C9kJdbLEZCeWRqEKMKMQu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVvKdKpBhyU754VSvw1snoRqrt6Ub1vv7x", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVvZZmTpxrzstuZBw2r5NWuDkuAKA2aG6N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVvfpYEDjzJRs9QSZ2YnaVNFiWQMLk8uV4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVvv3uYXsq1Fwu5iGm13ShvtQN7jDwqmDF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVwMo71iFyzKMsZg79NfWTnQCMbFDr5Jrz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVwP6Jygmr8wh6CjtzPUTtVQDMHVh51GUE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVwRvmwnWnWWtE5T4pZP9ToJogo9d7khcc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVwfnuCESfQNvDbMFrxP7Rtvwnxmu9dFAZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVxb6LwmDZXKa8oriSFnBytAornaD9pYoh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVxcXyGPTpf6cZpKBAvx6AH6oTmcwLKJJA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVy16xE95KFBQmRcJUqKPA9YEHfFJNgMij", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVy79mrkMzWp8SVX87YuyqR3yg7UUdzipY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVyHhL283dVoifxDnd4S6AosKfYHo8GDab", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVz5T14dGrh13VuzCZCy6EnCmEXCidUC58", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVzTsJm7fucxm2LwPDm7jqTHaFuYFySGjJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QVzdX8Qmk1gau6UWipThfKVkuWibwJNJJD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QVzgbBqJdjEajPzRb6tHZS9tmC9TarcJ3D", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW1AmdaKYBXnJHM8vzF1NvoXaW7vLD65UX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW1Qpo5KgScd23ntnB9YgweEBgWCSMduzm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW1h4ejB3jcRjZT3ruBSekGDzc5febzZzR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW1ip98ypmMmcSRjCRkS7Jd1SfneQbU7fq", "assetId": 0, "balance": "0.00000652" }, + { "address": "QW1jC8XYM3xiPTUKDFmi1GKVr9sQ63NxqA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW1oHjbdM16skv4ohzewJ58Cqxsg8XHmUN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW2RJna33g7qG8qLSasUBmWheNBFMQt6qJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW2op8DDxMSDBaZaX2Sob5fbHbHJLWvSFc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW2v8Dk1jsF644sMP9eVTm4mUvgx3JRWU3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW3PP4G5jZZ7vPyprBN9wrk4QfZ9KyA6hr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW3TpBLGEKMnyd23A9712cvpiiWrTJmZaX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW3Z4SgM9pNkc7owZMPLaGi6maQVD5vwLY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW3oGrC1U2cQtwYBCwPq5XhCgkjV6tMHJU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW47fHWNHhF9uJc6KtQJWkNfnchsMrtC5X", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW4XGij4zevmoMXGRNt6LaaHQ1UHN7WfV2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW4XLRN3qQ1pd3rQ3r3GW28MzrFKMXhFSR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW4hnT66UEMqjLbaLAY9TX2FvzB8jk5BxH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW4qqQDmKpASqowM4MFeYUf48gYYmvAk19", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW4swDCvVQkWpntHdmbA8H8WTMDZKDigpj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW4w7dsf17wrnmnUjcomh7CE4Swy4Khv8t", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW5153AtTuRrUXwQRSkhAN9b8rc7LQCMAG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW57reHGfHNPbGScsZ3t97ypMvHffbcWAj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW5NKdmGZKRxFfWAG4LnuAKjx7LBfPvbHM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW5PKhSqgZhy4NUEZbTKDh6msKVBTHHZCF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW5e4zTHTDhgSQfeYu1xBgMCHrdrDwYPR8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW5r8gEf5JfDiYAmCEYjUHhJ2oouiWje5A", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW5zt2MonPxjq98FLzcQZF1Sawdz27k5es", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW61XVn464JBC5XfzQYKNDhTb2vUZwiYip", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW7Q4jGyAKDB6DKzxbZpYcAEE9k4xZVM6w", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW7f9AvBUTSkfb2hjrBRJn1y6mXf6pD2i9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW7w2xBLucZku7AxcmExbCnp2tKDEzSre2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW85aXJfpnUzcQ6sg3wp4jigEPwHxrvz9q", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW8Th8rVnDhMdWJTP61Sx61paYUHAc3heL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW8fDKbGQ7JoHeCPJF23E9TihhMiWCMVNo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW8fpprFMPgjzLjHHjQyHTygAF1J4YVGEY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW9Ky7p4JTVjFmDPN8d8GeBMuHMzSneL1h", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW9XZQcJMd3u6V2eCADAmeoxQ7pRdVSHVp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW9gFPrRPfzJATHeMekw7nV86Q6eZv7oiw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW9iuMkZWr7ujo7bi4V8JX6icMg8KiRQaR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QW9pTsuFyu1vddydGyAW2iSQg4wbtE6oXD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QW9vDUk1WmJUthMzG7MgnBwN8DFTMgLqxd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWA4vT2psnHgN6QxmjyX7FPm1ypipoSX8X", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWAQqy5MDfpyE54xq1f1Tv8GSNGejZ8WUd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWASPS41iJTSf2wpfVYCeaVdYX5q4CgVDZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWAh2Q5wFfcAQDzn4K5StaTtmHeNEF7ADM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWAqhaGWprdhFyPCNHeyNzdBH6RTCz1P84", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWAvoazXrj6nE4djg9paHBTmjC8httSKhZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWB7s1R8hxqqi8rSHatL1ujCv3zVpVmNU7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWBFK5h61ZxGfqQpEkwwKTcLAo8t9VWe4K", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWBnvu274WpAYbiKJJhHB7EpQ2c4X7PzXD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWBoC1EQ341y7AWss9kBkiuTKPNGkgmvJv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWBrxCkBSMNaL5ssPEawjfP9qUdurrFmP3", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWBtd3kPsovkvXewKXwVemNtVtTnFe1A3S", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWC7MydcEFhjENmCS2YABKY5F5BQd8XYyA", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWCQfGj1rUDe3ndwWTMxtAzJmzZqNESFfv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWCRntcZXepXf5RntgchW3soaDmd6Hkzgq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWCUzDT6sYPqLTytt7wgGhBGjN31c4BBNb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWCczmcJ684o3MycoghQijbrPVa1GcWiZE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWCw13199hKgz8Txu8PazBZgu1V4ZdHDQC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWD6yinhpYXhpqMuZhT2A9CS1PM4voqdpK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWDGDv1tiGTN8PcyzdjL5WwXys6wxnzV3L", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWDTZWtxThuSR5vSiUZLzycLuzDBpqzySD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWDh2D49EsmyoDaW8Tnt2cf2h28S9Kmp5w", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWDwpSonM4M8WKzTzGpibKggJ6iEvvnng8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWEFJUQMkRKWN3i6G5jkVY8WDbttXnDUE1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWEHVc9paERLvJZLMNPq5xT5XnAqp79sZA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWEpxgEnHAPadNLtDMJ5ALdt5TaS8B81Vh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWErXURFoJi3HyGZcXzPrh7XUoisLVCjdx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWFHfqYNCYW9EN63zdprYSFQx2ApS6Hj2z", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWFKinj83ZZHj6icsGgU7Sq9WjUnxvk4aD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWFvX2mfhjzfCU4CenoJc58EixPuqZpSy6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWG5ZTtWJbzLeFYu3JfwBiybbJ5JrsSJCP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWGL3ZAriu3VMh2WzAgBYqg7TJPRbDCL3Y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWGNNvbticAwZW5payZ2FBHJ8zq9s2J5in", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWGShX6AWa7wSq4WDk76YTe18WM9YGDiME", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWGk3Ub7vNzcNte5Zs1jiSS2uLawwUNXjD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWGxhX7WimhyrQPUMhqDG7eDQhJrgJpm5W", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWHat4TN5mDAfQfefvyFFH7P3naRBFEJBP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWJLKxBkaHuoFXmT9ugHd1F4Vd1Kyk9gqS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWJgLxcGRyJuVGyVmFC9vGtesy5FqGDiJf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWJkvtor29UW73AdjvdZibj5JX8DE5gDJw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWJmrw3CyXnSXbLFzGrL695VXtD44qVqgX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWJte6mifzTrLghDqA11GToWj5ZubwdNMk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWK8NGdTnCJ22f5mxY9XMFypHchRWXAwYA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWK94e5PzrBN5gHrFd77dHeP5XtCiWxVj5", "assetId": 0, "balance": "0.00000652" }, + { "address": "QWKCNTj5zUR6tLesCjjadBaonJHdnZqAnA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWKM158sVoLkBw18tVDo3tsCG85LQ9628X", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWKMYhwMaV15GbLAd5VxgYX6Esr6cApd1g", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWKTEc6Pz5jmxPaJ5bN3CsSHHqG4LEcqEG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWKTJMxPWtZPr2t9oKwdPzs12dZkcxkiAF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWKYjxBUt2c6BHm26c4k7U8iF9eUEEAeQy", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWKgoecPFomm8ezG1kQb1qoVJM6WJrwiaG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWL7kZp6Pdd1bhxZ6SXPhVf5g7GParG9CC", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWLm1PfxTeJJhzCSmaBb1BeuJnkZKuKmgh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWLpsGYrkF2cy3tH6DCxso7kXZpZJvv13e", "assetId": 0, "balance": "0.00000002" }, + { "address": "QWMEPx9QfK4ErsHx4RwyoWL1cf4xqpBzXy", "assetId": 0, "balance": "-0.00002737" }, + { "address": "QWMnorELdRYb8E6HM6dgu7ggzjbpVT2HNF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWMnz21mUzga3vqH4BNizcFiVJQq62yLHY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWMveDdHA3xJ3pVqhg65ciK1uMuJDPUBnJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWN4qgyBfSn9TRTJM9e8ftzuAZmSuadmt5", "assetId": 0, "balance": "0.00000336" }, + { "address": "QWNBb2sYDrjSD4F6hqs6wiraskyzpNT1cY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWNHneeQGQbXEUSMwSzNgz7LjcEBQP5q1b", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWNQWqEaSEybicPzodMbP88aAZEn4jwYMc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWNszMqCwnFu1HmJ9iPm3Jd6u3RXu96KY1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWPKXA6rXCr9xwA9d5QNbLCq9nhmCQjLcL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWPuGwMvdE1XJxoxGFryqcVfNgPnFWJKxL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWR6NYuUe5L7Y1mKFkYq7UgnUKjyRnzSZy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWRMuDh7V5SdBDmDGqKeicZ91aSnQR5eyk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWRUSYethehdMvZ2pTWCCckdzEY8XoNQH9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWRbeTbMY8ZNxVcYzxng1sRNZv2PSJtjRN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWRty7GqocSS2uA7VfXzybqgR5J3LL9p9N", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWS3EtVcBFz9iFjeANx1hTNN8Pfxi2Ft6H", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWTTCF3FnNrc8MoEuYH5Ft6FtzT6QXhYjz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWTX7F48Tt7j4br2AN2Dyqk6wK1BDPmfCb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWUHjXxSyUhBcTbQbwLSQVzqcFuPZqeGkV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWUL8aEzQoAAgrnNGquXuLtENPS8cQXLvG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWUW8UgKrtzCEdbJKWQEq36oXPnWtMcdMo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWUjGtKV3Bz8bbnvNTCWB6rzgUaYmvG7aw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWWByHtj7Y8zL6A7usCRa6pDgu6r8MUgKx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWWFQX1zUd1f5C6Cmo5wKH99TKPW6MrNXC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWWTBrDhWgZexs4aS9ipzR52cZXact24Fk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWWrrDM8rsWJqQinHMyx7scmtYqAMY6QpC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWXPBtojp6d1hEimi3TX1zpUve1aoohbJo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWXiuF53SxZsiKQXvMdfDKQRPE22rEhvuR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWXka1pf2Sko2o7QWsC4B3gF6kbZRd6FKw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWXnwCZit9gfz6zeaRuEF9cZUFDxP6f2Uw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWY4NcmDx359YWYBftzVv8BmCXxXLCjQNm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWY8aU2E88rWFPQjifPhpngUVAbCEMBUH5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWYHrMupYxphh8itSk6c9J1rQ8eKVaeRba", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWYJdumd1gyjXy5z8B1HGjncfRGsfsDv3g", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWYYK7tGZBeSbFkYt2PQk1PWLcmt6SMrxu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWYYv5rihUiEzY3SHPjiEMG9PBKucpWUtd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWYtUSAojadqubqhbE5NEp4PVmFssCJ9AR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWYuFFFWbjsWqtg8L7uKPjVPfauoUYRBcG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWYxUJmR6M6tvyxZASux3pxWuq1iWqTPei", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QWZ2uhP1nG9QiBwWjauUpyUshAszXsMCXo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWZ3jQ4BtHLNj3hnLfGzJZVbYC8RGHqYpS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWZ6xcczeAg1EmdonPKLhZPLmhbawVcsgA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWZEpX5FGRgkVMsmDCzBqrg7qvq1968Bhj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWZNLfrSZibAdeT71RnTMPC8eEnQHY64cC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWausFyvuCYxx9aTrAN8riS5mGsXNLr4i4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWaxfRM1gj9eoTSJejbMj8MKivWGG1ZY32", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWb2JfSDSrtZMuXTbR3LZvLAYh5y3pdxFn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWb2Mo81ifUSTVtuPzdWJC9ZcvSgofm4mn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWb8NhsKVEnfM8NSMPdSWSdn1T4zkCDDFD", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QWbUf6CqSjiu9HMLtzLokaKQRnUNyn5HEj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWbaCuMEdsbnJv9hVSCCjztVcWd2MyZRkh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWbn94b3DaU69gAoAPqBvpQ9NaEVzXfnht", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWbpq9FdkynpEmQFeWeU883sbkNYMDwB1J", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWc4aPd2gdCHGsgr1Fr9bWzEHNsiGe8CtT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWcgaTFfxt1cZL7hn9G8ayo81WT13S5ECM", "assetId": 0, "balance": "0.00001003" }, + { "address": "QWcqcnuDeNpeUYqcvsJtXYdCpDb35ehAv6", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QWcrDfbZ7hn2JFJa8CqAT1hZXLG4nSZL7R", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWcsoV1cnTAki7T3mvaXx1zvkLoHAfNDEr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWd26Zxj59tDZDXAcqciboDfkY4tEGSWQm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWd56n78uQQVhYyiqg5tNZiVkyX1Cp8QzH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWd9nzAQCXyL3fPxXZmFprDeyD5yScxqKK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWdYau3B8P16wh7HxPw9WCz4ibf6VKrQBn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWe1iPDudLU189BggPykbH1DrAeaFEgX6W", "assetId": 0, "balance": "0.00000008" }, + { "address": "QWe9LK4FaKXUQnsfTrN7hZkXPZDacTYrrm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWeCZDFvDMvoxLAKU2W8Ky9J2P9An25dm2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWeSMgcuXcHadziPjneKgsskuD1B5y3nRq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWebysEh6mWDTBe3z6V8t2iCuRK4uJX1AC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWedcGNA6VMm93zeyXySNxFHgccRPo88rX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWejoo5EjyRAHeCo3Xbtb6iVokQZr8JeF3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWeqNbnGx8EXaNA9XADxZAbwU7bDmMNcNU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWez1VBfYVJ8KFoZ6MhJDzYVLbn5mr38VT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWezcmtSx9uqAiskW1jm3eLCMre9AGiLgs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWf114AEwV6hNn7adPGX1sBpwpDtXvjrZ8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWfBfSqW1cE3JG1UTbhsAYPEJvNgBZoqE8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWfc7ZGw2DP1ghK4KaFswXsNrGUH8mDRk7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWfebb2wo8iXduwQgVK7Yt89LCHUuT6Zeb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWfiYBymv2dZkEQEBUDLwcgQcBMLzW3wA4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWg73RrfiBFCzM3FQeVYnYjtUtUbWqEEaU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWgMGFBSAWN4GnnUmJu49ku1eWC32fwJEs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWgQStWc5x32LMCdoYywjHxY9BKqzn9rrr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWgRZDCKnMY4mVXT1v8J3aJpKF5dTRA3tM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWgSQuG62oDhzBMkPvmso3NjkSBCoDRaQ1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWgrvs4N2Am5XHKNnbFXe45yui6Si6qLLq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWh5yEJvwXBgMa6m29cXp7GmPD6KkXK93G", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWhFzTbwoFsaF1PopZcV35Em7RQibr73Kr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWhTasZBhBojqAgkZV48FnJGLoXXpRZqTq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWhTi7WgnHaPRbNTEEtMVU56HPdEMFMWJh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWhdR814RptHo1V2jK2wfGJdmbq3Up5w7s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWiABHvT15K2UgndHN9vZpMzhwF8NruQwn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWiLL56P58HkSnzsMCXScZCiFqLp2ia4ek", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWiWS2M2yTwPkViY9d97y6ZxDiM6wJeP3U", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWigG4GAT8eQ6rmNo4AdcGjF5ygSTAV1Q1", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QWim362Dy5CjzPa76yZQcAxKc8NmxF6s1x", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWimVD4ECagRMPVjUFvU5XQszgxCGscnz9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWinRb65f2g3yBoaZvTrQKQk7CW7vfBgGX", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWjF5HgSH7Fq1mjZ9wkdsNrBfXTtwucH2t", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWjLCvN3yv8UsTi4JmT327dNmu1EqwdL5Q", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWjUqUwMFhPm43qxWN5qCLK7i7eU5EoYTS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWjfdJ2cev3TGC5crrBXD3Ykw82F3k9StX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWjxokYeV34fgaVFrLWPcUuUyf7nubvafX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWkRyk6Tf5qp1NSKMYkRxnNMG5P4mM9LEa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWkhiCLpkW8Mz1RzBp9knFNcfejEebJwQE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWkrTmLYDpqxDUxewyR27Xk5pxDMwhV2Cf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWmNdkCRNoT1g3e4mzJiNKkn5o9DCVFZQA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWmkcX9Ak4EJMZ6JZskF5uqBxiqK6R9c8s", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWmpWXSGvdE1CfNiVtxC9Zcd9gYWaDk6vK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWmvpFvBBj7Zq8NX4EnExWqhMKs8gyqyuB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWmvq6TxfwCLM2AgB8G5EPgwP6tyFgaj8d", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWnGUCKdzqVCU2c5YS1Bq7G3LmfZRoofEe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWnMeEM4iw7Dypx6Fiot6gtXDvY8bBtwp8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWnTn4GaAAqzZTVWKCXv7jXsgsSEy3TVpU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWncDL8MibgjMcSY9fEdfg1GZ9GXPGSGSM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWo4oHDeL9QmvLGskjuWnXo9X6LdvQ4CXZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWomBbcXNTdkyuPFUafwtBfpbxHzmUZzqi", "assetId": 0, "balance": "0.00000002" }, + { "address": "QWomw4DwvCRASsM6TQ3JnGysTw5ThR5KsK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWos5EhnqwACrj61jX32MRdCmQRsBg8J68", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWp16HDKubnKvc5DmBuXac7L7KGfb29j67", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWp225B1m1PXMmJB5Wb7aZKvYrLfbe2oAu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWpWpzcY5r6ZYkMS4CitJctufh6KNiRZWT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWpw8FRcKTdB4bViiXPtfuDrQP2aNvsABi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWqH45NEeqHnpYS5WmgHgpn6VgEG6xSMvX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWqw9w7gJgxhEmgFGcWhbBDfWCtus8kfCS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWrgzTnPHH66oweQJcNhbhWUYnpCr5VJxS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWs9CQnBFY4NgMMADTiiTGt9LVQ9kNRWZb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWsEE5AW96MTnE9wuMwg9MS2qeAnjf26fr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWsFwrB88DkzQt4z9p1YzaeFaVx8wkchKJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWsJH1Qq4JfhcrVT82doFRTur2PwxcEgqz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWsM2UanU7UmL7TAYyaCuFFPeSg3wcQkoZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWt4BPS7ymeQm7kdv3x8p5H28s9TKNZGUD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWtDx9HYsZqnKTVVUixyL57rrm2voW3yFy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWtXL5ihoa4iCEiyczpAWRWP7PQ2ac6w6U", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWtp616B57vxLDMgzh1XqTkXW5XkFioHbb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWtqNwSFwrsEJt7cGsdQVygWDqKkQpTBUd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWu6hj4uywLMXZZ87c7DQuX2GxskuEAMVD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWuC8YAzfUiFQJHajnFWJ4sEVpYb86AnyQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWuRYEXHAsN8fUEVKr2ePikXDuZyth5jZC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWuW2YMygVtWieUo6a4yayD1xFDWdnmo5j", "assetId": 0, "balance": "0.00000002" }, + { "address": "QWue6adK9NDmThjSKZB8Qbx7ZMqeBUfi9D", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWurDPndL6yb6XzrCQkAo8eW35fWLm8fdA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWuzrMbApozD9xeqiEc16SeAvxikkjGksB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWvTdm9LU1GSX9q6Rrvgx7xjo2iuV2Gxn1", "assetId": 0, "balance": "0.00000002" }, + { "address": "QWvonX9AD2eeHRo5A35q8yTPPgShKtceU4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWw5Qy1cGrkX8PFVGxfwRUhMa1Y2kewNUd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWwBj6cFoM5EAE7sXULVw2BjVMCPTxDmVs", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWwFKyUCEQaEqytJ6qQ5KPK3YhtwbatruK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWwYbNc9tJjJrAMDNXzCKXCaxRugvH9gYn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWwcZuuDMpZtzFvjQthW6FUaUwpkzsmBN4", "assetId": 0, "balance": "0.00001003" }, + { "address": "QWwrtjBL4ah965XPXHYJhymreC9jyryNLZ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWxR96UNgx69M1J7rPmU3NyNdE2T5j6dFv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWxhqg5gqfqGKM7zdE6aBcuCHrymgumABW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWyGUXPbtV6ezDLX7XUzFZSVNGL2FFvmk2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWyfZeZaStRk9YJ31MtmP9udHqbfGNdcB6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWygkb66GNap2gkEGQGVqc854ZNVkTkvot", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWyvGXwipkm8iCDx78Ned6e2dKLVr9rm3J", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWz9hsv5HVkyCvsU8sNnb3A9ayxSgHjZjU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QWzRnUBj3QUrN3mN4wV8tog8ArHSkjQBuV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QWzjhwJg7u7EAfJVvRffiENs5ufhevZNso", "assetId": 0, "balance": "0.00000988" }, + { "address": "QWzjyZ53o9d1ssbZcpuz7ftgQvWvbxzxhV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX16Dt2haf8fnPyzthyusLrJJYZo5T93Tz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX2DKpRB9AjWENSfDEp3aR1AAMeRE5eSv9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX2YYVHRGTLDg95hekA8m9S76YSsZVJNjE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX2fV3kTcBFt5i2aFUL289JXxZPe9vcWPh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX32fvX3foMb4tVs87jpq8VQMPF7z5Rpb4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX3FccLBX6qrM5SQJ5KQHLomsyU5M97SV5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX3QgeAnfuvT1SYTCUphF1Bg4FkZ6emyN7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX3UVhQmUrfEyHipT98dTwwCjnZv8oHBJ3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX3ei9i42kouKrJF2jXu6SnKjZigqLFNZE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX3nZVoQLigdxyNP2mCMrwrVdPGN1xm9TM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX3ocWxtpBxH1PvmW7AYCydPy5NxbLkPP8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX4D8j5dd6RyfRoePY4WdhDF9CWskEuYhq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX4THPtFPJ8NX52d65Qta1xchm2kJfLYCe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX4WwswtNGAAspjAcvs3QxGza6Xh8eMYEF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX586fCAqwqzeXB2b2L98PVpX8AxGz3tbS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX5MJpgizSGuj2yPPvHiwZUhVsC5DmaahQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX5g6nyYJrbiMxdcHdcHgbfA8jURQcEZGZ", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QX637CFDXErHTmWh3GpZRwdwy1rp1XV4Z8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX68htFH5oyUmmqLVmjzp7DCeN6ZMDD5P8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX6TiCGH3oJKucGW2vEYU3kRKBuXSZLZtn", "assetId": 0, "balance": "0.00000988" }, + { "address": "QX6XimC1HzTNHZ8Xt9N2CKfqX5JuPZFzYG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX72WNWuU2Rwo6G5LAgmTNdFzTEX4NJNuN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX7H977eAxScf9E6Pnf8C3R2YhLEjnYFY1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX7HJvaoJwdUoY1jgrgCtGU7QzvKPQL8y8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX7J5PQFJ8DWeCWvhDFzu7JHJXr8HzRttg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX7XJiPewwnZmHKrWJWDGhZ7C5JZNfy9kh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX7eyVtizwaL4QyJFotposPfTevZQfECeb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX7h7NzicgvmvMDgLp3P3B7sVYgE5eu5ZA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX7xT6KEmLiT8RFhk48GRDnKsbBLfnB7eV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX86EjhBFgE9z2tPKNB2QcivaRSPTb5sMe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX88D2epXy6ZQrmPs6cyMLRKkLudjGcoSZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX8CdUPfSAN9PEmVByipYs5AHcyDxvwCzo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX8a4yNKnKQcwvrhHYdwDQdPB4fVA8UqfP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX8brPxKnauiZ32KwcSTCRT1A5dc8xxA1g", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX8mxo977eANNG6Q59Z4dCW3eX3HPBrZ1R", "assetId": 0, "balance": "0.00000002" }, + { "address": "QX8uwYVYxgiwtRkGuTxUNuVuVWYu26ZbZE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX8yi3CByWTCNw3ucJExPgEKc34f7frU2J", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX92FzQwtqm4svY7TR4gxt1aVAjEzdNnKo", "assetId": 0, "balance": "0.00000655" }, + { "address": "QX9dk93sDnRJRV1FcUVGSwwtbxz44Uj1nR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX9dpAALd9qyVKz9xaB36D669DJX8crbKt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QX9gAMMufDCu8rrVJJw5q7GBMq9yZf6jfe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QX9tTSKy9tNJ9AA8PCdAbcSYkXh7RcB1dy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXA2ZYw3hXtyNsLDnmRk7oHQ3gc7fMzezw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXA3RCgn8GvLjYuGqDqikeV9Nd4o2cWwZP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXA9rPXEA1dqdQDtAMt4LsNQHfVbuRhRQW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXAF33QjETNqqXbTFD934SSLP3rhWcN26d", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXAM4CuLBu7SZmcmMxLe3Pd5yCBUksPFXr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXAYKL8LbKmUitATzCPhM7Mef7RNE7SmrF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXAcmjUL15S592wWireU47EV3TQMNumfPG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXAfRoTzF9FvVvsWMrsj1iHFidG1Tmf3Pc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXAoGhYqxNLNnpnjbG7GuaFbcxC1zpWD5g", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXAoV9dhpgAUdRzA3RtQBdnNp3T9rjQyYH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXAoheytYpK8Jw9dZp98FLGddzLMYBUABf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXB1vNFJ3HAyKRUaNofc3ejSJGNeuE6qgD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXB9jbqCrYBA68vgzrr8Z8bqMXrCvyU7Z1", "assetId": 0, "balance": "0.00001003" }, + { "address": "QXBCi3J6QPx6m2N2RCzo88CHJhwFHrSVba", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXBJfU4XRmmVJu1p2G9DcamDoGucNUPpdj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXCAC76riBcXE1C4mRBMujrUvJCdeSY7eC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXCXukke76Fj7cQypESGhcJZPm2dpcLVjy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXD81dzfHxM16AGQtDk7CrWtg4Yxevb8hk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXDN2ZkjoLaHYu2Fj5dcRfBydXsZ1C8YKV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXDvss4xFr2P5apgdGuDr1A1FPYkMC4po9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXE1uPMACFbFHCCQcBCgtSQbMDsDgH8opQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXE33usWJyqM3uymszgJHcbfF9PBQVQekq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXEEBe1WC5NnwH1AKzrnju4r7xZKMRWkaM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXESX8R5CpGuioBkuCTitmNmxhudPMDemo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXEUH4QmPCiuLhMtnJ5ticagn4RvTF8Qev", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXEck5h4TcuWp3Gy2hAdz9TE92MoVjKJqY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXEjE6Xs24LciM75zkuyPEGvEyxirqjJTc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXFBVTmGqAWyUo5nLnokz4XhQ2WZQ1QXMk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXFN3cfcquqQ7CaJghd95VThU3RJUU7vvJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXFRdJBRKzMuDQroFbnwUZU3Y4dnPN2D2o", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXFTEcQwkwF11ReQK8m6oEh35jmAeNMp7y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXFVYCWTAnM3FVhpYkin3Yu7WPb8w7NNZ2", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QXFhqPApsvvvE7m2c4iLDs8t3e9x7Jra4Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXFoj8H5qrJsAco55PPAA2Ht52rajj8gJh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXG9jAm221jESt4cmF2wA139TRAMwwcBk6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXGAgPwJ6Gi3cW9PDD9ibXqWpi2ZTbfMs4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXHEZ4axuNq91K5wW9zaNSvtLzsdsQ1yVz", "assetId": 0, "balance": "0.00000669" }, + { "address": "QXHMjGxDjd1RbNN4o2XdmXBdpiS6emQ8QL", "assetId": 0, "balance": "0.00001003" }, + { "address": "QXHNmFoCZEnZHfiY13xNRUDyXFN5G25wBi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXHSfXp1PHU1q4b8wAKqkLDEm67scWdDQk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXHVLoX6kCkQq172ccxDgA6UMqTVSycN6H", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXHYMPgRycBzFf8hjuNpb79JCSuH9437nm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXHmtFXzf4D7PEu73NfBm3sZyeuGrm3QC5", "assetId": 0, "balance": "0.00000988" }, + { "address": "QXHuCyxGCmBVYXoG5y4eZoa6cLMBizLtPn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXJTBcC2cKE1YaKAbrMzdRkKo7g3vMFrRn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXJV9vN3YyXaCdZjEFB2NTjRbuGv2kVPCK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXJm5mBpKdSnjb5C8g54brbicropeQyoBE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXJybW4se4mQazAChjkLxfKLK48aT78Qjr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXK5PpEquFdcgZomwSodpAMtS678yYoTit", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXKH2maz84fSLA3ir3DygAhjvV1Fmp7vtJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXKTvTfQUpjER4aiyz7eiFmSpa3oRbqkAC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXKZ84ZZAAr7qgozjN2ScrxNmydm4zhVNB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXKmtkHHwaUQzGeHHG2dFiHUnKAp815Mzq", "assetId": 0, "balance": "0.00000002" }, + { "address": "QXL3jjVQqBobVvc8TUBJHid9Mx6uHssoQ5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXL9icoEUKYT6E7CoerRsDfWs1jLuKTu5L", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXLGkidcjcxJ7EpQgofF9LwtY19NRgKnvB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXLNr13hzrxzhx9FpwMCQPCkms8dZYAKDR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXLpMMR7eKhYWcdzKvYBrHfgyc3nfqgJFt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXLu8fYybCUbLSYdMtWnGWHcEf8yTarABB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXM2qYwdGbfaumFyzWewLckN24QHs2XWMR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXNhEcCPoqnaZiZemLd6nAeUDJ3DzagZ4N", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXNzWKLR9pqHW5KCUCFvcaUTwWKWvdYhzi", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QXP9E4FvGSdqo4iharsMeceoVkK6ZPzGiY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXPBNnHBw5wdsEZL41inMsYZN6AZhfRxvM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXPNnETtRcEpSLKnTuEGVP74rgae6LbbBn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXPStD5dSqMLHLApNwzeLnHnw8xcBnajpL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXPUNWGZWXEEGYCbfNoAbma2XVSviTThx2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXPid886VUvxGCB6UT9UK4S9DEjUU4Jkpi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXPmjaUXXT1RqhvBWcrfvjSpzPNjK6HpC8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXPoeA8BjFzJzdJbgSHJGtD5JHLUStTr73", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXQgez6puBDgBAxmw66ZVGur3MC17AHqb6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXQhqDVN2BQmm4iXowRgU3JdfHsFnzrRzJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXRFPQ9tscFhSsWNtQzcDqbUWCUaJGVbYC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXRSaYa2pMTp4qaZ8QqpQHVowCeM2ZARas", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXRXXg37W2zTbxBDfwkfb3HZcUZtKeLUnr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXRZmDDjQZikkVc3JCCLTHPtCqeHRtvivZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXRbY6sURE9V86SJxaVgByChkPSfR4UoT7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXRdzaQDpxHJkVsiWG65fMm11aupPaJ35Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXRzofvrWA2gBzR43hTunypwn8318iLmGk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXS1S62B81Qy7Q9bCajjoJGyid6pnrnkKn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXSLZKvSrjuwMVyrhQDWmzhshoxpYWnjav", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXSSiNP3ciPuw3amJDnLyBavBRN6L6C8Dh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXSXmErwvv3xFb5A3Asg1gaH8i63pu9YKM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXSr5k9AWRXD3r2fRUomj5G9PYRWLvUs5u", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXSuzcT8Qyxi2eL9PMpnxrqdBvpk7PpqFq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXSwoyPM2H7Suy8CTDiBSZYrZeZT6REfUy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXTVeNGDFcyDqknry7NXmhC47Gnt7tyC6Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXTcjMovcuD9AVSvNRLcA1r86K268yAPzR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXTeMp5kBcAUT96go9wkAbEckwkjPP1L1b", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXTqm5r8NJ5yzo3Xt4nybyxBMs7v1dKR1g", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXTsL22HXJobZwDH8SRfgKjHwUunE2JrG9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXUWuZ2oAUodMU8EAQkAkDwkQHS1SFxpts", "assetId": 0, "balance": "0.00000002" }, + { "address": "QXUYWW7qHCaMJg2JyVTr8tptV6czyppRJG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXV2EabmW5AqDa4usWyv13QvxtkSUF5LFs", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QXVB12rJHF1iVDVshqFnnJm9PE5sXGWDPz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXVEDSy51aFWP15yC6HVzCV6XsSeBKUf3T", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXVMP34nHrF9hx5SRDXJYGigQJMZfkLgxB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXVTjhr7NjZyNxeapVUpM1T4ffEAHYaGGX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXW4ZdMmAfKqoVWoggQJZpkgKpp7wuoSDp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXWeTV6gZPF5rm4Rhwp4pVD9C96qGGRTiJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXWfo5uuoCLrfY13Eabn5PEuKGwNNNt75Y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXWgVNhhWmuYhmFMDn2L2xbv3nqFJbED39", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXWw14nvLXiMW6MguKrqBDTUmPePLBXKeU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXXPPBv2TzRCZTnrG5Bm1bBm29CzEqxvAF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXXfBJz9UfAgTEAn3b9W9jxmJYtqar1P78", "assetId": 0, "balance": "0.00000988" }, + { "address": "QXXtUQVcPmaH8Q23qKoAR6fMCxm8JLpxEe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXYE8euGH7dGy2dYR9ErhQXZyvSFR8Aey2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXYKag9cHAQdH7J8n72hzoobpD7vmgZ4V7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXYk68x2tiUrDBv8eq6wd4KtBmLHYiC4zR", "assetId": 0, "balance": "0.00000988" }, + { "address": "QXYmcJF2c5wRyyXAqD4dDGsC55nMuXPFap", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXZLbGv2ZqMwCUFRwVHmbrUniENhJ1kZM4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXZadfaCTyq6r314RRKrJmAydQ1LbuodHK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXZmsrc5QXWx5QfC7bbYsXGU264asr1PPD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXa9ZjG6sYcs5mrA9KrgeVzmQTfsa4g61R", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXaCGLu6PPBpSejrz3krdZy4o88q47eYMv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXaK4vwo862TKfrt1SHAQNN5nvhGF28Eug", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXaXcaaL1eDZQaECk47BCsjHojWGfHLcw2", "assetId": 0, "balance": "0.00000988" }, + { "address": "QXad9dUxzLuMScM5wNiHfxw3SVnC8HADDt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXakTRTKxXji7Ne2Temx4kMmZqFCXX2WGw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXapyoyeuUZ44m8PdJ2XcMdADkrMeAeRzF", "assetId": 0, "balance": "0.00000988" }, + { "address": "QXaqNSSxhrKe6UGm52Up87K8PW9Uck8ZWk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXauULJk1JvETWnoZLxH48kqvCn9Zy5a6s", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXbT3Zo9gtLHdAToUNpemmhJF1E178jcHP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXbYDvqnRN6FBEWD8oJsd32z3yG4mTUNRK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXbcEC99fDfNfjWChoHQkAdPChZmwTBZYt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXbmG97jscGnjFwqrQTHupSsKagapwEZCk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXbmsso1b8yFx1DvLgx2ibX4EvCVyPLiuC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXc43uJ7HLGrt3GJNiaXhJ17ctStSc6XKa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXd2wRow5oHvrBg6B8d5N84Xxpi4yym5sS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXdL44BkWTqjPn3b4oAyWJaGN5NW6poRWw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXdS3Sw8pL2BgbRcvfUrr93vshkxiLsgXX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXdm6xf1tTxF86GyBCUdNWByKvrf2ZqV5n", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXdyEzgLMniSSzf2PS7hQhqUX6XKQemJnv", "assetId": 0, "balance": "0.00000652" }, + { "address": "QXeAt3FuAS9Qr9ocgMqaqPTLKMocTEPtd7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXeW3vzV8Rfe9kUbm15BW9dFFuXuBq8feb", "assetId": 0, "balance": "0.00000988" }, + { "address": "QXecRT233N1LjatJFV74wEM7iheMBVqiar", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXezHyMMbd7r1JJBAAnhkBjQtJQDGiHRR9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXfE6HRmeAZmhfkW3iPgZXEL9DwVoNtL9o", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXfKDdRZzBjUiFDQMAWePsWr9andmUYi8N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXfKxdnCLkeE5ZNPFzg346ahNAQ7RGTFut", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXfUzUwurXvmvveNVtRSrirCvkTLUw5ReQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXfiSXS1iXGFGPjxip5S6AE8fh6iWgB5cA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXg5vGyZFya3621j7K25NEWNHRGTFprzyh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXg9t2XMxLJajGgguYWZrwMtkHLmaiQe2x", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXgPaVzBPvM9RugqfBwqNWThcj6MaZqGWo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXgbN316Rp86RX8wkHXKnn66tXtDCYq5U6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXggg6jxkSgBHnM5uJ5oRwneHTvEYxwzsx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXgtLByEnVx6SrijXUaoyAkp2wsbiNv9ce", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXhenL3XyDFa6mb4JWRgYKXcZUQV2XHccD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXhncnksDXAoQZfSFuCXTJpAYwPSTJnHTM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXiEiRtxgn2WkUW4nR2hyXPQXR49PHr171", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXjS33SPgqCuqb9S5vTQiXRd5DBAQmV5rf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXjSq1q5eL4QwUcKs8UUx8vQwXYsCUcp4s", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXjYr3WGYyL6HCnc3dSUoCtrwa7G9WW1fB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXjaMBNMMPUTUqmfPDUWGUnxmzZ9frFQSp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXk1MYMFbRRd4yKAcQ28k26bjQBWHGteaX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXkB1Jj5kY7JGHbsDyqZe9iY9sUTstPU1Z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXkWdNbZfwuJwKEY5Dx1c5xHPArfhrd2XN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXkzZmkSW9Crmzh1gsERLEd3dUgiWodFVw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXm5e16Lq6dnYwpZJ8Rn2cME3ziHZfRRnp", "assetId": 0, "balance": "0.00000667" }, + { "address": "QXmAdL5wEpgWbTSgnHJgdfQmKkhnx4EfaC", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QXmCGYA1PP3zhzGEY643QJrhpoYVWGoqYT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXmJSnFkzk9a8Ni4AW7NqjChCobPg9td3h", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXmYaDzKQdGiAMncJCr1FqXy6tX3avMRm9", "assetId": 0, "balance": "0.00000989" }, + { "address": "QXn6ZJYvBKcX6b6firySVmcF8ZajC1smLS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXn9koFRnSiQrncHDpxcnRN6cAGbbsH4yW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXnYPF7swPqLub718iGoAPYfAuCDqbkwTw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXna16PRLmMLidiGHb78u9AogmZgAMAWWS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXnh38ZYAuJzz9qmCtoTFESf27LHXNQfR4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXo7LtNjM7z5Wk7fKaz3EY8XGVpkCXeazL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXoQNPMJmGWg4c1ZRezZQLz1DUsG4irVGG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXoRkUX3PZa7kt9ZjuLv5zwmRaaeA4cfJf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXoSXNg5pyyXkd3vas8cj9WKQodnvYd7Xu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXpy9pZuk9v1YHUMwTtSsaom8SKDpXf397", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXq3bGuBMtJ2UsA2jwFLN2DJzivb969Nm3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXq5r51G2TJ8TPkcM8joi5UXA2HsnEeQVH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXqA7v7zXoU52X9pbKkjrbbUKE2q9xnPiH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXqc1jH2DL6H3qFCHxTBABivtkqJsoBYyQ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QXqcSjvdBjPH9WmFwu3mMu7orjKjBrY58r", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXqhavCfuiHtoqSB9mxe7rTuvoJ2hwJrNv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXrMbVZ5aFKrVPkwdMEEDoYb3RWzaX5Bka", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXrenLTAjEe76ncSgSGPVJ9sEvPV7BTnJo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXrkYRBJkp3CQ2ryjvWskszuWTXRRLbhTB", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QXsPzsKF2vVQyQkXyjY2u9MaUrvjFXjydx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXsf6TxkFBbGnRESskfzWpi7jYZRWLLfu8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXsvf8XEdpgX1qqzzL8igGdFjHEitBBY3Z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXtgY3R3yoNeignxNPt7aVoUdxLHTcUtLT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXtuKtCBqRA77hfeF2YrvZzi2jSKWNBnjr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXuZv2TiCMFkQbAAE8TK7UzixZJepTCuxy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXutAzuvDDHE4oTtpqY5YUQW6pPzRCRxzu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXuxYphvFgGZhWSNAU62S9D2KQt6JiANuS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXvAeapBJuETHUaLAQAyPgEd8TJFW81LZn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXvHT6UCX9UyZY6kT8x8TMhUcd17zhNtVV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXvQWWedmPnuAV1oRTRRFY3i8BrepPqea1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXvtLcbk4hkd5vUCHjiQRwWDQ9rywq81ef", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXw1ntK9fSNdED9MGeo3pwHXiXoZWMdWo2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXw7m5DHTLHSB85dzL8YNJgxCED78bPsx1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXwBKeJdosLHEq8hqdfThAKWDy1pR187UP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXwRWiMK2vMsPPbfpq4uL6vckWBymizahi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXwZsQKmsW9YEJ2kn8Cn9XRQ4TstQrhDnq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXwvHcga8Tur7Xa1cqezUqrA9zGF5ziFPE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXxFZGR8vXqJF2WitzWWDcoonKEqbeUgFu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXxaT9FHxVm9uk4o57MScWdNjcYcyDZ2cb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXxpzMv3RJoJsHAu3ShsxKDN6jp2H524iU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXxsHEBdfDB8e9YJQVyf4F1reL6xLWuTdL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXyB4gApHGVD7QXmrAmwmkwAMRxnQL44Ag", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXyGZceHtBVTpKeKNBDfAPQk5DhTbd9FnV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXzbKFULQpBWLJ638Km6orTQ6sCbbtfMbw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXzfiRTaNg4WoqhwhF4otLkGHGYk2cJowo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QXzmwDXcE4eU58o3qoR6hUetsK8JXZFVsZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXzoa96S3GZbDPmx27bYrWbqHrnSHVCc4q", "assetId": 0, "balance": "0.00000015" }, + { "address": "QXzpeNdKwoxycyqZ2UanqFGngCN72nYygj", "assetId": 0, "balance": "0.00000988" }, + { "address": "QY11HM2CuXnc5ar6u5W6WsbCP8ZP1jm3v5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY15XTB3tqcyDvwNMcnNTo7b1pnmSvruvn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY1RFZTD2ogRohf3UrdT4g1Qo9D122AZDN", "assetId": 0, "balance": "0.00000002" }, + { "address": "QY1rJEuFJ5C6vp6QPczQbwUpg2F8KdPRoE", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QY1tm33W3xMA6akRCPTQgaVaADXFVA2R1A", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY236SLjyyzfeUZKjjCVzxANBc9ZWyjjuV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY2Gj1fLC1Dy4bNznRhjp4fFPe7YyhKmq4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY34mUTGxGWVSrBYaEENgZ1Sj5pFnwyxoU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY3A72T5Ltiobv7KJcATwjnVojvt26xCZ6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY3eUaqeFTfkhGruhQHrtbZb4GE3Up5REE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY3hZBHLWJMsLKyVK6WB5juncvHoRRZXWx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY3tbDj85AwDw9FG8YBTCkDWtmXUwKEfw4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY4nZxeFHXzAtBRMk9TLuyQaZmkeg2w4uX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY5YweBTCSb7zkuysq3zDgD38miQL5WkRZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY5ZKKcbxy1xoDd7aFxe2ATCzU85q9ckFC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY5eztfg9pEcCsjVTdZEGddPTiPmxcppdG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY5fL9o7Lndr6GfhDPukMijFabxXocEixX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY5qxVdhq27gBb5AWtFaowXoC5wxGwx2bH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY6CWCj5nrQEvPjkVaRXrsSQz9QHazneoG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY6R9zCcfbGdt2kxpwQnyEZfsFdEf6dFjb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY6UkE9eJXUwpvAKRtTd49aMsMGhmhq8vG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY6ZGZdi8h5op2VrRXkG1W5Jp3feLwp7ZD", "assetId": 0, "balance": "0.00000988" }, + { "address": "QY6mJxMyw1Fd6iaY8RkYfc59TiWppcuyPz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY6t3QiGA3a57eqoNck4gYUkP3zJNVxLaB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY72Tmz4ETQ3rWSwLwEsKeYaeX4hL1LV2B", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY7Pn2TYtnofRsVofG4VFehrjUUaCN7XCC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY7XHZiG6Dcx9u52NfjJDh8BhMXLY3ugNJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY7iJhcSQZEhw29toSrHyFH7DJYvZ1jbBU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY82MasqEH6ChwXaETH4piMtE8Pk4NBWD3", "assetId": 0, "balance": "-0.00000054" }, + { "address": "QY832UEaQ9NYogWXvLneotSYRUCpuyPuvk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY85DK7wSTku4r2RvnpZHqKg7GrGuPhb5F", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY8Mb278KLaxnN5AkCBzqTBmUrZZJ2aBiK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY8WbV9JiWVeo86vXULfm3ov5nj7XySmCk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY9S95CrfHVvw5qxK39o5EHYMeDkvNUzDE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QY9d8rAhWu2pCGrc3qjhAttA1ocEpgRnhu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY9ehJw1bDiAbo6WREPWitk1wzBdVJWrHH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QY9r9gNCBBSN97a3BvetFfEaSxZGNBfWrn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYA1jbLKSSY1q1Zo2VwuDe6vTJXQrr1wu1", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QYABo4WXL4v5XaevT7F3GpCBHNTGj4BZHf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYACkvGXWuhutKLpc5vZipRxBgQMuR8pNe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYAYB8m9CfksGvEjGnj49q74bNDCGGaZqV", "assetId": 0, "balance": "0.00001003" }, + { "address": "QYAbYY1mVfCcXSoWPmVzUhvh7UBaK5enER", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYB1cL1zhwnP3vzdmKhqsbJDNeE5Q9VpKr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYB9dJizBvcsmhFBgB4tzLAKsQvb5HQggo", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYCGtFdobiePhBpHEcdrHe9AyZRimeAQVH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYCHqCQibVEcLx1bBTXPDEYnfbQ67WVHEw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYChJiTMf4EsmiDeyoUtMqhEH1EQPQiJxd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYCti9oVD51uUzrgaQ8LyDmVytFBeQZbPt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYCwSxaD9q1qGJfhTxBMAib2ngQoxAdtxz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYD3kXchZ86vUyJBXNCVQ4LUvTAd6PUZW3", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYD7hu46jtqXMNm4R8KJDPDUPeZTC5XYuQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYDTicNhSAUvLNzcvfNoM7Mta6To5g1jP6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYDdyB7iae5w9mQMubsRD3w12kBDNrkJWu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYDg4cPX2ddSVmaiw5eYSmNSheDKDmvjqP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYDsCaFHNrJiL6gncRXgRBdvyTSCincFej", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYDsyZoQV1cCbFUPQwxpaRExwbpzcdnuNq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYE9LgYNRqeT75TTHj4TVA6mijrNDjNe9k", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYEDwf9ghkPBXVvs4Xp12qkCqcd5mRHdUd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYEG6uvdg2iKb16q6pZpGPfnXLdVehcZ53", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYEdKLFMkhBK4NVYhhNgUvH2QdMN1m7FiQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYFFRQbZWcKZQtLqvSEniTDcfmbPsmRDsx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYFZRvkwSBKoeV18VkmkPqU9LhyqCccb3N", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYFd8dEdaTo3skbmBCUNZ6deM9ovJRziCh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYFk7Zs6xdDNCegFYGkZRJuTv7EjJcX8xm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYG5Gf8FzdwPP6kV2n8P5KA2ekHD6iAtDG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYGH5iNNicmfGsb2gZFRh3XBJYPEvhYxz2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYGMSCr4xXDXnhGxTMFynP1b6mY4pwq4kX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYGNMWBmqWgVtMWGHypAsKhDVQw5mrFZww", "assetId": 0, "balance": "0.00000002" }, + { "address": "QYGbdNrxuM7hHaAGpbFKRhLL7FEDgB6WEL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYGcPZcRhGaY1MsiDr3VtwTXmB9TAbLFSn", "assetId": 0, "balance": "0.00000002" }, + { "address": "QYGrsQT4yhRUxiKfVgo8M5Sovfy1zcjUsr", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYGtnhuDrjk7VSEVeuco3z4A35ScMGHYYf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYH6f8T2mqote1zq6cB2JyTVUMxuujkrL3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYH9qk2RQt2SFuC3soaEGMj3SXBtEeNgQz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYHRsi4gwWhhE4cXFqsX1XX9H9idinKS7D", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYHVLRtnKb1vYx4tsf2j7tWvAnKRbjmWEn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYHvrW3bwYFeMTUEYascXhXkBAzUkcGbqn", "assetId": 0, "balance": "0.00000655" }, + { "address": "QYJ4ctpeqpCTzEJbYMNj1pUMA2YEb8XXcv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYJ9GrwoRF4bw15pGrUDsaLfC7meBQrQyB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYJCGSJGbXH3peMnR6p3uiTbmn7bLk6GBA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYJyMHCmwK4Y4zsoRMkG39FR5yhsRKdnWh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYK2yNjaWNMqHJA5rPGQr9pLL8eRpShQ6D", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYKDKeVzEVBwsiDbs3CTC36V4bpXChvQ9h", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYKWsqZ5XHz2meGChNQhuX27iBSsdsZiDL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYKyeEa486S1UorGFQoUHVAgotvgxcu6Vc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYL22vpxwRkwiirhSwxwLTuD1AnhMnMHhu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYLcdy3NtbS9c6CagXvr4pe2zm31EMkTmX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYLk3aHheE9HGEntVAMZpjCBsp4qfw62jt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYLwifCZEasiFUvLVF83VaQF7P8zVWHS7i", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYM16FMzt6LbkrN5gmmz6in22z42CT7fVz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYMBGaGfiUvvS7YHuuRS4SDWDUd1RAjjYr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYMNfcpkH2kKrLZ9XuR3nZ53JbV5asVpvH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYMgsPJXqYMAwBnWZGEDLyEvrQ52UXDmKr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYNB9wT3kHGfFNSAeTofVEy6gEzPL4mc78", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYNDeLBDMzBoRMgYEyzZ8DPQ9YRkWn9R8g", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYNLZahJS3hSXTftDGEFg2Ufw7U27AtiYW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYNZzJuLbh54wftQiHSECFx5Zbi3Hr3drY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYNefCyUNKnkz4RRGvVqNzogeBpkxi6A5D", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYNmrKWXKvdpcWWbg9GfTWDDHSpkWAZHGq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYNwiKkGEBFEk6GRxmg4oRQK9zhSYQAqVL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYPEFooAwi5GU3nZSV8ReWo1WePivs3Ws3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYPYVsL3rw5KD9mwwTfBpwwxMHVznkQ6xG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYPZ72V25K9kYsrGKZK2XBsRhTbuuAowQE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYPZkLyzqBu2jA3B5NmovJk2Ajvx2EiFai", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYPh8MX88ReZV5Noda2SemFar1PLA8VRbp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYPw9fRgyso8WEYvzjgQMzZY5fSXY4h8Vm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYQ2iArwYi7JUqwTaSou9zbgq3JPBZbshw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYQ2mnUxN1een1phEJHUwNHs8tV8bft6vR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYQ51R3UyUokGPzuyeTW8RrjJ9SFXb1ThC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYQ5zTNq4w8EFNy2b5PkqpzbooCwzWtYDr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYQLj6yJw5WDLhcFqihmWnn9SMgnuEQZnv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYQYhSnPs5NRiaYUJJ13nZ8xva9swYVA6E", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYQicM5rk5MPdYCHcynmhTiyWtnsadPh5E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYQxQhAMxWEZZ9z1mHM3qxWEN8uiL6UXRD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYR7ZUCDyy7iJRaiVdPyTGe6PRJnwEYiVz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYR7nxtazHijAUjCjNfN2X5UkiYUsJSn1i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYREQw3ohthywupqzLBRMjGkRSvbFPLBow", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QYRnRxSHcBVcip6gqo7gsAyo5HqPQDNad6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYS28NStojDepYPgBp2sqpXLAFkHfKjCFK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYSDGvWvR3Jq8Th4Nkva6wGcWon1CHJgXH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYSnMm7CGgQDw9fPjb7e92nBvZVm6FnTXa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYSotLgW4sGDg3zicNYnqLg3JFv5sPsAhE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYTDS3XqzHWcqmhXTsDcUDVAbQVXXVaVVs", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QYTKKtWaMrMPupWvwBrSktXurA23F5C93V", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYTmTSxB8GdnruZWA7Dvod9ihRQrAiLxn1", "assetId": 0, "balance": "0.00000652" }, + { "address": "QYTrFrd59PzEwJiUjGva2HGDkDLTiniDgR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYU5K4pLMggdjJc7mKv83wttMaKiWckiZy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYUBDqNyZZgXeA1JAJiuHB5ajfYPS56Kjc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYUDk27ajc5DqAFDxaspfv4ewEpEKJs2GH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYUXxhs5TAKxnanieRsGSt2GutdCbysbiw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYUhF2aLzHNaqj1Q62MpAtEehTRVWA6kZs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYUm4W26A73emLR1S74C3miPpVeVTr7XYo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYUpypwtBp9Zhrofyc6x2WsGaS6dgBY6sY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYV35hGrF9eZgbxnLyiWneuazhLVjGkJmr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYVZNMEfLqmhhMNssmH81gYxuB3icCsabC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYVd3XiCVuFVmx22y4vRc6zwu4m7YWzUYk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYVtYT9JbDj6YD7Rn971D2NpQQSVH4ZAgK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYWC11qEQRFnXg3hyuwWXFLmYnkRR6Sdcv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYWLygyCRBtfndKwBeriCUxHRKhfyJKQgF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYWQFxm8g6LjLyjNtY5KDcW7cSG2ETmJab", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYWQbXB4o49D7whUngzp3YSKBUJHsMGCML", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYWpy8QDg8nMMQ92W2y7mhjt6ykoRakcq4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYX9HCZbkPBam65xqHEbYaZzgjF2fwVUvZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYXAgSJeRP5mGA7goRrs9r8Hkv7Z37a6Z2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYXEddoCUgVzyWNLNFkF3NLvLXHrCLARVc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYXToH4khBXpkNynm6vaJrQKvatxvwR7oF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYXXEjAUkWNm4QNdXtDz1bosVg5dp1bqpR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYYWz4PorGvBQwUbPiM9vwyx6ceNeezvQN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYYdY5mKAymC7YP95uhd64DsNEbunPcwji", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYYgNvmp1pVngongdZs7vFZDsgCPSZrfQE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYYmEUtoBU3UsNpGyCTcEwPdEU2UKEc27d", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYYmnPbcnCpYQpnRhTLQGYbvARULGo2JBm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYZj8LFUwQVKZGBLe9FTgWnf6pLEyJJDZi", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYZjjmyrXXYbnrXv9188CMhdAboDbT7nAk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYZte7RVQ3DEjQAwfECr9kaYztskDcAPeA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYZzkvgsionimsakkvBsBrK3tgwYmAT2Wu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYavuNL1R5F4kaTLGfi4HrLUzraxNo1KLs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYawWSkFDmskxaoFjLQzJpKPugqsjgMptx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYbEi8iH177jx5fjF1YHfuh74pP51cD7ut", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYbcGU2X5rCEecNMxEj27TH4FT84kjyMsi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYbjea4NBz6si5qYBBPKQHqWxG7Pj1m1FW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYcAKUxhmJVTGJ4Zch19LEBAatjKiZGZbP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYcEBpuJ9RmdFGX6cdKAjSwnNVhwbtFLdr", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYcPYNs75ovWvReJzYvpYhGALYy3SUkSBz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYcZaQwkNpi8E1enCaUYkM8x4Ku1aTCYYA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYcc9fvrbdoikespPjZKDLjBZVr4LpQyfM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYdF9gJhyz35pgJi9AAtC9xoENevkqiDgw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYdH1z8RYNQB8XyPBcgqHLUM3EsfjsBqpb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYdM9RfFYwiVsgrogt1SVCKYkaL8782K8U", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYdvsRppC3yoG9DvHR3Qkh2v3WmcrWFE9x", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYeBZbUjLzqazzGmLjjwvmuZGThh87k9P5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYeumWgYua3WCJRtq2tjNwiGYp71fRFiCJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYewjo9qAQkWCb8562paTsZdfhJ9yAGC6G", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYez3KXFYQ6HPQ8VMBQ9pKoJZnxzDjBS8F", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYgPynBREMhTdXyapkuzJTLo2hQnEpXRgm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYgVi26jUqMzJo4ahZV9yekQNnYKHBaX8r", "assetId": 0, "balance": "0.00000017" }, + { "address": "QYgVmPaFpHs4KyP9AqNojVQ4Bt2podKRyd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYh13FWTbQc8Ee8VQwNdGJV92MezD6em7y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYh3b56FrfcGCaq4FBXoJ1akY8AD5MpAw2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYh9soqVjZqNg1S8JWYB1Td8iXp3dnoAdN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYhBwaEkU94Q1NPcBM5iJHECeCwwRJdPvH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYhWQv8kjsoqamPVTiJjbjtcufTJVxR9D6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYhfqEBunpfgo9HmADEuGh66CFDgihnm1n", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYhvkr3AcMgCQ4Z1rSjbyo938yx1frT1rW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYi3gMt1jkJJFZLQCjydhyHiWNjG2yUSqn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYi641dcjYFnDF336qa62APtPJhhahvmMM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYi9HovnP5USccMPuqsur3PSvt6uRUbdwy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYiKDZpza6FEBdWfyYzX3kpdHEFC89eVNj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYiSMPKtJnJj4GSwYKsfHj3eDo6uS7SbYP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYiSqBUwTAM9L7qZyu9rXvz2Nf68oi2zUb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYiUatp3sUfLxqyMy3DHynkx4A9uejbjT8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYicTvqqPFt7buJfRd9cgs2xvJ2rnvyTzX", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYiekEcspemVyzyRK3J1au2zYSr1EJ4MBt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYj9VhBBDvCS7z9qGL6UqWBSb5fJvvN9wP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYjEEJFB67js54hyp1tHP5XRQqEynC7v5b", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYjQoreHJnqR72tVX9bY44e8X8WK6z6gS9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYjRfEMpmJYwDnWrz3AgpFdvoRoEG4mNK6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYjTWLMLKCz3nMWUmEZKkWkg2K6bfNjFh7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYjuaZamanCfCSzDx5u2Kvq2CW6w1TAaMz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYjvAPrMEweFVfSA7dgHop69Sf2GP7TDHy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYk3JeXRtR3PtErYAtLwTo7W6RKgKAsRiL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYk8xbMzoDMvbx2PqMseGY9yRppdVF82sg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYkFBiGybu5C8e2ChPcz1TsC6Ff1LEzF5C", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYkGg8Es8F734U8mUwmET748D7Kf2GiGcm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYkMhpBGqnWFnzU28iGseZhcrTpbSgZB18", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYkY92NzCxx5Zm5jgq8sBXpB64pQ7hfxCe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYkZdX7V5GhRsq7qumLdSMpUdU5fes9tt1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYkpsUvut4eufXqJUnUbCzDajK5RyQ1Vzg", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYmGzFEJR5rk6prVwrEZ4xtpDb7Kk8XdtZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYmLqcEmp5VoTGbtuxJujVii9w64pnUqBu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYmPoFCwL8RccsnsM3LYcsnSiWNwF5VkgY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYmTgtPd6T3WaMW1nb448rnRpJoC7oErBF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYmchatSAir5JPWTRNoKHwaJHHjZemCFdY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYmiapeNwbzWYaoosv1QFmq4oRiYR1KiT7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYn2Uh4eii4SE29BpEPeRySbAeb9R6tGbf", "assetId": 0, "balance": "-0.00000333" }, + { "address": "QYn45TZVB1zJZu1JXxHyyFsnsDx7jyG7Y3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYnAgU1ih1JivVzGd7ZJM1uqMRRUDNXPZY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYnXXWjUsxhrNhzQv9K8PMx1m8Xh4eoz61", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYne8Rf8wGhavLHSSbbmTRkvibs7CcBpih", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYo2LDqQh8YqpQzdKuTtXPy5FRNNGqAeab", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYo6jUmG28gocVwmeGPfHcwFYNpdS3MZ57", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYoNKJhgva9ECexhgAmB3r4ucM8xwJbTWu", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYoTyZswm9C6M1trVreUKj87ZmfKi4Rh1b", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYoZxQVMsm5FcSLMzCsYqVxQ3UrE28VAYa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYoo69X2US4eFb8H8zDjTVhw1Z1FsAWjnn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYoxjGy65g11gYQWQSsnMUtiP4gDFPcSng", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYoyp5wkUX4x6nDXNdKyUtp11ePeKyAohq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYoyw2vKtigRPgqYVygiY7i8n8y4MoqBRs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYp5W4kGvCHfzeCgDyoCAWBZ9gViECNS5J", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYp9P2gf4cJScrC33xwAfSSCcKSzvRg243", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYpQyvV3xZAB3ZtZ5jkEGwDYLQffC6uLSh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYpaPx3S1X7RSDkBdJaPrqijwUyq1ZtP9W", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYpdDPSKUHjCkZHKr9MEyZ3a84AnZgVFmV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYphDYA1te9acFNc7FEmFBu3FTTomp4ATZ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYppKiFc7zt1EDXy1dUwHMHsnm2ckVsHTc", "assetId": 0, "balance": "0.00000669" }, + { "address": "QYq1C2TjrSY7JcC6VMWir1TB8aKFeTG5nQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYq3GgcrPkbMdNxeScCEoimBBq53qDxehJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYqMCdre22fyWE9HQrTnchqvw4LGT5H4Fs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYqPmM57u3n9tr6LqdcQedeJZdfFbEJ3ar", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYqQhKYoVRDeFRf4NZd2mfRr8jnDEW6yLx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYqRENxNBDM6c1CN3iP5j8hadzUVv1L2Qs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYqfGtdmPp9JFk6LvMoNQamn7cCwKN6CNF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYqqm5oZf4jyQLnkdvaM2a2thoJnCtgBa5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYrFp2VLyd3fwS2YogmDNfFxoSxQmTeytB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYrHsrZyffjSVS6xxSwJ1zNyohL7qM7Ycv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYsPCm3qKyXEskhXwRj98iD68ZZnZFn72X", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYsX7PkNx7cXwzTdDHf3joFWvURJSczKoC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYsh2NB6TogqV1iXHmHXcVaWw25WEYA94o", "assetId": 0, "balance": "0.00000002" }, + { "address": "QYssfbvBbNLxMV4WarmggjUhcC1rJxsmta", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYt15QTrikyQcMYdzkw3nbpzVdKyw6KrkL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYt1n7g68RrttdF8BdnqZkkKcYq1RHTeBF", "assetId": 0, "balance": "0.00000988" }, + { "address": "QYtCJnbQbkmSRC1VGETQsFkEqgq7ji9uN9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYtHZsCFT42uTm9zXPvVcPzcVUcgMmor84", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYtL4EqsQnMVLnFZnzx5ffkmzFHwm5iEWQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYtXhAqGN8XmxEhzDWyXzNSrdXiyS1Ddax", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYtyLLyx21PhFj8Ee3tR886ynKZgHkE4mu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYtzYfD1kbfsMrcPXU3LvGnGEcgV6Bqv6Z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYu96JBP6NnPRZjqostypoJeS21FCDQcxr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYunxUXCM2uwuqf1KfVPU31obcGqpNuZLR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYuoZ4zpzkCDPDGqRgK7dPJNMCgPAFnmwt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYupxJi77E1Umy99Dd5Dft9Xttz5d7hx2J", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYusobwsPtTzUJ7ZQcSCCFJP94ve2dRVyr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYuxfgV3vbBi5PDzmQo9BxSt7fttaqb18k", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYvN6ffkVU2cGB4WknhGd2kqEe9XJRCtJh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYvbyomsGJSGuLePcZMKTLpXEuDt7hB5XN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYw6Ep8CTaxtrwNSNhyin3uZu1fkubnk48", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYwBh4TbAB2YHJKjvquiaeC14ecSM8KEEX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYwpWuGfMNGJwLWu8Zht1ikFRCL2pJsS3f", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYwst3EB2GA9hx2tPHTjsBEBhnGRcqnaaU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYwyotes6GxtEX3btcvmfBzDjVGTjWzhmt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYxGNWJeBw6bBEYXyckeSnxHTwQpg9xpPJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYxPtcgUZsFtLdMsHuVehWVrRnRAf6zMwz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYxWUJH7HMowhuXBxWE6tUFzue9ynwb4ep", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYxZiRJqSBEyT1sAmtPSuKcks4ynRv7sGR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYy3vxLW4eavnujrN2p8AKKFKgSxmNC6jT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QYy7a2UZkExMB9aFkRJNLNB67SKyqvSM1b", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYz7VnhS5uqtvxzZoMg1oFv1N4Ee5p139i", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYzcrWob3AfDifQrjuHZ67uCyRMF48m5Q1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QYzwWoofkjANxGrCsLDAMqcAK5ezSEJgiR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ12jTxmXVUgpDwFUNvgKB7Q52XPo6yXZ5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ19JRpSsgvm4z6EjnbhdxJBoUYzDGvP3x", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZ1iWoraqiezeAHrgTsC13MTcrwHJdRwgk", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QZ1jZpVuxkJemzV5SSNYrrD5f5o4HzCuHb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ1u2PTK7Q9KWuJcDNxeN78hb77NLFXpkc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ26SzdYyE7TbtNtLrtZNnMAeavsKCKBGy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ2A6QhErn1h2kJVGoYSdpwDdTu7MHVkS4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ2EuJwQwGy1efu1ze5xejpcFcUa5vQjPM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ2L25gTsvf4rjug5gtVqkmXDd2bQvZakL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ2PihG1RGnxSAKTyugYwgM8M8LrBZC7PY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ2gi6BhUNpGmrErgJLFuY1WHy6xK1J7qX", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZ3PqidJNPuU7z2DwVKXTzAR9Dg8EuKYYq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ3hDB52DwDgrLrUWmE19a4VLXUSadDDJb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ3ihVjrZq4hpyG6rqnq8ikzmafK8iTCjz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ3ijHjUg6SYQk8tRyfbhNomtms1RTjrqT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ3qQYZf5z4wsj4EoDM3htnnB6z7i5Hvr7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ3zcKriPP6QthMr2F1eyh8y6BKNpKKuqH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ4kqSQkpyHviff38gNzGq8jijZttpiYUg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ5EVcpQnqzUH6KXPPy54vWqDqGRXBHqpB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ5FwpcCCSdxf37VPkYwJR12B57dNFPqNX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ5Y6U5nHQzqJdRdXWhYLCSAWbLmXs5mBA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ5pAiCZTwmCBacQVNbAcihsrs8umbr1Az", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ5uoTRqRDitxk7th14uYHyvqyvEokuyot", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ5zAadzHYE2ydfJWUrTVcjgKwdSFX5EyY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ68o5cb1U6XoPNCHBLWDaBFnwQLLfnXy9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ6BPH3eo6Z8yQjN2HZJekt5A91hfHbhoS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ6gWWcUJFsAqr5YuMd8iogyYYCJh1kDgn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ6kvcET5Vw2BqmvQu35HWP3v83jJMmHf4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ6pKxYAqW3uKKLifUryK4YQ7gd3yoS8Cm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ6q2FtHFncXxC1ranW4gjAELZvmhXGoeC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ6soj9nran7zFvWREgZjFeWVGCBHuo7YS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ7t38t9wSx7m1fPo4MMNCeLjSoEiQWttM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ7wvWAUcHKRhvQ3ijdrqM4zucQKCgQ1hQ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZ8E8JJTMASdx33AFnj9Kxk9jsMCvkfFyE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ8EQFjgeQABSdRHuMQd5VeA1WuqjF5Xwh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ9Azx6iPq6T1yM2uoqFnEVBX2z4EFZCPg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ9L28k1F6kwN47L3ndjEGvsK1MHpxtWNo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ9acAVMkxUgZba1wR7N4hLfG9XmP4ScwP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ9b2d9tcSEDf2Ssa3pT4oVoYH1GJmNRkW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZ9ep9Kr9nNMQ9C1cWuYsN317iNg23XSF1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZ9kFAVA6bVRYewDrDjf5DmvmPBJH585yB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZAJsieQAJbwEfXWGxPrdbnqz7e2FrqWgj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZBCuN7mQQfgD8NBB5mzA6S9zkKkVxAbR8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZBLBttWkFiV4ZY4TKoJ8Gqb6Lmmz7jhNx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZBNZNummE6yJnb7mZQbAzxgrshJgqZAMP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZBQEyaTkJYZE9pgFpEQp4x4jsyL1zcw2s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZBQTnTMT2CfgBCYdPuRyG9LbXs8c2w9hq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZBTByprtp1MGQbEND6H95cPsrGaKEJEmy", "assetId": 0, "balance": "0.00000652" }, + { "address": "QZBcN6Ffwgac1NM9k5PsgDw2V7FAjry1fT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZC7k1vEG5wG45eTgABp9dcMAKuqBwVXaK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZCAFYJBRU5Lr2RAx3VZp81cELhQozu4Wa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZCCh4XwHK2Lok69EDnsJq6viNLpzvwvAr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZCEzbH2T2tb2QbzZGtrX1cJLBLGxybtnL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZCJtE3UfHkToGZBT7Lhf6pJyqnfJWKGG8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZCNvRSndafxhHqpaCMz5EsEjLe8CaHucC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZCXVugqJFSdnaeNzS5PtRHuMoHQGYrw2X", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZCoseJQ3YvbLgxm1oVZvm1WqxDfBMVt9o", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZCzTT7xd1RNUZom2rcqMMfTB7qTYSHDLT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZDbThYA6s3nn96kpHCiNS7eR9U2ZpknpL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZDfRBkX1ZpWVQvEnxJjNQWMQRq1JS4oY9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZESRztnzT1aP3dRwU1QGhK55SzmgUcaT1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZEhkW1Eoi5KPwWjD4dLyVCMrfAieEeZJt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZEhtbFRe5BH7S2kJnYWhw1N7RdWwPz5k3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZEoQgrZZ1Tc3Utb49AaRe4FnjyWmTFb67", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZF4Lr4sxz2H9qDUvVjQAzYmgbRr2sVuv1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZF7CZKtD1hnTc58wbwL31q65ipKGBDYDZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZFUj2BJuK18AZafEHHoqCSgRN2XtWEeCc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZFpG2fmRk1bJPMngekkhrj627Q8AW5QM5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZGpiwVWyTBE5ZJvd7qzsKHQrhgSemYaSU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZGsXqUuHiL2X9sG4VafmqJTrKJxJYFgaK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZH595yNp76aniAjUXkv5k53VAKfiZe63k", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZHC8bBNbHTSEmdjKMQJFHAJhRwrTBXje1", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZHGbg9ZfjyMqriF84pDSvNFZibPoEFQqE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZHPKFsdV3pFQuC2x82VmkewFxCd7c7Mhf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZHvy7hG3xtpbWGq6HEnptY3X7T2iRNRqY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZJErjnPXAfLsxsMg7dZfthxTSoeqkF6C6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZJJB1jhdWPtRTDLCU4vtonDEHvYXphezh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZJc1V32oFm8tufB4bk7fa3aepu4EdkeDU", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZJvLEUj3L2sDMMkTKnq9CgsbR5fEYjdkq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZJwReFx5Vy8fd6FYyAtzApqekJwGSW3gt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZKKSYCnTaB56dT1dkXiV86eU6Pc9ADos2", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZKP2kuD8CDz9kk3E4eGeavcpmWVjsYXMM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZKwxhKonH4A33NEkKXkVmUvhxP44E4VkR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZLCE9S5ymDekUP5DFfuBybM79HU1qdqaA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZLten93usFkRbodC9z7Hx3wj3Z4aH59MT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZMDbkZPy28GcPy5gh9unZM4z4Tgf4Xtcp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZMGokSNFe71tMq1jn2w5ANtKN5pxTn2KV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZMgbAHRcKBtxhvko7sNR3YyyVULXSMTjF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZMgqVuxvXPXe6gbt2XBBvYYw5NwWECzhV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZMhBGpATjZ9ZK3fdcRvXW3RWKAAETymQa", "assetId": 0, "balance": "0.00000652" }, + { "address": "QZMzF4iTBV93LP5Vkv7Ka3Q2xjdUwcUhcV", "assetId": 0, "balance": "-0.00000054" }, + { "address": "QZNDsvRdBSCHpgjUBwupVCTdLZfvrj5Cjk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZNDuZzaf5Ekiqmehz2gBCWPvWKA9tzZJK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZNb6HLXZbLMcD4uSYJcNJSFN7D9ecsRvk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZNgzddXxmV3d6oVes8yjn1qmqPMu8vNfi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZNidkrfBnQPBQpuQkvqCs8Qxjr8jqZ1Th", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZNjtDTuuz2DD5ZYFZA1MLs3kgErVJh2cq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZNqNi4MLXNDy9vSrAa6WqZpUstuGa742j", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZNtMU9Kd9CkRv4LCcQi4U8Qj4xFy5K9NK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZNzbWpwvR3w6r7ugyf4H1qZM78WyNj8NV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZP487j2QKpHpiQdqy2fE6iMxjRQJVycSD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZPHdAgFRWw5c21cQSsSouMTR8wxPVsvHy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZPTreFPbG3xXAcV48NB8q4en1zaSmRmtk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZPWHEG8AM1pvPLp6dKF5f7mgj1NMxetVD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZPnSwtfeNjKZpMZHpUBordtofmTpsHTMd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZPnUEYQXxXkqhgoywgM1NWr8gGZ52PxQf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZPqSmHr1kYJvrqUmhtRUgHtU6VTgRNxmt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZQ5m8iHToXdkhU7zdread2SJSk6ni3rHb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZQ9w46AeLXoXdi73sJPCC1MZ1WaGWaFFQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZQEi7Trzjh23rZbR1CysqUhBesTa5w6PF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZQLwgXtq6CYRyCTanFzz4e1fmavMv7TJU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZQTEnAtPcyuo1pCVbkHhsMPgBbgcvyuRT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZQgP1ojKVCHj5EtwwAToEzB5e9co3s7bh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZQkUh29nBPzwzf3cx4PXB8bLSVXGAnGxN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZRBPwvFBv59rZ4MzuPnjVi7cq5Uv7WqqR", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZRNeqMouWVSSbN7YWwP8K4Z3fCCAuiR9c", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZRS2Y8pj57hzYfJjz6UJyw5TEDC51wnow", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZRVma1B7Expt4j1V8WSfJnuoNSHx81ovq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZRodsGbr4BoCwMUzLzBWJegz9JcR2266f", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZRt26Acqd2pqSYoK594DTXnN8u8QPnr61", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZS27iAVr9G5WcoyaqqrteLxTYxM6UEEBk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZSeZ5vHPmmmQhGccwC5UeCaUdNARSC1uq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZSf1xsAnFHzb9TAzQRJHEMLtQhbj3mtM1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZShK7YQefWhuRbcmBj9dA6So9pckjoKnZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZSmu6QYmA71fdMfQFcUXMAjYnMXFnXn1A", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZSnhpNcmahRizWiSKTrNmM5dkP2QKqD9y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZT77Pqocj3wbwpxbwRoDxJENy7djMBzGv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZTAxPPbi6TSgXDQjj3KTXu16y8JZaysok", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZTEm8Bnay9vdbnWZGLrPzAPj3PBk13Lv1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZTLaWd1vpKQe7uGAxZq3aTzMq3nRA82fA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZUKcpub6BKysq7mtPMk59udhNcppVi1oR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZUVBAe1BUoUxbCofGF8MrzT3B3H4B66XX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZUa4V2hHVtxFnpQABT3UuXc5QYAi5r8xh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZUg6WXRKqpHGzM5KgkFZffaKjrnDPsRUm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZUvZ4gxWw74cj7Mt5o8MDUKpUiaZqcuaH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZVY73y9bHJ3EzH1BVRPudLJ8GUPL1Q5XA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZVYuqDUFNy9xt8BA8ED9YZCSKRS9B9Xf6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZVwEvWoVNAxFYoVWFzkWvqdKsnV78mSiu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZW8nVTMxYHiqQ3L93JjZgREd1RA2cE4Tv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZWFxzXhaaSzffyVkcuGZGAqTCjopLfnsz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZWL5atv3jQi3SdcQPS91vGhbk4Mi5CF8z", "assetId": 0, "balance": "0.00000652" }, + { "address": "QZWMbsEWMPZqmfVMibahdqKU2Z6MjYUtKt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZWeSPpyzyASiT8END4pxc9VJJ4Yb2NGd8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZWi8T6mkc7C2JrvyFHLUCPd7t7UBT8aEY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZWn7FBpCzmMaY3x7yYa1FFVM1XZWwAN1Q", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZWpkKQy7hdQFABs7PPqoAiNJ6H5T7ghS9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZX8U4wHvBbjWdvYQUXDhoaq7JwSSUAFoK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZXCHcJYiU2CjDt1354Kbtaf7SkwPwEBRR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZXRA1KywdbUrhSKxwTH1twsM4TAPztsrM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZXfUWsJ3vzEPZ61CpSCsTcvUQs8fgpnTC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZXo75Sk5AHuuuRX4VcHCBvcHaCHGHBVa2", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QZXsbmz6bQDRoZcHJFHHEEDGLuFxK4jwoc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZYLbmHagQ7hkNdzJSB9oY3USKC6ELZPR6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZYWeYegSg5kGLAs4F9YG1jgvVE4dH5zT1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZYiPMHWvmhmiPPYLonaE2zXE2BUEbRn2v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZYx4gvdD4fcJkxR4WsMXh96W23dWPVKGy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZZgAZgNpqsXDNmZzmKYTovr4h29Xyj4Bx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZa2nipNm9paAZrftxJq35DYeSkgdMyCUP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZabRSx5adRKRzDdtbNDtjo7PmK1rZ3nD4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZb1jPdakvcB9f7aVRU3wLXRcixgk8tPdU", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZb81oH9N6M4ZjPstDJuceARrdjLi8dY1x", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZb9RDXiYk74Mkw5pm6s6A4GTUhBSx3UxY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZbBJuoU892QYTQ4N1sJT9bVE3HNNdSw55", "assetId": 0, "balance": "-0.00001793" }, + { "address": "QZbGmHyHciZg8QmXBnZoVyuPaLbLs5t84G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZbM9vccKMt6Esk5GyFd3APnpPouHuWcc7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZbXeYhMo2cdcRM3JqJ2iYD2CXgQmzLbjU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZbmCFTydD73YmNvEFct2iqXZJ9uKYaVyg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZbojpiMFV3X6NxUgN9H1h6g6bcGkXghFi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZc154kL6TtwxBvsV7TE2W9qwPAW2Vsvgy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZc8ziakQ6qw66vkbTFfGeJ38NXcaK85Zh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZceRheh49b5PcepHgSMmEK1xkGrTczdbH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZd3e1DBRWnzK2ESQRicXkKw2WcY6awwKA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZdGDxrx257kGY5ECXHWQrVEKoMG12tM84", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZdNdU8GxWZzEuWz3wfSPWXmZYTpLtZyG4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZdNrxKoXjk8wcZitpBgs2oHpnuX4zSCG6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZfCQwYXGbfCkF71vr9weM8rU2TthMNDP1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZfKhvhmKuRianyDJmrUYLeHcYt1Ap5LDM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZfaBgaoJ8nvFHaHF5XYhNscq95FudCXQx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZfbJARQKb3a5E7N87zHEzh7FVFb1vczJP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZg12NdYPEEDgANuvktFQuZL816TXQNkPA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZg9Nc8r589L8DxVDZx31w2JPmnYPM6n5C", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZgVuakmyvEDFFkyKnEZaVcxVepTMKZnNx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZgjytRXqgP3A5TP8cCY1J1cksPyxYzVqu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZgmX8v3EYtnNt7VXNJpQ5bepdVV5dBk6W", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZgmeEXWAyArossVE1K1Tw28zLry2JH3go", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZgpMDQeZ3ReC13wnTvP94hVJoyAgVXEs6", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZhcDhqRZ5nV9xSjd4wcGCTvke46vE7v8A", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZhcMekJwqeaDt4yp3c7b11QMGFRALHLAj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZhwyAGLY6TFtjkqfXZLtmvPFFaXcmXvsA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZiMCvxMJqG3bG6SsET43zegm4mtm2TABA", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZiNhi5Xa61jQh8P4GRzZsGwcj1hTJAX3h", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZiVKzVjWJiQu1pkJJuKB4Fr4NBznmUqHs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZiYh4m4Uh3FH52cnow8MrNyXhSH88bp2H", "assetId": 0, "balance": "0.00000990" }, + { "address": "QZiZk494AmYp7SS8WeeQFVooTq56Zsrk48", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZjCgcSVvSRsFZeLJz9C5dTa36s3cSKqvB", "assetId": 0, "balance": "0.00000652" }, + { "address": "QZjdkN8GZggPGy9QF3rcJm1hPMjZUQEsr8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZjh4iS5jT8BLaiy2PghB6aDZ3DwbEdZyg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZjkLAwzW6vguJgBt44mozntNApSpPLHw7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZjnM7gbfV7Sonf3MCJtCYPkKXAaJVLMVm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZjvGiniqQPUM7uMeAHrLAruBPGkr5Y1pA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZkGZgdu4qVmKWwLKpFbx8sgWZ8arWVWvP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZkRuhTBVH76BiEb92UW8F3KwQFdr7qzHJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZkThgFfExognAbxLjYZGVHpL7X6g3EG4A", "assetId": 0, "balance": "0.00000005" }, + { "address": "QZkWZxhhHsALaA1nzKWW2gGM2WcSjhxhKB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZm6GbBJuLJnTbftJAaJtw2ZJqXiDTu2pD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZmEBDXwY3ik95r2xEM5zvdHNhu9KUV8cA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZn2mk6t3RfCp4J9r38Pmyfgy7pYCYkBed", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZnKWC6qdENvhBPdN9Cq83hu7C2NjmGJLa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZnLtDWbTkNbVAuiapunADRT5L6ByMdnrJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZniaDWZ6XSaTDv4xGbUCpA3ni9xGviHh8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZnrsxaVoPnJmZhAEwzis1jTAEbMpVL4Lq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZoAKKZUSiFRbaRGVYRt9xnPBvBs4FpMeX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZoQtJUhEtjXy7DAQErwUMgeVr3GQcm3iT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZoZJKj5vhWD3VpBBNmKB5nzDMuB5JGpPa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZoo2hJtwhi2CbAp5kLWyA8uqHTSbMpwSx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZoqWzc8XQTmHDUXzuLLsptrgxEHn4ZHpe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZpCNquYLc5B6xiUwsPtMB7M6f1CWcLBwP", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZpCuWFqsE3tj39zGG7TfUNhMPnxkgf42E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZpQ9KSuDrpvkDiNvZ5DjSB5zCjPFBksNy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZpfePhpFTor5B3Gvp4ESzGyMdJjaC5wzx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZpjpUJ4CTFJCLb9M7V937TFffMSZtHGrs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZq9QcgsbUqFsiEjRKYCYYCMp8wwMyhgbt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZqV511qELx3NUCCbEJUWUbBch5E6xhfG5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZqeZsKosr3LwvqYgwH6p52XsTAmutSqKY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZqfJg1raAA3AzuivGD6sCQfQQcekAM6tx", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZqrccFCSRuXGBmyyit6843xu64EwYXP6m", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZrHyMewEWUjnWzrjhsL8mSSjgJnqKvwnH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZrYMybpELkAE7ek69sSPHoVs2WsUcEiHW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZrqxWLz4prJmiUXjUMBRps8dFH3r3KNQY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZsu2zzVTZtnKKjgpvTHNi2qD2rAFz8pCo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZswTA4BfV87iR2us5wGAzQrCicDs4f1P7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZtRjut77UHrMutCLCtfVEpupJ4rm1MC26", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZtXtbZrBUh9jD2SDnWeZiyiiBU6yPNwRL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZtebdFopCUyGBQs5S5WYPckPcVZh19E4q", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QZtnGSfKFYsgxuZoADMFEsvgTgS33TkVRb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZu18KSxNAVDSnFoaspC5NPpQfYEXn1QqG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZuMTKkErCbg6yq2VZSzxA1uEsVZZH88GN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZuNFFtiXVnuBwEZ36x5no448d6Y8k6bKh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZuoDthEoyN8pVeKLEPwfQy3zRTmBYpkgh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZuyCJRgWQdtaDnh9GzsA43VWGrt9A46V3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZv4q4DgDpBnh7iPZxVVXJTuaKGucbMX9V", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZv69CURwT414kHuX4bGAjVM5KXyJax3Sm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZv8Nh7Zo51Kmf6gX31e7pnH5uxnvfUwyU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZvHW7amu5DNktsBgaMrR1brHZhhhVwKLW", "assetId": 0, "balance": "0.00000002" }, + { "address": "QZvnnAhdrqftTSU71j3CbEhRfrk6qpFN6Z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZvoHf48upkABYfhEYssvizA7G3iGP71Jw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZw7tgMttSySNMKfcMrEbdtnqHVrQ9w9fT", "assetId": 0, "balance": "0.00000990" }, + { "address": "QZwHxxStPk4TmMqBTc3BAQTq4vpxfGGnGM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZwJRAjwW4wWYbkwE48YgMCPYRjRYGetUs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZwvST38YqEwKCRCNbFyGjjWja1rQrbYRQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZxA1UepwPJ7NwWTKhoWwcSYHsYDSPf9HJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZxNth97o4UNw6XbDY7fnykzuKaxmmqaR1", "assetId": 0, "balance": "0.00000988" }, + { "address": "QZxS6tB9p7NRkZDekJob7ycYQv6zTRspMr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZxZWb7qmUs48XGHkP9rRsYt4ct3XtHeGy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZxpYGN37ATyFzu2zxinEnnpt9rhBWFAAh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZy8pHqWvCrYYsMqQv7M2Sks6Wvh5P51M1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZyB45NbK9nzDJefxtyarXMrPYG9FfVxwE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZyB8dcNqHsYbZnkibUCKkoeyzeAt12zi8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZyNKjGVy1v4DTnwuHeXnvgQieGGEgYc6P", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZyRbMtoLZ5anzWUjBieNLycRnCzKYsvo5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZyaUkLiYr47QGtixFca3NqbNDwWSijgs8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZz2QSwUTaxw17tUoPkEHfqY37ffs3fdJ2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZzFtAjDUxEzYWEcXJATazFt9T5itUnRFr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZzXxkhsKLjexZ411a4L3Lt9kCRSs84oFb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZzYtnZyFY3PT8Aqcaw7KkVDoyYkVEAKij", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZzZEBMBqgjWsK3ZkfnNbCu7EkZQ5XdneZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QZzawNUymFtk68BWSkQKQuYedj25PjKAxp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZzifUJvcTifrAjLyJR1Nr2ZAg8g1ATmZb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QZzxwbQZ7Gi4kSVa39bcXb3q12AhGRQDXA", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qa1EzQEk2rU7Hjqi9AaxcrWSSTF5qKaKiu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa1TnA5bZQLEk2PC5aDGX1rNRWzK6ug2Nj", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa1iitzpRGHzQ9cQqJPheq9EUfbj9qqroD", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa2UDGdfoyiAiuCUykxjNJLVb2N2TecXiK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa2uSt77LS7NVMzHvf8LZUBqC6PDDTuiBQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa2zR8nQu5j96EQpXrTSR5ha5jSDTdMNRW", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa34WUAcDevf4ktxhnFWSxcYpTqcNuMCzy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa3Pf7nhPts6rMTsoeEu23jc89F3DCLoZs", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa3RH6RgWtLMJmu3GBT7Pj1rpN45NNS18L", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa3ifZn9YD7Qm7rqbLXe2bD2JDmQJZC1KJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa3jCearGU3KrvXEEnHWgxbbfRS4WvgMDv", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa3n3L8ZCsQJFvhceWhpawXr3r6jRwiRY8", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa43JP9hnNjfSy1f3LYYNFhhSuMokUoYqQ", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qa44bZ1coz55Qc48T1wi4cZrYf3i8TMQxq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa4DNhEQGGhb33idSLkrqV5DtPuhErtnw8", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa4J1yimPu1ytQXLpxTiYC3kx36KmaT9TY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa4LvaEbBhra3hA3DdXE59xX8Q8fb3qicm", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa4M7pkhCqX4xpqUbzQhZXC2P4G2bp1PxH", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa4VMxDhmGH5dgYLiuFSyaWju8xb2fGZhs", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qa4cmwhFL7SZqyHSNkDdwuNK9XbFRw15PU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa5WEaMSumnrhaZLeAm2VFDpETUABXXS61", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa5d7m8yEjZ7GrnnfFEoqbTgzYdYCJGZir", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa5k8PGeKpmA8b5Aakf2ua6q3kpzxZLX4Q", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa674dqLCaLbqNEsdFjQ3p3ohTnKodsYxg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa72Ei3ANXPptt8UgTsPexMvJ5VzWQyAFC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa7GAcN8nL6wwWqWUutZdNkhw5exVgucuC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa7KtJYWb81dHjGXtu1hCPEffDYfgRLgUJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa7bUWxHWYnG6Q3fq7uD59qjzeZdQAFD6Z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa7pWLoewjZizdavrr7NfBNxUqaLg5vc2e", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa7v72qsEMcVXmYqiJ8eKMsSGY9YeYRgPi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa7xmYaY2rVLWrFdu7HAfNKDNhc1bDWKvQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa8DvBdhG8NxHPtW8MkCVT8afSpz7DXiwp", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa8MhYp4wRVKy5ZzCzinbet32ToQQgvDHH", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa8NSrAyMzzWz6MxFvWP83rDdGNeFgvJbR", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qa8pRawmcviX1BHQpNCt4vBYHz7HjdNfkL", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qa91WbQ2xDw7zJAysYkfnXKfVhU8eAbnhz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa9RdtUMEnofn3NFzNhiyNtLHDE6hP9Ppf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qa9ciDJr3oEdA3YEceGpmwh7B1nS7vH9Ut", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaADdpJsZR4ZDQ48fLk9sRi8CdSwSXN82d", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaAEm8JZoHEeq6bHLX12qzZcCDbt8akMgA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaANmmA6HWixB5rExfVVaAToWt7bAnkCAT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaAaitGV2AEx4PYo4wZCpBVUcAXPbMHekv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaAexvRYGSPKbijBWEQCctFAPGkbUG75ca", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaAirJkVcBbinZu8pA132Do6LesbFaGsCV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaAmw6tT9x4CEWAtm8CbrjJhvSgvLDsiC6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaAsiT6jkFQ1RUG7QAM2LfKTcUmYV7CJtt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaAvJs3AruGCzXbKf8ndNzma3PE4aZ1WPX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaB9QNqxtMHDfSr3iEfmCeZASUqE5XHkK1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaBZCXHNq6aReq5bzRny9uc4vUqbCWcjkV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaC2pigdn2CKqLLQ3TziN6SANUChXAt2pa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaC2tbynNdQkKHjPVpLxKg9AoAWpfGPNxM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaCKNan5a1hqgzWKtZx3mZh6HiyTXRycZ2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaCfg3mu8FgZvF8w62KVSdtN1pF4gCEm6u", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaDAVR5CrMiThUzZbMphgyES2GLvApLdni", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaDYnV9j6rNCMCDU5fAbSrz8sHk5RXaYHY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaDdN6DaWaQPnbNAgJWBY5SZKJppY4To2D", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaDdNJDNd21rCUUyqeRqtcAATP2gdxBwJH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaDvvhWopjRqorSMkBBLMzzJFNyT9S6bpw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaE7HmS3zCwEsEU114bNb4KtwCMXNdUhkL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaEBWATyEgyLfKNriGXSvaraHfg9m9Da4q", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaEQzgRAR4xjHvWwqSoA8fSojxSur8qXAM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaEbAYnpWLioYyREGUCC6vrSYb3u4VLjo6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaEoZyipfnyjx2qWudfhy9e1KbPyjr6SYb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaEpLG9g22yomd9QWuQatdx5VNnmMisAJ3", "assetId": 0, "balance": "-0.00000072" }, + { "address": "QaEuLwg5Nt4eyvU34SyMK5iFar2JkC8prH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaFLNKNz3K2BoMHmZFBSPMmxLfZTXR3Jpm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaFSiWFErXAY37e9rRHRySPh2vCwk5L8pt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaFoPxhJz55ZF2YoXXi3ek8tLqcaFYDukk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaFsjsbtjLXGnJFgtKBVKD9M5cfguMmUCJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaFtZqQsNwfb8sKqG9pxrfc1tcKgk73egj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaGZbxCc2VqNYQr6RQUEj26HZRTgsiN7P8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaGfvZARBNThBLfSHBbK9BUksmN2ep1BZC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaGumACNkayr2YGLHRGrAfBVy9mfr1ZQf3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaH7Sie4qJktsYfjgkTJWCTmVwgQYGgreU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaHHZ6MFFX1yyKPfDQ4nYWfEtEASWAbAA8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaHYWJS1q6haDA3FYtAm7ADcGXYBfKrzj3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaHgd8eHRbx6MkHB7HG4J5i6Pz8fPYJUVw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaHkqtLio2rdaCYwwWnfqoMsFWViFEd8ek", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaHnQwW5ky1mXtudX344C53DiHW7bkNZuq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaJPRUv6h12AFtPeQUvVTczLsSRunurwMS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaJRydSdUfpd8GactCbrmPtLN4Sg584Dfa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaJgrAoT4iXZM8R7wLvgqoyWsvXToJzCPC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaK6URQq4vwEDWyBtmS25kor49Z56An7xn", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QaKSQD2UEUrgCRfGo9NAvmPYjyNZ2Q8Qho", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaL7cgmaJCgnsaJsQCfiMbdDw4gEV9JDHh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaLWoAkjc7ip5Y38p5FX8vVbEYFHCz2zHh", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QaLeZWFXQjCHMQamFZymMXS4ADHGpNKbmi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaMAUL4fwpvWxVE6h4nBNQWKXHS4dXLHH6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaMNCkdcmK5VrQh8rH5ymepFAsMDEUaQAc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaMTtPVwsnQnCCJGzZWxxWTxG1qCi4mXTU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaMfLQW8T2dd1PCmYv6CV2vUneXDq21st5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaMy1UzL8RNQsPnMwYuNvYcJKD86b5cCwt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaN2QRHs2Wc5UwV4ZUrWzwm8yUbm3Xg5g8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaNCezwFF1R99eXmE8veSH7ffrYxgwH3jH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaNTuhLKrLwoHbBHFmn2Ka3ZwjXxa3P9Lq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaNaaoVED4nqWK5JqJFXKdUPxRYmThFcd2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaNbyMhUSFjK13QGnPJKZgdJ7DsXCqhCC1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaNqXrz46a8ZEEmPnu2jUhaC8pZPiyTAzq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaP5hSygZWHJfCe2Ukp1rv5e8TZmQdhwJq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaPKuyyQtXJcsVhKLKgxCcYewxwaawxLrB", "assetId": 0, "balance": "0.00000002" }, + { "address": "QaPLAXjQqUwQy75uDSE5YGWMokQFxgmFiH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaPRumEg52xNEtNzEbQ6Z57deeevZFeWGo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaQ1PsJh5HKGm15PJAsfcCB6dKvav5xXv8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaQKQW6PvJdpN5PVRhtj3TRPtyKY75dyaR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaR6gyQkPpgMqznKLfjtkk4p8gfcjhiAob", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaRVDZNJXmcGNoALtrjtizphwmYa2jEMzX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaRyo8X8ALJFCSNqYmWxXyabH2oRuZpZxu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaS7YDzeLTeueduqnSKVZJpEh2Zpc1EJHz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaSXJSHbQ4xmwaQd5tKJMAqvVzwzLvhddP", "assetId": 0, "balance": "0.00001003" }, + { "address": "QaT49TiyQ87xR4uufhuem6Ds9ohVFrn3MF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaTBoGjRcpc873L1aiQ9WnHKPhmfFuxZdy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaTFc1jFnf6CMgHnToSSpA9WQwxpuZxbdz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaTUcyvyTjBoSE3x9zD5FeoUJ3Pqhf3V9p", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaTrsFEj99BWuwpYtGZtd6VmLrDBghbwqd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaTwX9zspDx4QbUVANYCLGqxDNtu6LgVbM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaU7LHgDu7M9zuWJ769qQ3Nbpg7Y73DrWB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaUYeTpvP9v24qKzmx2wUqN6D7Fpkz54XY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaUciVnbQDXdNygJadEY31PuDEBLi6Spmu", "assetId": 0, "balance": "-0.00000001" }, + { "address": "QaUdUMHuAwMis6XQPqF3Vz5zkZsd85oBS1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaUr6V9JBSc5H222TDLLkoJeRDU5j8yNWc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaV7aRPcR7W8GD6YacpYkYyfuRDuWB2Lh9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaV7vtyf9Jj1DyMXwR12pU63ToNBpv54bm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaVEiUwMwX1SjRnud6p3xTTrmJe1BL5ZLB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaVEw4jFFYqGtb4y8TsUC6t1KF434wc1ms", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaVKgLgYfQWvWw5mL7oxBtLWSh9ddUohG7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaVN256MDPWQ6PmiD6VW2HWBkvWDH8pfto", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaVX3EMhorb5pzS5RwVzbsWEyfsdMnYsp1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaVcFGJw1Rccm9bmhEErtj2wi25Wz83Pxn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaVdH2hvzCYje6zha8cWYzjTkYR42K5zPJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaVendrTvEqaQu9e8UQ1a7D95nda2nXTK5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaVgc8GjMoW6CGKL7eoMiBhWSFaDF6wkdY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaVrtetoq7PiwfUfyx9jtZRYEaWQ1PuSvS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaVuPBXfDa4F2GfQDk5zgFdANFkNx1XzNY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaVyQR13cr9FF8XkTwwtuJufNk9p34Dv3Z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaWLF8u7MyZQJLrQRCk76dCZ7mEpMtRDv9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaWY5Y5E2GYSHoNAooZ1XibngcdhKZoYkr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaWyebq4XCvGafLW7n9qL1nfSozxLyBd4x", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaXD7E4pFYeTDYDGTQvLmdK5YZA9BpetJt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaXRRtdGPCTEcBjYp3sTj74zgRZNwEpPSP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaYP4Hh2eRjnSZ5t8jD4SZH4pp4b1SXMpc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaYykvYTNx58mJCMXKejErcdws6dkG6Xf5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaZCYGLsMNsHW2tE2DVJu5Bzc6qjaAxAmx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaZVjmsKm6u6Mi2GXzGvLs7bLo7MLVQsVe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaZbjYGgEcXGvyTZeqjWwBMCKgrntUQMm3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaZs97g4Mbq9tXMoBWbhw3jFvBBVkWKS5F", "assetId": 0, "balance": "0.00000988" }, + { "address": "QaZtjFR7xVqHSZB3xopdJR4hTYSfWtF5ar", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qaa1ftG8VSrbjMQgmdATcxyqZ4XuWa8mo7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qaa3LSRxSeWji8tKBsaNoaXqLLNjjcjh6T", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaaSGKxLfNNQh8mCRLUv4f1qshdQsNTDZj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaaUqLT2VuymBNAp7SraiKDkGY3cZxfo9Q", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qaads2ojLCURirY5NAWASL1iQEmvpKoGA9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaakSG9FFEpy83EukW3h8zdsXGH8vwfwEa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaayupMBzqoLNpiKKb6Zrm7KHigReLp9NG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qab3pZkJXwqcdJFy2Rn7zhGU6dFhPVz3fw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QabdYgUZgi73wxT4e4RwBUsSyCVdLFFS6S", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QacFHzkV265jd57jfTZ5gSuW8dj4W1ttYs", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qad5ZJAa35X3CgVeR6Qfbfn2oCKgQBBs76", "assetId": 0, "balance": "0.00000015" }, + { "address": "QadSr3t6mH3gXfd9o9aKH7exAsGsxr9UUS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QadSwUuK8LEum8FheQ2xcmBKqthugsiZPr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QadVAZb3yc78yjQwQDJ8bXCvFohAdEovu7", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QadVL7wnUyZwvE6M2M98yu52HEpM2QzWPR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QadWXRvcb8MXDo3RtT2yeZgLH6AcSy477X", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qae8dps8iK2s7pE9LYDR5V1MSVoC2pAJYQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaeFXCfi73Wptwve5R2RdFSUJUs2dqsHXY", "assetId": 0, "balance": "0.00000988" }, + { "address": "QaeLPkxd61neUCrStKRyaqPDmpzxzJMe2v", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaeeSqcYb3XbYWuwmMmE3hVfc3bJ6igCEh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaerduwSW4ieKFi8rr1nZVr2LxZX539Fo1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qaf5BHpXrWKK3dprQX7zcCXtCPiQdhm7oo", "assetId": 0, "balance": "0.00000652" }, + { "address": "QafN7msFAobWYq52UVWzLM4wgWiqnhkR1L", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qafe96JaPEPSgMc1skWPXWBduR6SfCx4AX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qag88FYXLZHFLqV1G6L9LU93F9WD6YgdGn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QagEhL2zUVcnS6jp28pHWdWTPsz4aNXgRf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QagPQ988fuE7ZZfmdjvEGuBXFt8kgK7PJB", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qagc67YPSTjcPTSPGeare2Vj2u734WJA6W", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QagngkdHNrfQzHF8XxhyD5L2UcLMSqtiEE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QahA9hatvsVJWKpzUKmhqYr8DGqiSfHxXX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QahBUbNtovyHTDqjqfv7vntAYVcsJZ5AcS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qahc2exwRCWHZxJLhHY4Hcbr9troWu3Jnd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QahemDqPs6AqFPQWqcPvGV2dJej7cWgepJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qahx5vgd9EjnFPSrPBYaTnzKTSjzN26AEY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QahxHxWMP8mhV9Jg6zcdSqV4cGk7u6qzGY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaiXJPEZ2M533beTU68aARJ2f6PtqNUyAm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaioenA1zwAQcKEtcDtxftwH3VXEpNw2vg", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qaj433HvsBAKFBa4XtEe1XpJboooSRKW9p", "assetId": 0, "balance": "0.00000015" }, + { "address": "QajaBYviR9WoMNv5zZru5L1m9XZ77sd4NL", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qajp6PwaZsa1xz7eGBSqBFkz8wGs7QWGEK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QakBm2bbZQVdnCFZTPggucAqV8ANC56hcG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qakyn6FppW5F9Q4v5854DN5obRt5Sdnjnn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QakzYX9JZyUjRYtXJeaQbirXTuUqMFdp7d", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QamBiZXLEyzwf39WXNB2LdZFTzB8ATViuo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QamCrPzmG7batUuZhfnbpLCkDeJkoR8Q4b", "assetId": 0, "balance": "0.00000015" }, + { "address": "QamiqoAfDvMTNpvhUtQmxPsAEX8RdDXKRK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QamqpDjoLFeueRKbZBHCtH2hZps797gVSm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QamrqpVMcCBBq6eNKUQg6cBX7M75Xof3bR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QanWTQe2EA5qMKQso8GKVZum1FoS14wzDG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QanYq81HNrintpSE6FPRVioHfhCHzTkN83", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QanuYXvZcuJPdmqHu8SgVs5tKjbMcyrBeU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qanw7Lt1k3icBaJowgAkK4oUyshhDiBJYP", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qao8HRgcgKgcjnqJr1G2GpD6zmzFJgU9qm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaoVMdbpPQDHfBAar1HfPoEBzDoFa2PjS8", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QaoWpEkK8Usa8SutBWoq3hwaf7r5YRLPus", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaojWrgHP4LpB7Q4TS3xXd4yLn4Eot8DCA", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qaozy89v9Qj5cXYUoRuCdZYbwWWUNpH1Gm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qap2xirfkZ68xQvky8DgW8EWPKnw5aR4JG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QapE6pVuceYdVKnHVePbeaqf5QNeoDKbqr", "assetId": 0, "balance": "0.00000652" }, + { "address": "QapHHqwGxvRAZuKfuX4TGqSULbkY4WD8ek", "assetId": 0, "balance": "0.00000015" }, + { "address": "QapPBJaPgMQu7riYnVSfVzrwGtSbKSKh8v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QapdAhZtRonavqGsk1s6NPbW3fkQS6j17o", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qapm91Pj8xRmtvEpQwhUAwzch2TTe1YaAq", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qapmzd4aDNEyYjw1qF1TKYbSar9YTNS14W", "assetId": 0, "balance": "0.00000015" }, + { "address": "QapvFZdMv6TXRdE7YudSbP4TBK5bgDARfP", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qaq4sV9wkdSSSgJtaPuuSV15tqsVPD78rz", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qaq7LWNi7TQrZbrchQ3EoDsxspqqaZhRZG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaqMFaB9YrhtbcXixfsjD6sXz4AL2PrbG4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaqVKWHra2NVoa1ATm7xvd38jk5qQDx9ZV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaqYbCryf3MUG6Q1oRs1refkGihow1ZkRk", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qaqgx9qLFsZY52y7NYsGLaS94dTfLHAzaB", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qaqt3b44YzNpwG748HCKufBwyE516t3FRK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qaqu1tw9cjdRBmPVn53qM9JSyYAzXk26oC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaqySMK9eTvjE3b3mrJ7HQZe6G17TDH4Uw", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qar2d2pXBP7NCe8mNTbzTSzAQ48ptGqrMC", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qar4W6fo9SUaPE6v69Mr7xBJMwnyuJWHQ9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QarZ9FnhNDhPpStqFdLW8xiQj98w1kktpr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QarivUb6R6U4UvopgqZG9cHGmw29K85kGj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qarz66R7PsyBVGKTPrdmroxXKiLG61apX5", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qas3KwrybpmVUELhBhiRivgk66V57ggC6q", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qas3sB758vibY7h7eGHfWPnYado86aRLnc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qas4Qg38j3CrzWnmsx2LpNLCzztv7FiQVS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qas9YPSreoHu8SBKSEDwWDYG4ELqqH9NbR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QasSZxrp3dCcLFkukc8VzuvrjcMyyRxXoV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QasrD9TxGAuWqnRpJxBBwwh7Nj6BHiA77d", "assetId": 0, "balance": "0.00000988" }, + { "address": "QassptQkgTTVx6T21EBhyoriFbfsVQpnoh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QatKXVumso7A4q56Rw4QXFhdn7pnMsPoSv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qata5oApMShnD4F1kcgSJMTiYsxTPSFW4F", "assetId": 0, "balance": "0.00000653" }, + { "address": "QatyfTX2fd4FTVTpNFeYWbbp1eX1RQxPU6", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qatze2Mr4AtJvffuFVzp6L2xycEwdv2p28", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QauAtqCmxL753qERp7MRU1fUyrajZKavGv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaufyP9EV2t6pcp6ynW3soe3VqgzUGXuBV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qauq3bdrafWWULXHAfAiXaBYpeMAKevtEj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaurZDg2wMi6DnkHSf8txEUzBq8KUJx2Bg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qav7pUkJVNX8Cz9HBxG77NRbgodNP2eHHh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QavBkY3kRPJxtvsU5yWuhUnMdDWvs4E3Dw", "assetId": 0, "balance": "0.00000988" }, + { "address": "QavJhQSG5tMnJDCdQP7bJGTA3YsU6V99uX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QavxXW2SQ5r5MskuKmLYcuUCv3ev2bFZCf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QavzMF32Xvbg4Q4rM9a9R5WVmQZt7iW4Fa", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qaw2eFRptzig9n9cZqBJJMZZPvTcQMi8UP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QawB5MesBratjs2d9EMnXnrN4EC7gw7LRw", "assetId": 0, "balance": "0.00000003" }, + { "address": "QawSgZ7i2LLFTKyPxQptk9gN526ihy5yZi", "assetId": 0, "balance": "0.00000988" }, + { "address": "QawToNLRakzrv3q8cTseZSsKMV68MUtgRk", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qawhyno436pmGozqKopfjjccr5MfTBxU1Y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QawiogyqZPWBzhKMeHXm6PjpFwr877pQbK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QawqppcSi63CtELXz23yp536EKzhk7Q4nk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qaww87dXjMjBFYRueAFaKevojs3QrUTx4Q", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qax1MJTn2zBvXyATAt9PPB4NCxp9S3dgvW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaxMTV7fGSnibyvVjRaX7FRerrb9aMW6SR", "assetId": 0, "balance": "0.00000988" }, + { "address": "QaxN4GQD96nWfaUokw9qTsKdcRGv9328ai", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaxW1pAWXCbwBUTnL65Jd3XLg8Mh8cxEPH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QaxnmTdSoRWVteRowWDwH5PcHkNUbn6d44", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qayr647osydPw2HEdmzfCc1nsNJdHtP1dB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QayvPBEiL1ovfh1KitJS3i62L7exCcikmN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QaywytB5dqQoDgBejmEhWXWaDgfL1CRBGH", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qaz1kKnSSzsF9kttZjxc98YDmor7JiDqjz", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qaz92Y1xfsn5JuwEtcQ2uNQi32GxwPx5SA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QazEsaZXMeYcD5WosFJetMwPjWbtztNiRs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QazK7m5rdQAjGNMyGGZn2VrTYdhjTnhXyu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QazQKoTELNaxvzwPSPrC9oSDsLWKrxaMr8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QazUyqttMnDh75yXGqFcFsirSD1mvT62co", "assetId": 0, "balance": "0.00000015" }, + { "address": "QazWFRdEL46Uia9oZUSdqo9VFPseNcKPnL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QazXMN84GxG7tm2G9LCp1qnDttc6mD82xJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QazjFRmbVZ8nxKhrTLt4mk3JGaqgvczxdQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb11xoZA9fGNDSkrfCNx3reT4bD6GPhdJC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb1EPMcyAuKrBBBArQEKRkVEKrzWYMpS6K", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb1ZjxpHhmMnmpbnig5jgmMgFb7XDComor", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb1zbSPJ6iH4EttuBkfT2d5bGuWjeTVD4g", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb2Bg5QeD58QWYoEVQ4zuraXnX1AxJ1bmL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb2D1x2wp5CsQXKayzpQ3h7dwqGzZqXVMc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb2VbWdrY2E9uLALmyan35E6H5ze6tBmxX", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qb2tx9NXLWQYBHvzXnvGXsFwhNGmHiEa3R", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb3M1dHjQbHVabXHXBCADdGi7xcR1sXkFP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb3SGE8hD6EZGnRi6WQYoojEUhSxPJ13hC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb4RZWkPk4fGFcLVjBdJpBUzkTX4YhR4tJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb4eYHCTfdV4gaLEpBTTMRFyKgrt5E4SFx", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb4wKvLUSG8X17JD4rWM555Uqy8kARuuhg", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb55mthKKfqzBTAwMXABwP7ta1CixtkCAD", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb5F6KX4Fg1LRM21QJF48m1EYxnipFfRy1", "assetId": 0, "balance": "0.00000655" }, + { "address": "Qb5rPf8rgvFefx4XBNWaQvMnNtwf5BSxRx", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb5rprTsoSoKaMcdwLFL7jP3eyhK3LGqNm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb62A2Qgwt1CzYqPvMPwjtD6E8xx32nPo9", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb6q9tFheH4dR9S8N5KX4rEqCqsfC8v1Up", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb6v5b8Y5ScBw89iwnTg1re3erqVy5QbY2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb7imnBnr6V6nfvD8J3CSzd9ZtXjXxtomr", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb7sUS27YKMxDDRU1PJktfkR3DKF5ittbc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb85cYWF8dcTDYKs2Q3V3tvm2EZN39tfsS", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb8UDjXABLoikvdAJqwCbgA73dzPdV1x37", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb8e17866gDHKoPXPysWr47UA3kzxX6ZMh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qb8gexr4fMZAEPdDzwVVj2rLpTsxBirj8o", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb8myxE3KN3j4Ss733N98zdewPFuVJvL8t", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb8tNeRv6Fy9ef52H1ERSHW3XA73bCgCpF", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb8z1PsKYHQKCuVCR9z8ckKPwwAHs3KMRk", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb93qAATSpZa9NXD85nA8CxMVY42dovAhW", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb9GF4L4Kzv8KKModenXc4bUf2w4MuHS93", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb9VazNiUwTtMVaLUzKfoJpkiQeHiDYbHV", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qb9Ycc3f6KUyWPMBGgeEczy4HorPFfy2hj", "assetId": 0, "balance": "-0.00002737" }, + { "address": "QbB5AXpiRMaxvd1DCYKyzhP43Anh5jvjch", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbBMsJvjo4ZouPPGegxRs5kqKuRzfLRYMU", "assetId": 0, "balance": "0.00000652" }, + { "address": "QbCSMy6caYNooeRV1vP6qdmaDoChjsaTeM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbCpqgoVXtPLhwzUkh9anuXTzTVdCahE3A", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbDJqN2MLYHLAysYnjSFwVuGGDAP281rMM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbDfftDTfAGx35jTwPZp6Rnt3tsoTGs7Tg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbDz8xYs3e5p4rw1cPR3AGpv3nGb8kN5n6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbDzd5uFovPP45RBFfgJPCUCTsPPcV579y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbENqxTcnMkZAopqZAbSzefeUuXQBvpXcd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbG9Q5dWdFnbiDSCPP22ScT5fmbDQPYK2M", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbGBJtUMRrKXtaxTMSiCaZNYr12TmGQQmw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbGKYTojZzaFGT9njF9Dh3tWn46sE7tz1W", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbGRxipxhqDLgzwdZMVAgjpStperd5G7yD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbGatejTsxcFrpWcC54hPgmQ6fmcdx4NaS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbGcrivvNjJqfCrX8dh2S9ULbVEL2zhJh9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbGhhBSZVS1hY1esT4vMUC8vcgMX5HEoJg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbH1euWV5zG1uo1ukL7t5x1QiqMNNLpnMk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbH57VT2LaBA2UurpHyy9iBXEXGZrQguxX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbH7TsBhYxMF7c2fYrLpzB7iG87T5RFj5L", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbHJ9dofXo6MaDoqr5Ed3CVkwdXqkwKqJv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbHKchtRwBdnHW97ENfeDyyuGksn39cSef", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbHLGC49JoxmdN6RmEHkWHALCtss6yo4MW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbHY3KGdVzCe8VfcNBLGTAnvnAj3Kx4k5E", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbHa4uSLSzZxSSAp5T3eLwaDj93EEdz3aM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbJ48V1u379rBt5V4AQdUcCvAtF3DLy77T", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbJ4f3ffsfzB9mURc26yfzCmenjbD58mv7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbJJho6sTHnqL2ECivtfUrYZTuEgemEha2", "assetId": 0, "balance": "0.00000988" }, + { "address": "QbJNcfwBeaiWgrn95rdLEGdqRckFh3zLGW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbJQCNosb3r6DBByufiCpsrACiip5kKsZf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbJhMqYk94FExie11Vs5y5x7CNUS5e5b5W", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QbJnCGgpmVgRbo2peHdn6C5mpFMhHBNxxs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbJqEntoBFps7XECQkTDFzXNCdz9R2qmkB", "assetId": 0, "balance": "-0.00000054" }, + { "address": "QbKvQ2fPHwENEx82voNDNtu46Nhhoqky3X", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbLPi1Ac6zZGTLURapA6YxyiFYVXH6uZYQ", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QbLWLr2m2EcpJq7Uv6PxYULvWzJeFtrW2v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbLZBUGynobKwDhe7EoqUWYekYXHZjzVVY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbLubaU39eoWMiUtZ1JV1dZksNVwnQyBYN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbLy4NQJr11wLxXYbo2NG3WRhZsHZGZtbx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbMEDVAdrjCB3hmxZKpahLdaX1QmwrHkcH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbMUHipmUCuwt3b4DW7RfLFEVVUziySXsq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbMppCoLPnXdzBQBCqXaD1iCBLGKVSW7Z1", "assetId": 0, "balance": "-0.00002737" }, + { "address": "QbNVmDzZHPzk5c6knz6Zp2XGsjVNKLBdd3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbNXLUbDJYDcupsSyTb1rhegEQP4zgxCSt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbNaKP3udSoKqgRdVR5uik5tb2QrgmyY5w", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QbNo8tVAgWLtHeYAXYcnewXurCMtnW14RE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbP34AJs2ThVXwXsvWYEZ7pXxaVyXgMHS1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbP4jNjmHbpkE66HxxqHWiWp4e2bNaffiX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbPGzVLU7B9TSrr3fde2u7xoyQfRUpk8st", "assetId": 0, "balance": "0.00000988" }, + { "address": "QbPM7sKEw6YMXuSYHT9wEtmNjcTr8UttuA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbPdEcneptsjwBDvFnYzRGuYUQJCpJGwmy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbPjJHHEeGMfSPS1ycZtRjgfh4nQwPeaAM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbPohUwaqqdEN4sV6bYBRnBwasgKm9Xb6k", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbQ2eQ6CNyNp9y6Uds5Qp5iGXd3r6bpHCk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbQQhQM7XdoGPPjJG1ffNwGEU4tmgUVSyb", "assetId": 0, "balance": "0.00001003" }, + { "address": "QbQeVZGZ2ggaC5p7sRrS2PRapBYH2ushQE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbQip78RA7pZNz8CEZrjHLG3Y1rGrxqMmM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbQwbTPooF9BBZKgXK5cYLNnhZEmbqxcCD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbQxDsgU5971F7dcdQywPtAKtx4o3ypYkz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbREXibitUGtvntNAw9eSExP5m8a2Cib1W", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbRFvU4d4p6PM6PaVooT4aBK2h2acDfxRw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbRSEDYTi3AkNuhVRx3nbrfYucUASzJq2f", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbRr9YsCwD7sFLexW4VG3XZM7ZaM4FtJHu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbSLbuAxRMqe9vdQpmhbannkJcieXgPfnY", "assetId": 0, "balance": "0.00000655" }, + { "address": "QbSPEgwv86B6nwg5besE4HMpXFSpFx8kBH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbSoJpecPJmhSesTcvpqgN5Vc3zFtDiuq7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbSpunoYSf63XWdLuTDPupNwbYk2spmEZ5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbSxzcPBjT8HjA1ZMuEiXwS1YnATswUFux", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbTFmkEd7jkW4HjamVnpFKCYTAcb7fXsGa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbTJ1UFDfM4YVsE6qL4hgrwy1nMrN99RYx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbTXp2pNAaGD8Kaf9PsGzHt6HTYzmYxiAS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbThbtj9gvhqxSsUcW7dhdpKqqMH8M6PHD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbTnxWmhpsgM8Je19XtK8X8iAbYSWzmr3g", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbTtRZLX29sv55Tkd1NjvEmKNR22k8Maex", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbUSTCbTkdKgaMJuKiEfJfa32cXTy4sHkr", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QbV59A7coxzrzcUck4fQYPetVQwNyBtcC3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbVJ4KnGPjw3jnijrmtYRphQCqzvtkvXmM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbVmhJ8W9h7pF6crv3sCAoBEGaz5VDygs5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbVw36U5sZG1mpTNN43MuHsoPbiLPW4qec", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbVycRLuf99Ba6LmFYqrELUXPakejcZkzG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbW7Zctkehn8U5s3JBVARYZZVyAy72rRFf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbWBLepmPMdanFbsoxD7jaJZQ1QWJ5M7oq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbWEbBoAMLU7vuBrk5Vu88frj24NK3J99p", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbWWPusZ9RUcR9fKt5zmLMtvVLgzo8RFKX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbWo6UJ1bZdzjUKMwZwbUc6mzKXCfo1qNf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbWpiaeUZsR8ZjKm2ZsQDqNETEecyh616z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbXjh5buBXW68AmJBUW2URV4YnM59vMhkP", "assetId": 0, "balance": "0.00001003" }, + { "address": "QbXn25Szr9E417Daju6rauFrtx3sxexLiA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbXnFZaXySCGPFzqG7SoWrB1NN9iW3sPjR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbY4qRTuHee7gX93n7RJytxNXJhMeQcdCP", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QbYMdUUHxG6uNQSntoRLbrXdHcDgSGF5oM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbYTowTHCr9WzfrR6b8uDfJKwL41nG1vyr", "assetId": 0, "balance": "0.00000988" }, + { "address": "QbYVbsJ99wWEDNn7fGNgUYuSN1fk6y3T1x", "assetId": 0, "balance": "0.00000988" }, + { "address": "QbYaYDYjTDohUtsbALeR4PQPUXL2qYe3hh", "assetId": 0, "balance": "0.00000988" }, + { "address": "QbYwgv9BRTJPWbhMWMpfKWCWoN67WrqxFM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbZ3deVmXojFZccTg63Hp5HePjuAxHnwiC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbZ4SrctpPEkWmmQcsFKvUFxrLUdPc9hUX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbZfuLBVXYB73UnqCM4gEWqaKmrHmMfFXi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbZhLj27mpLukmAf1gciHR5JqNQN2bhmxV", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qba4bJ8NWxS3eSYiDSQPNEjUY2ZymjQcnM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbaa2FT3uuL5QqPLKgQJufAZSZhxzACWeP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbamBvXkMkPDc3p6Uiyxipz1zPUzTJZcYV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbaquxT7RrobTNQprJp29DoBGfAAqfLcar", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbb5dghQro1ijDHg6WbgrWxeVWuXSm4GkF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbbFb6F61mt5LdfVZff5akDLp3QwzYMXKR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbbNJCEeC5BA7sGipFTYfUFQtxayrFVnrT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbbQcgF2iCfTCWQmuYTgKsqRrNWyEaGhgU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbbU31f6gakJnnbqEXxUJzsFhCPjmDVdeT", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbbaj3zGB2BjqXp5vQFiGHzRS5HNkebYgx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbbggSETMFdxcRWfy26uPBMbWh7SXtF8D8", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbbi5WvQ3Eemcbp5WJBeJrbtKF5HDJQDnC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbbsiUMNMdEvvUQf8nLvLfvjqtSSk2oKto", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbcQwkRAX1wY1qYQV7Dy7ezjQPSWV6EUZH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbcSNSoNPkdn2MU1Zjp9rsC2y2mEfW24nS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbcWEJRfPMPmM9uTx8JnmTMZEHUDZyDZNb", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbcy4uyMkQF2JXYqGkueDiFNZ4tHjRg8CR", "assetId": 0, "balance": "0.00000002" }, + { "address": "QbcyGou9wgUtJQNf5YBRJ3jArcZFVUxCwz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbdB3KDJfX75uU5TNjJNjzERCpLtq5fHpx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbdGxbe5dRtEpeE4xCqeCeBBjF8S1ZCrPZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbdUB95d75zMfb3NjzYPaj6ER4kZv4xqPd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbdV2vipqMui1eQnj9ZudQgw4e8zRgQ9Lk", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qbe1FJLcaiQCBLAiKBNStKy9ZaudzGi7wX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbeZjTZz4rrebhioZwfE5FMm4YdntejaQG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbedYfmiYPD48nY4HvdNzHssf1hR16x6Ln", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QberSiEskiKVVQpUYa3SJr3AmPDw4HqF9m", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbf36rq32m7y1QCuJ6CMWQaR97ib3vfCoU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbfPd2TBWZifmYGnbs3YeKtDmN9Vma72bH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbffE4HwMxKp6aowXiygNELcDtm1ZLZ9RT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbg2DU3tF8r6bHAriUaADA5W8kWRnAqJcW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbgqoDSrpuaj2sMopgmYQXyt8m3JG8G7TH", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbh6gWsxNtcaKq5sAq4NVkCTXuqyA6pbUm", "assetId": 0, "balance": "0.00000988" }, + { "address": "QbhDXQoe9bSxcDVseLBu2NdRgqygMiib5m", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbhMKN21WR8XH2Y7yF3qHVpvHfBRAXuGBS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbhQiee5RYi8ihLgbtyqDEeDYDsU4M3BZ4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbhXbQHa8CGgNJnEvDCpbFH2rfaKF79Rwg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbhefy9GmGaLv4xHUv6h8eP1HWhG3fAbnn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbiCGUV6ZU2s7Pmzhzc4VALaNwL36WH3RB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbiDNqpZ4soitxwpCoh2kMQbxn6z48vbke", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbiRvCtq3QWVSxB1twGwDJNjugbcZocdBF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbiTox1vp1NazUtfPK5FLceq2Vmf2wpWnQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbiYsRBck4iLYk5hU3ZrsLcnLjPV9Ym2Xi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbim1H8yCoEUjViECMwvZBt5gtVAampgNs", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbj6yx3BWRZ3YvPbgfFnVGyHG7iuyHSCco", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbjctdzpVuJgtHkKWp8XYFaGpmfMckHqq5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbkBrdNxNkFNrdh62SVPPiwCUACUjy56Lp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbkNEHvpz9u3t8Pi3LUYzNyDnn1NeyrENA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbkVRL2egacUUrD615HBhx89aHbHioYWZm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbkizShsUkVt6LTjjkBGwKcJgXrLdA2caC", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbkj6vSu2rEKV1wn29ASeMVrpYJsRTccLi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbkpikNJ3Z396qTG3rEKRHfc73z2Xishmm", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbkr5DhXw3dqHEuWc415qd6ZJ9ywCtRUmT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbkz9311in9o7LS3ug8C1dTchUg9Fxtt6H", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbmEaM1ERsZGT1jkGYUcsyHJMSW8Kk2GfV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbmJDoAJ9cjRNM9AuMv5AZc4w83kqosCYS", "assetId": 0, "balance": "0.00000001" }, + { "address": "QbmRj4LfG7Y5GQhdr2pc3sDXNsLdJMMKLM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbmbpWWfz7HjEy1ixWSQxqFyuJBk5Sb7WB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbmkUDm1N6Zb6npxpUkx1dibZR6ZnAkf9Q", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbn46ELUuYkDCWXpw9WbcS4mHBQvVLSB69", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbnFCcR69mUKJrfjSSyW3peXA35HXM1JFK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbnNR757sVecebqegUmv8JLGXVngUvrvDH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbnTJ2nJjVxC6H9fo7Z8Hax9MkYNuzrWic", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbnUepstbgNJYr3P2Zba2d7mi4wNPovp16", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbnXnUysuCw7C3UfTbkXtefzZaCpuYGdyv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbnizezPemhpQg1roAMs9MAvJVw4KHiSuq", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QboAHPK9oDvFV55nNjvU4G1syHoGATtLUo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QboSQpmqGqWW6WPS1vRZuZ6wKp3hFZTuGs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qboy1zgyDqVLDywbs5eiUcUnxdkPBKPMK3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbp29FCi74EfP7AVm7te1Zr9HD44NKFjGa", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbp6SkqccuuNwNA1n4N6N31fUhizSWhDcr", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbp8CZLnBwphPGwqJGm91LTaFJ4mkXZLgg", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QbpAjLiUWfeAskcgbboHWVHayHie4v6V6x", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbpTUUs93pB2a4z7juSrSREB5wtcz1ACxy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbpmjjyakGzF2S8TaWp6wRHL5xebeV4n5Q", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbppGQsNbXtY1iBE7kHz9bSVTceNnGpKMQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbq1ctcvwnkChPmU9PiH4fAExbgTv3fBm3", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qbq3SK9i9tfSrYxg4B7chST5iNaGyyKE4d", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbqRyFw7Xu6Nsb4FraaUSe7nUPukuUpekG", "assetId": 0, "balance": "0.00000002" }, + { "address": "QbqcDwnC8ha5FHBaJDG1HyCMAwcMWyUKwr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbqeuDi36HUKFdZ5aRHMwrf2zoDYNdsBVN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbqu5XHrFmzQrSMA1LjVKjG7KKF9njJBDL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbrRqaS82aBpmbhmgCMcnEQrXsv9eAxL3G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbrTcWJWemcRq2XhNkLdVeFwjBJdAavSBN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbrkUM7P9T9tAEKuvX2xAumV9zNtstxsKo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbrkhkJWRCBeM1bAh3N3J5z5Bigrdjdow1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbs2Y2kgv8FQGrh3w7dnxFAWotsvxEoeZW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbs4BQzip9K7ESbGTdqJH5RE2UCCDXr9dz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbsHDs1zZpnT73U5wN3FfCtrKbHspvMwS9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbsTGKdpDYRDEeTzgrCXqNUsg1oFhktydP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbsmRD9xpHFPen6UZ3gkaAXLnCM8oXRbkc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbsyqGYEqBKve1rLCrM4v5dXJaJNrUJVqt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbt1rRB7A95YjBh46qk3SdtYy15KzuhjvN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbt9DbwTNv3x2H2sXyvZtQPqEeziWSSueX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbtEhAvV4QcY2CKiLuH4y5xzM7RSdKXey5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbtQSaoxfvo5dhgDqaU37XsMfhWh5HWCZF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbtRnQhCoMo5a86dxceM4mY4VgYrBskSmH", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbtut4Z8a37Mokd5uvsA54WXfBHN1Ho1Kx", "assetId": 0, "balance": "-0.00000959" }, + { "address": "Qbu1J7P3Rc2XUuQEaw8TvFVaqYVnE3XhPe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbuLjPVvGTcP3PPntxr5qQyrXQ1Zuqiwp8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbuXoHEnFZAkB2T1iC38Y1AxEe1UDy2Qs7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbuzgwT1cPa5c6x5M6gNnX46AgvWWM2APE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qbv174reodRCyUjxRuVvYUWKPHbnqTHfmb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbvqPs4cG9hx58D1J2dP6A1QUmcvxW5Qvj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbvxC3ENqomXp11833APchdjeyCNd49nLj", "assetId": 0, "balance": "0.00000002" }, + { "address": "Qbw8zMRcP8SQzknbU8EvpkmqXbagiQghxM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbwHBPssQt2P3TwpNXHKKJ1nVhG2JVFAe9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbwgaEiEYNDsi4bHQ4NPx4qTHFdtXCovpr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbwjZJz4UrnFW42rPByrAkuxQQXz2bw6uK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbwtUqWUgjngt8yZXex884K4yNeRmWwjpS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbwuEbwjM6yG7DXCBQDDzZvA66k3LTfFf1", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbx8G1MEocPkVijxKiGcEq1dGNEfynoNja", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbxJvwrEHZs7MDE8rbqBwZAZkcywue5F3W", "assetId": 0, "balance": "0.00000988" }, + { "address": "QbxmA2goxRP5sbio3RzePtCj6dh5oSJ5E7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbxmYJ3MBHozBkHyH9yEZ9XFMmTwhDkqQd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbxpSQiCpyEqhzJU9t1nSBQr8Ncn7y9oar", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbxwpeqyH9KxdBVJssjviWrkhoMK9P9BDS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbxyzSEvtMCE72mnEvbCQR1QtTrWP4dg2L", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbyEu1BjDBattEpDVx9NufD5KzaWNhpzTC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbyjpGLeT8uf7F7eiubdfEZ6QqJuTp1ZR9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbynzokADEaQktnbUTAmCaPwt6NLCRKDDK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbytPsRT4qGbAoRAM74EyB3eJmCN95CC36", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbyydhZeSjTFqcdirMCa4qGBjPyqeE9xc7", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbyz4kHR1YTsXbcfsFYWAgowzATUPSBfsM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbzERVYhUEJKvWRVpEeiacV9HcNxjoCzA4", "assetId": 0, "balance": "0.00000002" }, + { "address": "QbzMAsvRVNBZa7sPD4WnKYSiagAdfJuMxC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QbziBcR2knCEtyELDkCdCaEv8Aob6v1Jgu", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qbzkwk6n3EM6hhmZo6X5n3V3MosgD4CFPL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QbzzgxJfe1VFFm9FZysF1aTDUzi3BZfvBm", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc1K5zipGAuHnY4uk2UwEZeeCjVeTi2GK2", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc1RA3BgR9y1tjQiT7UgN1Di7PgzK15GWp", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc1fEXHGWP3Cwmt6bS8qXcLrVTCHJpawuL", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc1wMMJbivCnM4QjvgJDWqmSQYUfuswhts", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qc2MmicXtmDKekajqBrapDXEUVgjZvVNYs", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc2iXE2K5prxTrg2bixMW2ae2nf4zMpS8P", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc332tffnVbCnhzxKjzN52jLwHZYmXHJeH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc3ny8wCmbLBPFYwZthmmrfnEvmxbp7DFo", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc452BVT5H3Nsb3oqknefCgWXNR2qhGq9X", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc4MkWKyEoiLm1v23MKXqgZvySxrPcNDed", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc4YrXXcaUfqYYvo6AM1qR4W3qBWziUuya", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc54dt4km6NrxBMvtEX51jKiuNmHmzEuee", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qc5FTyKc9MmWq5MGKYfvZh74gk2Zh2M8YJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc5fLaLNiSXskydRGnWLL3cfdfBJYpjAn9", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc5juh2DpuiZttYa7vr6Qb8ES4dDBhRAH6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc5qVjqnD5SjwshbXCX3erzdQR7DUrdhAv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc5sVri2bAy1jLVreReSbxt5Ja3fFhiY87", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc5sZS1Vb1ujj8qvL5uXV5y5yQPq6pw2GC", "assetId": 0, "balance": "-0.00000072" }, + { "address": "Qc63gUyaGXbZ92z1D8tKGnXayYEHwzYKXK", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc669EPo2mEAwrDF3D6gnvHtSsDyXiaiQ3", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc6HyzVxxr1BZh9yCnwqrsDs1AyNEZhvf7", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc6LqZHsen8jJnrq6NjWfeExiDTuN9Wy4W", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc6mWVk6L7EVG4uvhXt12dLrXnCFZkeX65", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc712jgU8pgubc3eueksgQDaYnfkK9NQYB", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc78stsmH6Q9jzjFU72CCyHL8iRAd6vsZt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc7UoWbMp8V4bijWKZ9Mfo9sx3GRA1dfUg", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc7qaQVZG84QfYYKM5DZ9d1RYEgFsJ33bU", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc8LVU3qujUZXzg366E6T4S66Rygtihtsp", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc8gWP12w2TEqhWLrgKvHs3fRTaiQaFnRb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qc8v3Kpe2jQmfjgrmT5xA1e7vq5R7AmjUy", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc9ZoiHpBD286gngHhziCQbGAzA7fguu67", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc9dZchoYfc1eRJhSLXR9rxSHcqNB47Dex", "assetId": 0, "balance": "0.00000005" }, + { "address": "Qc9ke3Wm5J2RrHm9rZcGH6BXUwyi7eY8ji", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qc9pBCU1tMLv4ESYg5Zin3Gr7TPSVUaxxM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcA6tRA7coF4NLWJ4tm6Pd5PubXRLGznHs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcAAbUc2x8W9Q7Pjto4BzMFGL6T9CUKM5Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcAL9GEywv2iUqTwVNtXG6GzH25hJLAhtS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcAnotEx63Qx7voswNvg7QtfeX2XDuWA5x", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcB3hShNV2ZLoKqjFiTeBigzBwz39Kziez", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcC19adAPmwmCAqtjQHiN26hiPbQnZNcmj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcC6EwpABoJhrbBnx9R5kJ9qhmxKrj5738", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcCBVfL35rxSyQ416L2MBz14FYbNrbeNPx", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcCL2sk1nLLE99HgqjGpqLQbCwPtSozBx5", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QcDhUK18qJcYeDq1yyb6GAszDgUxyu1w4A", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcDpSQDidTgaSWyU1g37fpgW521QE3TNyK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcE1XRsKieuNhBti3szvhYjAshY2BcU9Za", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcE9kre2gb7T8AjygpVPAh9g7VdXEMQ6QU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcEUe38fnNTQ5FWSLYPDdd5VFSYtop9YHM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcEUkwNG77T533JSCtZiPtYwGa3gSVzRM4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcEpMZ9NUkLcEv2aWw6FPu9f58CSKVSH8N", "assetId": 0, "balance": "0.00000002" }, + { "address": "QcEuGsNTwQMTQw2NxgS9MYem2XykNqMafs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcFAUadVueojfXHdii6zuJcufn1ooBLnWa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcFSRBWNLxUrHPw3RZxPA6hJyRxPLzqLBk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcFZ9yCvGESF7gi12jt9XF8RY423c6RfLm", "assetId": 0, "balance": "-0.00002737" }, + { "address": "QcG1yEskGKsvMB549AktRmkxXD5bcK3Wq7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcGDsVp3Pv1xznuvaKAUCeC776nuPSr4nw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcGFjReZ7yjNJaCMF1SfXbdrPCGeZhdgCv", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcGdwubfgY14EPjc7peuWn7vz8tKZbWQw4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcHF9YogbuzZhG4fK4116pgE2qrmbkGh2n", "assetId": 0, "balance": "0.00000001" }, + { "address": "QcHJhNjjGe1eM5P8k5RHR8PSJ7jFiK6zJ7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcHUjJ8kDCHm3mFAAGo7HwEGFMDZBp41vy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcHqbMv6se4xESPTncB9BtsDHyzecGSPRK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcHuAjLXhkdGsmrfqQYEyPy6kUTy1q3gUy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcJ6213dKZ4KUwP6P1HgXpLG28SQyV2eqh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcJYCmguJh8psmZJdVMQ7bbaWhBmXSY3SY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcJixa6aFdvkdPCw8BiaeoUAMQcpqBD2wD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcJm8mZ8tQW4KEdM8K9qxhtfVApdtZ6sx8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcJmstRsv9EB5K8v9rcrQ8rZaCZqDyS2is", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcJwVCyzraPy51uB4xd4f94n2UFYAsznGC", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcKCR29YmDroG234NHckwUMPuaXp4vUEMQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcKMRd52VET8FvT7N6LwqppXEd6gZo9da4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcKRmAfb61sBWpD98BJfseuUmSctuUm7xo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcL4MBURg6rLWYau2Df2HP5aMBk9mXfeHg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcL6nbbM5ZkX1wZV4d8VF2HAvUBKLM5uKw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcLAFbR1mtgU6fwc6AFsMdcwqq1hUCPAey", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcLJ8vFMmq4qLJmRZC9nLSzUeCXq1MfJf7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcLQoFbbSdBhiQBCxSBTRR1A5jkBnYXXJB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcLw6FgPafipKYgWWWwuueBp3gC1hQiZ55", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcLzrbRofMy1aCjRBDhJqTV8Lc8A5d1mCj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcM2exjcS2TCjAXmedfo67ZdGfL7DqiGVu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcMy2eU5jhTUv21Q8jGrWPaVRiQda1iZdj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcMzhPBnx8ouHSooc4qp5r54NiSTteZ3UJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcN4onSVDK5PYQ9AjvUn6tAHzfzmM1JVdF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcNJMVjdMBj1dxfqQzamqtpTLwrAruDwag", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcNN2hxy1AziwF6KrSLNMhMDTBCyDbhTLS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcNNFDYGLC3pv9ocBL57ejds4fkwG3rh64", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcNmqT8CZ6zSZwuRm5LahRZnuGBJRnPY8o", "assetId": 0, "balance": "0.00000002" }, + { "address": "QcNpaHqwZB7gNyGznj69aaBidZKmNcebWo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcNsjj5kMdbxQYem8jtNcN4x5YGbRo76bU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcNyuekPFuebtiZRmjzH5LjLjqg2jaVnuH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcPFZfaNa8uLp4cdhekYDGC1e17igd2rYx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcPPNyDKGk5vQfPEpXQQKvYeidvBZ9T7nS", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcPSK7GnrqSEJEoxqTPKbKDvfYLTaoD7sD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcPZtywTD1hqcdeKEXckjtehvGa8cpXgZt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcPro2T97Q8cAfcVM4Pn4fv71Za4T6oeFD", "assetId": 0, "balance": "-0.00000054" }, + { "address": "QcPxKYyUJ6pLV6GvkATf8u5YBcqJUAmake", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcQGQcWoMp6FRLNjMwi9HbeFEsbhmQxPPG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcQPQmeU1hw8fWQmsBCKEuxg3kRizaQYUz", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcQYwDkyC1k9ZbWyfybboauaYjE6PJizjo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcQkc1nhWNuiHEhmuoUyzBiT5FwgpmTVD7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcQmYZTydm25v9wWufy15EoKZQJp4r89dW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcRYGiF4ffxMUq3CGNrcFP646KbeCcnK66", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcRZ64ZgxuACvQc4e4CtjPfyPBKAxeVfvG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcRorwVb33AXLofva7hYnDHsvb3A8KN44M", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcSANW32yQjKv5mP9TT3FBL6KGvBA61ciz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcSTG9fpRBbMtf6oyUScGENoBLjNpvLEFS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcSq2VgDRfgRxSQgPvur8PCkLR7BuQuyAH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcSsjMzbk2eRpbQHt1yMJDZtFxL6BoqscJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcT1EG823qo4uv3D6o792ji32ociJF6Pt3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcTNduedy2aWxkwmeCoKUP4t7AcUSFYGUi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcU4VhU9ohDXU4k4AUMapgJRYSzEpizjLN", "assetId": 0, "balance": "0.00000002" }, + { "address": "QcUA6GT9FiPBbeE7ttBXu1avBHZzDsZg2o", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcUAJN6YnTLB4eRPf31Gebxbp85HDUbALV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcUXWQk2MJ2SNTsZwMvmw9XGgMu64cige6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcUpDQ5MuLtbE6J3GBQEgqKZpyFPrqNsyk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcUt9p3wgiNNWbU8TviDQAtrFV6tCEWmpW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcV7iTyejc5aU7rxgBfdjX6KPCW1wDdsLC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcVWt36WhapamAzy4z7CyyLopSduYMPMdy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcVgbZSVQCH7qPfdVFo6LHvikfXrcRqHNN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcVpxuuqQZnya656BjojTogeio1uD3Xp7e", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcVqroboHgScq2Xn2xKterFtPfsPgqtnPE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcVuk8QwPwhmeb3QY4NYntzGGj4eRxz4Xx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcWkNsh8DxSsMbeRt5qXj79s1LEyA6ecfz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcWrdi65kPArg7zCo1QPqRnv6UcoG8odC6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcXFi52u57UM5FXvHJMZyKfQhd9zgcKwyA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcXGa311Cs5ZZ9wMV3MuFhTYtFtPiEVyne", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcXGmJndR1qESwT3vHz5oMERbi6Yi6UjLk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcXLCiD5DLPL9qwSSwCZMuu5ajt4z4fFkE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcXbDWgsB91EyUgPc6EWgPvWgLjRDEKGUg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcXiqU1fPhg7wv8apgfVPsoSXGd1upErAn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcXpcypQdLXHnWGTvAV2Pz91NsbaM5rnEx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcXqWStZeoiak141tWKrLpsVdmGAznqWTJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcXuXDcqzq8goJAqwambRU2Uk9RQ513mV9", "assetId": 0, "balance": "-0.00001793" }, + { "address": "QcYSrH1Y2gBr324htMwhN5bfDJhjdScu34", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcZcZ9ghGKL9nC15wMYdiArhZirqRgCj6G", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcZfQe8acTzURKjzmAVkqVnvrPKn8T8fZp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcZiwf5nBsYpVBP7kAuXXjgH2HDAGjwbCM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcZnYMs1Kj8PPTReEoYhAkaGcd6yXgGpuE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qca7aZFjkf3YNcFyLYfLPT48fwS8X2Wnfq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcaLA6Ct3TU8ddGVe4PxWkp8kxv8wq5T29", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcafCfJLcCDDJv4TpP5NYeY4keCfCRar7v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcbFERiGqBXkuLVKttrxPrY8MUfMjuN8vx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcbH6ksrRsvjJFLbZoM11mNdzR19XPv3cK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcbQonJNuRqXBdLcbCxdXn71mnA2otiKwH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcbRy7kXnUDqgVahXLvQJqAprjq5bVQRrr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcbfrTrE8YiHHz4wc4m6yvqe94c1guSXcv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcbmmmGaEs2JhxGjEYZR1TcymYWLt8T8f2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QccCq1kM12dnYd6XkbrjxZTnBq9JDqWRPx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QccbAZANMssDSyrfLV983L6rCKMf3Vut7X", "assetId": 0, "balance": "0.00000015" }, + { "address": "QccodEvSTYBCWad4Fdnf4rDrsLvNxSq2jf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QccsGeAuBE3mpD1NZfghomchGUZFykLmRz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcdGSzZ1LkuTN9C6p6eyoUxX2aJPGne1ap", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcdVMbjajXeQNmWuKAgw115hyoXXSk2rn3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcdVZRPRT2EkjTXQghUapbRUFUuX2v3rzx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcdVphoqSvAyPCRfKgw7uSu1AxTSHTd5xN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcdwP8p9JyPnqA8d3SH4ALHgMjPjtdfy1S", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qce2Djqrk2WzG1QhMZ3BqFok9HGsz4wtM3", "assetId": 0, "balance": "0.00000988" }, + { "address": "QceChBwiKFaczeVnbVmq8B7j8ycJeDr5Jt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcersuRtXp8Gg9aL4BdqgYDH5jPDwarsiU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcetF1q1n8zjkV68WxkdGCkePeD92eRr5i", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcexHqhJr1nMPV2xgfvPshd9YbMmgcPFMj", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcf5wVLGjgdt57kYsn1D5H6TuWorqb6hww", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qcf7tPE23YRvV8iXdyRrNXWPdVE7vmn1A5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcfD2PdsZGC4jCBDGmnSS2DX2XXzfGzqYR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcfJfGzBXrmbm35xxVPugZeYH6XTQMRVvz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcfLzRyLGyua9dPSYLbEGo3r1Wgn7CZfH9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcfTFSYsw5r1gqXqPgquLYdg1EpKmCWaGq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcfhhsQG9vgVdQULu8RrXaXooJUec1xMj1", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qcg95NBcGyMP5D4Z9h8B3pxx16c8bPjD4D", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcgGwbUFbDrQWp5MAAQv3j6wM4dvU1b54J", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcghjA5b8DjBLfjHcrqvpo72pSMMB5bX3u", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcgnYZDB9QrTMbUrvgKo3848np9mKAcaYc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcgoMF8RdKznBv8WrwNGZHsf6SdxgBvjZU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcgqdMXYaZu1j7RctxpeFmgr1KbUvK9Ahi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QchA4ZrUdN9A9hRXKEf3moEQrHzYcZh4ra", "assetId": 0, "balance": "0.00000015" }, + { "address": "QchJtFvFzZ4dyUZ5mSUUTweX3MBr1PTJdx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QchM4ixczE3M9YafFtcEbwuPHyaNFE1Tgr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QchNcquk33tJ6MPgZvchLpZCa9sp1ZbyRZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QchYfSP8q7rfpFh9s6URnhfdYqagW8UTLk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QchhUFAjTyMMJTZZFqYv7AEA2kV7XtUr7o", "assetId": 0, "balance": "0.00000015" }, + { "address": "QchjeznUVdLXcrpQmgJLneNuwgBZhUE5sW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qci63eJj4cieUk8cqb3dQYUTkkZk5zU9pf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QciAdhiiwE8cd6dzbqe5hqg6BU2Qk1eUPw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QciHwDrVfFcStpLNk7TWqCQDhSWboDgmYC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcinNuhSAtnfYqzha9C4YwHXjjwPXwHARZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcj4LgB5yBBRgpN3PRqp446wUbME88sCqR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcjXf9ZpLyAhL1uQjEdaveqvG155qxRcwu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcjnLCfkZ4xTvKjccCHgrVhYfXSd3Syyvr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcjtWkpjNo1BwrCQXpaAMCCCDHLzepWuZW", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qck27pE28zWwmMoxa2hypGa9X6rhjBBfmJ", "assetId": 0, "balance": "0.00000652" }, + { "address": "Qck32co68QvrU9sFx9my54aCWa5g4QDz6v", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QckLxn2NgwZZjV92W8VKnHUWiWUVmQrhiJ", "assetId": 0, "balance": "-0.00001457" }, + { "address": "QckoJ4ppmPcEVbgfBdW4ihRhbbAafgSybE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QckoMkBho91RU36yKfrs3mZz7h1RKw1UoM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QckyAHQXEwGM8UxT6oMUJNLqWEgneqswNg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcmRLi17WqLJiVffraP2NMy2gWiL6ueWPE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcmXVL4GSCXb3MwYeFJZ5A2owugsKSxqH1", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcmdo5HjgyinXbRf88mGAPD78R4v6VHARA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcmpBR9GRQPjrRXCTKWKMySU5DxaS8nmeP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcmpSXb34Z2Svnb4WCGCvPbS8TbnWSknho", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcng7wrGcFZcpxbBjfEckFcGi5B6Uo8WVN", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcnku6m2mb4bae1LYcL3PxNUHuijuraFLs", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qco1K8pYUmh8G2c3UysZCYjZiLAQtGEArL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcoSnkFm2pGK61c8MvzDhnuCEfPswpSUZv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcoX7k8BedPHuUzZZxwrj9reoSySmXqWSW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcoYMWXZ8KMXSPbfy8PrXNmqBPFKhKzqFF", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcp4MdKkZu36xTZfooGupFV5VuRN1kH56x", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcpFw73mmgkvTGkLuBHRhWnCnhMucJJz25", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcpSNqpmf5nnoCuahKv3QmKK8L59b4CsM7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcpa5ByNNUmXSjgmU9x7oCvCJinoZec5JD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcpbvDB7zdmEXMuTWTmSwnKbSRcyyVaVG6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcpdRK1LxMyu9KtVEi628NWQnt3d1hzh2o", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcq5tRgx2MDnZ5Exe6S6Lrcpf6GhNsiGhS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcqH5EVZfsqwA4BVA2Gkv8nsTh4MVK6pf4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcqPQ2HbBxNZtz8E4gKbbGUuerrUUf7pEx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcqTj1unHj5FXExKu7RpRJHPBMPjGtmXtJ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcqoXUaqBafyZ15qXphAAFkSNSjQFJe2wR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcr9qQyxFFyvkpLCEHbGiMqSTztjvpoiGc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcrFKuimahBfmQmWkxDr5wdG4j8sRh1VPw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcrNpYmk5tcbvLiDrP16251RMJ8i59DPTN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcrY4JQZpEATmVLscU27GKExZqQZSdVbxi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcraPumP96SYo3eg4b1Nu7x9qrQRnv95GV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcrnDr8TkUPLVgDCoQFKRTWQ2udPTmcqYm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcrnYL6yNwHKuEzYLXQ8LewG3m2B5k9K5f", "assetId": 0, "balance": "0.00000001" }, + { "address": "Qcrzbe4i1NkS1ETWfRfJKGXqpNEpmEm2t7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcs3QusuYyzeU4kR6bnefqJXobQQodgnaj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcsGPGgveXDUGV1Fbep1i2KMtnPG1bh9Cs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcsW6tJPLgCa8b4wvbvpgjUEYTqnrykaWy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcsZPKXenn8MdZDw2b14mG2TDtR5LQDAe1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcsoHWRjMHnEX7iNanbddG9b9HDncyCHpT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QctQocpiqRJd8iPTV2GRCSLzvG7ZcxNc3v", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcu2bNX1YSbYM8tKBFW6cfrc8gwHC7aaHS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcuAoAka6VYq2GaNMEwCFMxQy7hGEbGhd3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcuN7yFhT5jKTUqSCZvx6ywBzWqhzqeUBc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcuU7x1fNGaLzncv6S9eDQi2ps1DEhc63d", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcvbfoz38CkJC1wx1LGHUCnbb1G7DUsYhX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcvcrb266Sz7ENAFbSmdGCajXfqsgqy8CS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcvd91ZxhYcPjfbychLpF8zZ2sRyRLo7WX", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcw1RYSAEehrjj12Mzke2vrXhio6UEMWnd", "assetId": 0, "balance": "0.00000333" }, + { "address": "Qcw53Jnm3sJ7BQFff5h9nYyxdFnX1SYBWL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcwBtNB472JpDA3WHgop1qyHqoeeW1uaZy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcwEgPdsvF1TugqnHvT2bwXLCLzEKMVk3A", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcwZbRDbzcpTUPybpaqmMX6GbtGvUdxb3A", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcx4PE9bn3qXn88XhpDmNSBGS32SmDE8Ds", "assetId": 0, "balance": "0.00000336" }, + { "address": "Qcx6tU6uAWqkzTvvLrAEYwtHPziZfj3HKF", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcx7YgJcAfxnNQhzKmZC63fhGZuDBL3uiu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcx9GwPD8TK741CYBNSB9U4ypacA4qF3VR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcxhLhnqgR8hHfAGGCLCgVuNjjE4axC5HB", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qcy9DB2QpGD9ENT1Yn76GsD2PGWaoJv7UT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QcyBacxzvdMP5votSnAJyA39fu9BgYhWmG", "assetId": 0, "balance": "0.00000988" }, + { "address": "QcyF6ghpe8ns9vGtVKW9yUYT3LnL2rxKw8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcyMcsb8NKA1t43f26A3kRXjt9xSXwqGd3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcyW8pudrs7UFMJnfqPvnckW7WtojGMw26", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QcyycXBEQykpZCmKAgSqEJi54SeLHQHDnZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcz2HFvR9AtUUZzMFAS1Zj4XmX7TUnx9hE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qcz8RqoK8s9vy2CbDy13pBbyfzfSnC9F4B", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QczUNp1L5gEQyW1yza81TuSxoLvNkfS9Gg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QczbxR2R9Hqxh3TJjWEGGJKBtYvANHb9AD", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd13La2dFDwSiep1Enmv5qcLFwY55ZW8MY", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd1GV9JYkhqFZMkvFi1Kk3YSBPuv4rfkZ5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd1Px9vhWuEmF2SbLx3Ez7HhGtifGMa8TJ", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qd1U7qW8xURWBt4AGVQv6whug3UGDyA6tj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd1Xw41BzN1CgASqsh2PcrrkTKyDs2MVYF", "assetId": 0, "balance": "0.00000002" }, + { "address": "Qd1qVuFpEAiiF5CXiSQ7yFy7CB3dQTBqRE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd2P5wUky9uEbvnjwib8K3VQxzCFh7bdQg", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd2Tbombv6zHpcTnvVAoqJcz3o5An9J4ub", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd2W4iV9djXwhooZrtSzLJtqtgQe98hh8S", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd2vZssFVkUFj1uvuGJZVs5bfLVRWEArCv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd33zAmKqm89UWMes6bfRMMoSNjasehzzX", "assetId": 0, "balance": "-0.00002737" }, + { "address": "Qd34YR35XbfpgPpTVBYx44Dy12ojUaz5Sh", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd3MNNFZCxbzQDG3LoYQa9daifVUwZYvpt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd3Utqqdkm1pkFEJ5idCeiAAhJkmXkRi9j", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd3bVidnA4fhKv1xwHcKsDZC3MUBFhkrUa", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qd3orsm6BFP4P95ksQtmi9ySfoFAFBjZnS", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd453ewoyESrEgUab6dTFe2pufWkD94Tsm", "assetId": 0, "balance": "0.00000018" }, + { "address": "Qd45iQHEWwYrcJBZcRjR5b7H3erg8vMQCN", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd4fUZUNkh9PpSjriiFz1AK2a1NGpBBo4c", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd4zspCr3RCppofC4THM9dVFbDbB3QDCz1", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd589pwvBCajNiHz8KAzzCM1BH9gp2enax", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd5B5rXCe2J2Tf1ngFr3fHW52JUDUQhvn5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd5c6TY67G9rw6k2MXefyPiDTWwT2NddqD", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd5eWPVaABDSyJwcYRYbmo2yfze7wuanc5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd5oR9YMWU7qm7txNhJCki6wEUAbNV6T71", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd6NxAc2FewzPHiycHGQyYCfSH6H7Extmf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd6RHqXRcuobzRxbNNh8dpiYiPTDexW8xT", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd6dskviALGkLevNyD3AJMBRqbgqnp3Aic", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd6mzgGAufEjwzLD27gG1d6P72kH2wnMP4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd6ndGFP6jKi79dyWMuQ3oKHXMDsX7cZMM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd75TjafsrikBgnfA6Hb6Y9wk45LwiavB1", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qd79Hk12X7ZwogmPH1AYCG3erGdVLuzrQH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd7JWrhuYqyccibvhf71AirXYsYxjxnxQZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd7rmD8PZvKKyJLr4qgvFQzeLPRhYkKcya", "assetId": 0, "balance": "-0.00001457" }, + { "address": "Qd85Zq8WVDk5iYZqTnAQSh7ZDPdUCwNGw4", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd85y1xRJZiEZo7iMPcxrVQmYSoJqMb3PA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd8Ny3FakHXDjWRcuHJBaYPd6Jqmk5SipC", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd8UQZaFcAbJy14r85c1Gxw9wagANTeUVY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd8fq5LCYQtyGZiwmWt3bfM5eASKpWz5F9", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd8i72uZ9W96jbTJ4rmTVc4tiKp46n2npq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd9ANCsS4zwEY1Akbyr4asseayd8DoU9ts", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd9aaazZivGgmFzs1stGgbnasG1vg2DEHW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd9r14fheezcoDuhDKh6xmrxvcaXoHiUq1", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qd9t2NZCaxjsPicXC4UrmV3jxVesHabfEB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qd9v6CgN634H7DUyxnpchhyEZdQBrt14HW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdA1B89fjvQmp9fpxwUvQLiJAwJnZ3vPDU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdA6NygHKbZF6uph4VTCHY9Tdzakvm8JZg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdAHjaXyGDod3tGcDHnejc978TjWJ2vRCu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdAWiHs9BH3qFCfR1L8mufmDPuHdHKF1nQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdAughkZmZBwnHTFPB77Gz7dpaVQsNgNrh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdB2HxnRVa4c64c9wC7i5qvjKpn3Rjpp6v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdB8SwKqiWLihnHVdWy79dbQyUac7xwYHf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdBAG6WgSvJrSZAxNeZmgsK7Qyfw4UiH4r", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdBDzbM8a8QXCv2ZMV446hXdCuaGu6m88X", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdBjqM6ryDukM25xNbpmwpy99S6Mhe4814", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdC6BV1m6nxJBfN1KLMbSPondYMzsv6jhL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdCrMmwhpu8aMByUQvERe76hqCS6DComuH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdDHr6upJkgJnfSvN8Rrj2RyZrpQD2upa4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdDZttHApmsyfJuUxxWkRCDjBY4BxRUtUZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdE4akyPuxVhxe5VQcF42TdvK8NhEcepf6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdE7i3XexTKzFSoMuZDuKnpMHxeXTVowpX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdEHqsziQBagYjhtT2jitDA3WjRSME3m4i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdEUP56wKzY5Nf4zv4vimSBzs6Hzcz2Crf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdEm3GvtQiUwSjWNsvcDLyZZ7RqWBYnTAk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdEpW7ErKNaDVgoywKFhv5XZdEvpod5iDK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdEzz6v4upLMc6nFndmD6kFVnxnHTxQnu3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdFBSPq4nooYZZrxmMHqiFwPpS8TgoEesW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdFJBQJrdy15hmS4Z4WbxXLsR2oeuqgvcv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdFZk74skMUu4rKMPEmcSVwR87LNDe6o3Y", "assetId": 0, "balance": "0.00000988" }, + { "address": "QdFh6ib7umWxFUVJJyiWK6G3dVkmTyKDig", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdGDySP7RVRfP7LsBheLkE5iwz6TrhgwwA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdGEfGUd8gJZqWNNY31m3GLsukqu4mgK8j", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdGJn8K9Eg9zUgTosNfRzmeyWP8okUqGF8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdGYLQjYfgEnqRtQujsXiMZ2fGu6Hkey85", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdGbhtkFHUqd9nK9UegxxGXD1eSRYSoKjt", "assetId": 0, "balance": "0.00000002" }, + { "address": "QdGgCJ3mN4UzQ9eT7KdqAewd6eLNNEHscD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdGhrVt6FcZZAbGrkbyz8AWL3sNnubzkMZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdGqdykxg5DGQM11qJ6d1RLT8wHg6goR47", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdHc49iRMiCaanZfD8kGiTZaZxJneDTU7j", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QdHkX9GmWucDwVTNNqGB8ka1QE8gYd4J6e", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdHp9G9AzktwGLz1ATu9LmYbZTXzHWxMWW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdHseAZeb9tb1kMKysDtny8Y4EiHw2re9E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdJ5QBs9WpxEiG931HDaFXDRfM15QGUVrF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdJEx1dt1ohTuwq2pr7quaaiifmKpLD4r3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdJLfsxdDjkUfWssKjbakfPcwYaSbwmCct", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdK7ihW7mwCPG5rAFyGX2hBj3BjQ6iy3fV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdKHSYm3Di2ruac4qgau2QLYfuD97eNpJy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdKTyLpYQFxCgsFYNLo8jt6sL6vD88o5Wz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdLAu7erspXxnvY2ZNqogtxySZMrNXZRX1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdLDuiLi9kEhbeuHJXf76KVqtXX29U5uLn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdLFGiFNKuaRCr3Yiunbia3gM8Ps7Z2Bf3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdLV72gg29bnK6Fh6xheUeMrvdyQkdqT2u", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdLVcU7SNHdpfC2hsDXEyokBXite9L3Uu4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdLWH9Frzt3zJ7RxrtFUbcaMU6CXJaeNvW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdLXDDQNi9naC4V4VdUJyBB3j5GTkmHPLz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdLZAim5Nne5pEqAhTecAT9j5NZ7cTYcoZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdLpxdNcngWdRZbjGvNz8dhNgmt4VgcnAm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdLrXnuxBHfoWMwe9g5MFUXvdLzbSYFBMp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdM8RjXU3jXdhNG5T1pTvVdqG7dUYdLHXh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdMALHgfUAiPTqfxkNf7gpL6ssmkfvEpDb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdN5jE4xCzdhx7oQs2PmyK19GHs5gpGSdo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdNbW4NEiJDRMm74M3nras5nkXEyf9Phar", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdP6twdTsJpq3eLDgi83t6LH367gauJLqo", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QdPCECruFjdLC8cfActnwiHmLHinmQjqDj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdPPu8usFGyQjsnsSBVnyHSrLUL335Xd1h", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdPR1kCCsZGmjtFf3ZwJNVms82hG2qphaz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdPVquxf6vDaSsLainc5cHvAJNf1NAcEkN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdPfZTvb4VH9EU3zq4zG6unsDNXZ8pU2bm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdPpkwPQ9K5vgqvS2kcv7uhtx2VYV1Sr77", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdPqgmLn6dQR1cEgqZBf9jQKSxipivyJMH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdQBjnFSs8UpgFSBgVpzRYLLoftFuqEyQA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdQHWGKy6sGbCEbykZmHD1xR5suuZBTMjf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdQW6mRhb7VB3FJSn438fJYdyDBpv9rkuL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdQgGKjyUyCX3rcyN1D2YXtUzZXovhnAEJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdR3Pf5yLvFf83tCmsRgXiZu6zo2UchdWK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdRH7tr77Sib9Bzh5KrPSPUncj3SinVcAT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdRUR6yUh7eLLPgh4Ry3eyPYx6aMKq9A6S", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdReHz8SZbFu9oNF4AzsMFjipacw7BJnRn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdRukdW7SXGFLr4zyCvBZiuDWPu4kaDMkZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdRzjsQrz8edqeRNX7VcASbSz2hPfvX783", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QdSD7buADG1YQiraNjmoZCoFBZdgUNADom", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdSQjxAdRwfg4JgdaXNp5CZwTFL8ARwDJf", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QdSR6BSUdZUPh5XV3PkeDNAuoXWhCxzfdS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdSeLxZxEy27nc2jGv7Kjj3A3RuqsiKM2h", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdSoe9HXRX25YeLVQxGCMNLurgb5TZvBi9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdSwWRPgK1x6fwpaNJYLKT4TxsUfR9PWab", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdTHYUdLQtab3vPeAbRa9G2FgybAEUrThH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdTQACJ13yFyEzo3HG6BjWnXa6wEHZW6ui", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdTY1v63aMSibfPV2sJTAJZu2mqDP4dMZV", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QdTb8DcpDrziaB5kTqHs3rg3gBR3Hs5V8n", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdTfQFQa28N9rMFTvego5x7fSyK3a4ETrJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdTzsTfc2kjvyE2b5MbBydP432odXHYQCL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdU4DujCfTVwNEgDdWEYAjfTLuhYmdzUWi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdUhG69mSsQ4fpBUDscHRRYpcAyPCTEEb3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdUzFG8FoW9bg9aupESLiFWpAExah65VBe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdV3fm1Ridq9Z4rjtVsYdGQHtN4fiGyrJQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdVBitjs97NR7vESeAT1wWRWNUeghTTXpk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdVCaibUiQTukRs1kErZtoEfgL3yYstHwa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdVqVNyjnksmewSZHPK5ghvFwHCXsynKE8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdVvtcqS7sFeWW3zXahhBktr2KZKEiKh7L", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdVxQE5faBnTjB67rTwnF8xWkM5xPqFypg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdW11y1kaFeKiDBNEc1TLhRScxtMRqmEBk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdWHK7RszvEVWCZW9bVXmTQmupURmJGQTe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdWMn6xMgDYivMYWMGdH8DxsbJn3paxfUS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdWfJeFsQKTWPmiVLYTQU8EnMRpPw17uKD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdWiNo7ygpVLBG5syD3qT8Sqt7SVWH1cGD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdWySix6VCQYYqKqf5eVeE6RVV8rUatKN8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdXMNPHhbt2kiwkp7NPskBsrZudxZ6gXN2", "assetId": 0, "balance": "0.00000988" }, + { "address": "QdXdUxnyKGGo7eEfTcx85oEikNe5nYnuwa", "assetId": 0, "balance": "0.00000988" }, + { "address": "QdXe21sjY8smjVmiAUgZY8xWVzwgxMgK5A", "assetId": 0, "balance": "0.00000002" }, + { "address": "QdXrx3TspnXfqCGMRck2nCspWX7HMsh7MC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdXs62MGywEiZaucL477k9WVX29uMxZXmn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdYD1TvgiodoVgjvNrhYot281ghTj8jdXp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdYXAGSt7K2KHQ11mvtqHiBGKed9jtp5KQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdZ5Krd84CX6oh6jorEhYyL7zdCacrPAj2", "assetId": 0, "balance": "0.00000988" }, + { "address": "QdZ9KsVHpkuWttWJYBs1Kep2MeY8doVM8q", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdZAB3Cgb5sGoDtGhchDHtR4VX7R9zRy9f", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdZAub2h2AUwY9BMjkEGGRSFSXHo1cpbgJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdZpEywMEp2Rjb6qGJQHXyGBVtWBs9Rova", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qda6ohFW9tzoVqrSh6kgvgMirxLZaFkLGY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdaKgg3gWXZUoHhs4SnSp7JsU9mCm1cV9h", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdaat4pbhwTow4cZf2KwcJGWenKJNaBF8Y", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdacHAjkZDes6gd9KvWDSgKkMbA6k7WU12", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdacyjczfxDXCW2fcHq8ZcuFcTozzB34Qa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdadi6xFFm1r5WpgZbPyG8biPs86HXdf58", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdahoqcuUszHM9UVZUfMqF5s32dkVsakfJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdb27GFXfyWFDKY3urtrKrQRshkeL8hWgt", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qdb6rSVK6Qzp98BESkxf8vuWXqwhYP22cd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdbKqFLQCPQsv6oBnEFy8gZebQBHEXtbBX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdbRX9wNHBfxJF2Soo9axrY67M6aP5mfDA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdbVdMeXYN4e6foUcJLtYcLoZR3z6XtuMb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdbaUAJEiJfy5NMQxgLazYa5MZoNJ7zntK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdbdV9cddh1HM5NBXeHxBmaDfi2zoEirWU", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdc2iqPdrZRPLPmUqHzEjKmUevxrjjj2C5", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdc6EANssJUSFa1Rt29sEon4Geyu9USgAh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdcDScyVNDSt1ZQNqE5aQfR3cNBgXCqLhB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdcFKDsQnJmBQDgkn9xSorJmtNcrkDryh6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdcR3vui3s8QGzVoSqpm6pYrY5oYfCCGj9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdcUxLhQjMzB1sE7t9ErKuqtqtLvgENQ3n", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdcfwY1ipr8nJDnfyijWjy9ZM9RnnoTuBN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdcwdBtBS75j5v61w4NkdZQmfsopCweRUX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdd2kXF4zSC6jPrtdgaVjVPdkH7Cp5qVMT", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdd4s7RACNzKAkeMoDi3nFRRXEEDfJCCyW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QddVETwMsj7p1BNqoSSMupX9G6rAS6wFPA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QddWrBH45Q5xApwTTU41My5mjJixsBX2sC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QddnNXSMHNAe4b4GndfykjBvgiQQPtS9gg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QddqSDeN63L29t8WkaDiSya82UyQG76CmJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdeNYiB5E2C3EYMEv7wFo8GnSaV95EJSF7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdeRSt63gD8vncJ3xFqJtiKuLTNpY9XU9w", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdeVYAvHFE8R2mwsJczfgWz3eaUKU6KT12", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdek7EwWbtHxqKvLkQ4KKhkBLnejDCHnFR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdf6xP9uyEuLQQucDyRVn73T9XAGvFUzt4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdfLZwFQH9rjk5i8rbfgWdm3UxNCXe65xi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdfNvKEa6ueqT576sLBwuy6ZgffpDqQPSt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdfVYgtrUNV4K346MgyDmV8JdhkLUhSUhd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdfiMADN1HiTn8ebS8vuAtFXCYgDRM1RUg", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdfjy5642FpjFpjV5smJTzUGL4vtP5ATBV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdg8ECaA4gDewXxFSzQwPXJF671yvpjCfX", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdg9RfMYbWnjMkjxdfTuYnEiV74acLiN5e", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdgFivy8XPEts5ccbRAEk7SvpozbA9QycF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdgP7NhWcqJJTm4ZwUcyiTpTSR9xfrhg8a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdgbtYSRsDgKZ2PZMKCfoNWfGuvDm2idmP", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QdgyAh2nm5rRtBtNbQqhMZUDzj9EUS3wxk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdhNa52erUWLzyR97DXSkrUfLiGspvaD3W", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdhVBUjpKsJGkKJhGdu2FyrS7jkZEVm6Z2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdhZhLNUihttiyy9oD8sGv4nfCueaU2jJf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdhh73fa8uPcrss7G2YzwZYwssjV37KmtR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdiLzEJEboe94iuER2axmkgsJ3vnppJnue", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdiiFd9E7KDrzhWF2M1ynB674zxv78HqgU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdjEjCJ5LfwyhHzVArygzV7694XvSBwVZX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdjGXNUK328jquAvMTzjjphk7TpZo6CZFu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdjUtCESvCJyJHouNqKNdgC3yDGfjTmhdT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdjY87KcRC1fVaCZTDSQvrobiqVbVyYt2S", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdkA8M8HGtpruTJPzPayQABv2KyeYuL9gK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdkN2TVeHvE3ypvSEDWjmis7mv27N7UQB6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdkQWv1qxA97WYKeVjsFBgSJtGPzsRyaoT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdkY8dvAWQoYH9aeHdV3GMUtde9kywgCvj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdkheUawBwuvhD5J5N21uqypH1hZw1enGE", "assetId": 0, "balance": "0.00000988" }, + { "address": "QdknKEhDEHiq48LMpGidxEmHkBTZxGptib", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdmCm7R1BpK81z2hr1vwJyH6KN5VFQMoYT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdmUaWs35Lqs3JxearNzBdy99dfWzz9Ueu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdmfZqAb5rZHJtcPGtwqAqPXvcz7TVaHX2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdmkrdNJWUXoMv7urnwL1ShaYtps9qYL6c", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdn6M8DJeaybSzq8WuaSk8fCi3fx1wp4GU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdnkKD9avVRdSTVEhE2Yx1CrxSvQaG1Nya", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdo4w9BGeB7Mo1Ky2FTEVgQwzCsEoBZkXv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdoP283P1hAcdErAgyUUpf6CEFJPFmmDMx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdoYA2snqz5Umz3CdkUXDfaMVyPcLAHjTQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdoo1PGqHHA75oJSciac6iTB8uhRdCeM6F", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdp2CoJMptLAAd37ma1mafcrPGKhRzAQJq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdpFNhzHstS7Qx93QiDibnk7MKRe6ULT39", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdpN4Qdhtw4DkYp7dpQ7AEHmUVPQSoRzRN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdpZdGs74axDMhGgkT8DBZ1Bv1fNK92YZ4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdpZnEWyeNxdqPKcmu9yNje9vek89dWxEt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdpjTBYH9dTEwF8wd9tumj1g3J2uVaVkpG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdpjp2GhCGC1QYKqKgd2Bp4UGpLzEiWSDe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdq5uTZFF7kRrvcRZFkpj1TsiWFPioFyX2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdqEtfGGZjRKT957WbdeUJTxguCb12J4QG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdqPNybYJKLKkhXy73gsx4rxyN9rJW35oE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdqeuJMkbdzeTGgsxRSe7FJzNfoUDDXAWJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdqkbhzgGPYNxzaNkwS4mk6PkP5SqVXCb6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdqmzwLfQBPDFjNbUB4P4RDZyJTW3ukoMG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdr8WcxWbWEsTkfRZY8a1w8DqNW6AnY3hN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdrBWX8aF1ysadnoJmwH1hYdskpLrKvAMT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdrHUSMB4YZQ1NGgGViHsGDqTnv5fsUYPA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdrKJEJkanZKBd6VQMYdVEfSbWGbv5qYrT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdrcLp4EeJ2kdf8D8cb58o8FViiHergtiy", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdrr5hD4RTm5b9mHv3kjcx6444xur5RTaQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdrtKavAEszNjsD5NwxLh6ByL6brVV3FVZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdrva78ArBYJDqQcnhSuDkxi9XFFFrPybw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdsKDy2ZGiSwEVUYiK8RLATVDiRp5zkzYb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdsMQUyuWyYT5Sit8YSMW9bKjBhfq8MwRY", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qdsh8ep6VmYEisYxSaM4XJYvRNuKooQJcb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdsiBFfTQUPMrS6NuWdcYCU94t8M64EcPf", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QdsxvhdxwKMr6x1D46Q72BC7o7kw5iEz16", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdtAQm1EGNgM7QDSaC2qvV9WdpRHwpApUT", "assetId": 0, "balance": "0.00000002" }, + { "address": "QdtYxL4twBv8gbZXK6UEmjML4vdHU6WcjZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QduFeDXmHTqfC25iucie79G7mZTf1TUQKb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QduK5VgkRAkCYi4qRqKXswqYXnbDAZrn3k", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdudYG9SDw5WYzfoj9oq3QC4abHx8ZWCce", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qdun3tfouxsZ5AKFTj5irfXFZfMwVaEi5B", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdvdVngtE5ymw9tshc7Tzhw728RwXT3cF7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdvvAatzKo2cnT8t7aeei51gPGd3Vv5mrh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdw9cRazXyyZUTZZ6LLNK4V2HefRWuCErq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qdw9tSK4iU4CQn2H8LrVE7Up3xRs8HQfUZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdwAogLYeYu1rnxJ5bCfocMiDyWkyZYTpF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdwDKEA9TqzYvbHc6t5Qs8wSZmYP8qMYWT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdwM515PKjgUJucfUg9p2i1k6HSZeJTwaF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdwSxr3t4hdGHjQFy6EVGR9yGMipefsTuo", "assetId": 0, "balance": "0.00000988" }, + { "address": "QdwXrqQyBd3YZsNMa91uqQyPV7rGjTJFc3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdwpbSr6obkuF37oAxDoxt2ZkKUuQFpq4P", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdwtzQBZcwcR3v3z3pzLoDMCwj5sK3WDAL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdxDaHTEX5cUg4S6ohMAi6mE8wQZCqQBoT", "assetId": 0, "balance": "0.00000988" }, + { "address": "QdxE9YjD8BEe8HZn6avWCph27DSXFk48wv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdxZe4KARtQGr9gmCh9owP59h1tFDqZAXS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdxxL79QHKSVfdoNAwJHAeALaG1BSL5e3X", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qdy3miiYusGk3XvB2st2pX6z1MMq4c7j29", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdyQS8GAPoCV5po9ieW2862Aro8kuXZdeJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdyUxDZaC2HoEYuShQwqNbS589T5ZRECUd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdyqaiwYkgH8cKUyMvrtsy2WUSabHsCTco", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdytXtRDghB23b91G8a3EmL7EZjJqphnzW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdytubXgkiemCjbyHguy1bwhbw7EsfwcTW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdyzBSPBNLfyCfdPNBn86EcXUZeDdkCYLm", "assetId": 0, "balance": "-0.00037976" }, + { "address": "Qdz171TrLr8SVxpiVN2RrVgLJi8svYEaFD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QdzGYfF91yNGM6b7EiiGT5Tq6nKcDyUgRF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QdzMH14qpu33F1UqM5skDDHx4RpRnxwmzE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe1498JGqNA9NC7CDw5gn6M6zhCx2nHVHT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe1B5WuC8NW2m9xkq7fz8FZjrDaiNL6xEz", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe1YNhWuKnKVDDBY6sGnQoysJCKrGN9hN6", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe1oPNsLrsbL1j27259jDqZRXSg2QrMVro", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe1zgj6YL8M9SsbzBNfo11HTm7Yz4qrXXv", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe25XhnMMZAUvhjZsnxoBG5U7SYc6z27kp", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe29bjnmk29z19Nw3xBkbWMqMzy7SkzZ57", "assetId": 0, "balance": "0.00000655" }, + { "address": "Qe2PVwDckhdSrKQfk745dnGvyRUoLHNyWA", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe367EfRS6uko8LTixjTCV1aPRGHxXSj2T", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe3Jbj2rxLg4cT62PASgGnm6NkJp6jawkW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe46YdxTMXhMBEAAS3dPc7W8LRT9SGXWpd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe4Jxo58rvseDDBKJrp85UyC44D5GPeYgE", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe5RVyzB83ifE6SZpkEVSD4uLqi2bTCQ3q", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe5k97KNsWs6167D1ZHDhdx9qQC4P4N151", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe6MUJvYJqJxpUBZkzmzT5eh3YW4N8n1wU", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe6MWS1SnzzSAuxyQCa6pjr3qTk5WQrBdL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe6ZC5bsMZQu7M5yUr6gj3rwgmh7u1wzrY", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe6wXo53paHy2i9eeUsSo9ce6pX7kmZ34W", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe799g8RPmeZkBoxadodmjHVynirBbgGNT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe7E8FDZp9ceg615Sp91Jij3FzTopuEQSs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe7RxFfsV5JNkQNuK9UVvtTQMXhcCcTTTf", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qe7TNxjfcbXjhmFJQMDrkTdSKuVCcy7xfL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe81QdeEPiXSGsW78PaTZ4UX3SbSiQfzXx", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe85EwcHrwdx4RCs1N3ZRj2a1NGUeE615S", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe8pLKRZnCXZrWvfRqovN9bD2eD3iUdVq9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qe93ibjpmtFoWMH2w9rs5tqEAmeZ9xYxqZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe97649UfffFXmtn5xvsQwDzux92dbq7Jm", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qe9S8zA27FPdPVVLcVQj9noaKsuwySPKdq", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qe9VPzQp3h4Kg3DHSHBUQ3AM3AiRBfCDfX", "assetId": 0, "balance": "0.00000001" }, + { "address": "QeA29hzN3ChXqg8p2Hv8pWyDG7vft1Z55h", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeACGb2RsRUK7QQUPurRY1cni6Qrbe9mEU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeAHXst5usrjP3c2WqZFDZzxH3KoxdZQLw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeAHiq28seiCm7wxMoo4NWJtAoBVMtZrpc", "assetId": 0, "balance": "0.00000988" }, + { "address": "QeAa7yawpJqQYk7PNisVD89HezskBRecH6", "assetId": 0, "balance": "0.00000988" }, + { "address": "QeAdSG7BYRoPXs58goYgifX3imK3cdzY6M", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeAi9YPNZXuYa3Y8PFBEug3K3RDP9iKFQ6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeAmJzuGNX9rQD2FruDw4nEwCtm7vQJ4W5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeAwwa5fn5EUrgDjKr1R36mRLaifzXyahL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeAwxFMkYMmTJN5dysZtYAaq2SAxjeYrL4", "assetId": 0, "balance": "0.00000652" }, + { "address": "QeBCwegVDcK43gvRL83ZK4n2pHkv1FXZqc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeBD1sFC5JWQeR3gguXyWWAgnPhZPE1CUo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeBRdBCcW6bqXtLvS52grBooMHkano2Dxs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeBUAH5nCBUfgcEybuoS5pHD9QDQVVvdvY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeC5YaKh7XYsTweYdZvmzjvmFZDEN7mo6t", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeC7FVBkwzEqxiXV1rDu8xmrqwjasGSvCw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeC8B9WuLdGhxuMecXrkdsvNVXhCaU3UK4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeCTKHwG4zypj1bV7uNAzyMc4hwed2EFga", "assetId": 0, "balance": "0.00000652" }, + { "address": "QeCWzkokiWM3xh6V8nqxg9KBEL91Wzf4gY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeCeibfMsEoSXSPs7zHq71Gy7teMZdYDM6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeCm9sRepJaT5xv3GY2pH1nHKf369JtrDT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeCmjwMqj5vKUQcHgqSyiu6TnWMKEm9cTL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeCnvvxenVcDeBfxQBx3n6W5L6Wem5gyiw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeDBMiVFHdLvovHYpBLHwCGcsKQQHUf59W", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeDD4rAnxTU3JbcE1MasMfyNn2hZyxd8en", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeDLyq7rYfjFjQUtQZiZrcFD7biPiqrLUc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeDXpmjVXUev8qT4GxNTEy4YPVeaDfHetb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeDZX9SvEoD26maTAv7vnZMkrXxTRNUJ4P", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeDc8wTbt3SSvTQob7S1AR7ayWqj49bKmz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeDpiTVxsUDyVCFxSMvWWhtosCUDasqePw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeE4StsNr4jpDx7eGfm7zhJgs3irQWMdNX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeELvLn8DztFUbHWwXbW4BaWr9PwbJZ61m", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeEXLJNvm18N96Prw3Ue15tZrLAdGWDk4K", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeEaNhAUjSu2HyZxTkqjuETjmS1rpku1Sz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeEbCEj3yfE3XVU2sgL3d23iNJVjwKaeKL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeEg3aQPhdn8yHHvweyUUqz4p1171DhsBP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeEn8pE83wM2ULYMZetGEpvThwsdzujRf3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeEno9enwVzmb18phB1d3nriYkntVx9SKw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeEp5p915tHUxDHAmrZVa9x8MkEkS5bUzh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeF6dXZoudXtPXzGM3oupe18mbzKYzSuXZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeF9MqmtjcDFBVxoLXhucWbss6dLFqwctN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeFTcmhNwpu1vDUB7GdbnXi8zaQ9nBUaLH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeFg6VyTJiKZ5NCZPqi4zhCieMruUCxhuS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeGEhc6wa9cCMSuXZeyjyYoYTGW9spUtGe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeGVErSbQrGczDbjJZnoxJCHHaQBkSy8gm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeGwAyw9jorGBcPHWx7DnhA2fUjZ3ScxZ9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeH2ajmr3ca3t2g6xcnbmFeGYd9BeACvA8", "assetId": 0, "balance": "0.00000652" }, + { "address": "QeJKSb6CzLvJ6RksBnCgrucmbongHqdeDS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeJkLVffMFAbCTM49ifz562z32PNn7ZD99", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeJmHQvuL5ztoGr4cbUi1x8d3z5p98qEQr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeJysBcE4MK7qMuuesyfegnBwm7tfWSztA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeKGG89WgJZUCNTyVH5KmMvYgMvKSNghAx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeKHPxUXMEHDQxqSzQm8P2Sz9z8Mc1z9N7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeKN4bFrVUFJwVty6o1kvKtM3heUC4Nini", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeKPGMfao9NxiTV9MQqrA4NiixC3vRuTZM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeKa4wDx8JC2cPohViah2K1UoL6adtRCxX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeL78QL4guEDNQMQRv4u3mEKQ78ooVweie", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeLF5wmqmHMnJR7rHGxyXJFsSLFSWFHejK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeLWyiJLvJbSymunoB4L719XZPsNMCR3rX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeLrZBMFJqDssYWZsEWbw9RFocFagdKVSb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeM2pxEWetM36Zt3YnV28hv4awmYURMpV4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeMLxFUdfg7d7ZSHbnxjMP7ZmtGMGv9gkE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeMaFBBScTVo7wGZnRRWXASysRtqY9wg62", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeMcN6YtUtpR2W5gjr8xAJhJRsqZaCF9D9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeMhT1pgQxnCJJ8AfwrP7EZXwqvsxkHZoY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeN2oBiBMtkHmxU8Hi6p6n9NwLYHG9zjhs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeNHaM8pZEfeNbwNsHGwkHiGwVCxXzrNz7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeNaUc8jnbstGDSLC9T5GSjHVd5KHk8mg7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeNn7k1eDEdpMs1rGmfAD4pSYuL4n5EQFy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeNnkjz4ZiX6qZYo5TFQccCT6NoTW33U7Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QePEQ1E3P57NLLhBqxjiew2hCBjSURTBV8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QePTAK7zkKdRmUaRRxU7qi5CwYMaxi9PkR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QePTnxdu4ykHTRuq4ucArua9MSqC3io82o", "assetId": 0, "balance": "0.00000015" }, + { "address": "QePak7TL8guYsqY5jcVUGns35fRi7h3VYf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QePxofWCbFuWYA7zw6BTtSF8BgbLy1TA53", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeQ1SccqEuAYBb9gMPqMNf9fmFUSvRpgvR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeQ6h5R6ggkKBj5CRvp7hzQFMbDjE99gQ1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeQ6v9aeqBzjfWBiShhJFtjisxzHvxko7J", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeQR6xChZtsFyNfqLWNUkHu4tU9cYmk5p8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeQfAzEbBQnYGB9G19Eru2Ae9bAkabSY19", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeQqVTyaX5fRL7uLs2xvDNmrcopRT9qqq5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeRf5i2mBzVfqRfcmXCFFTNAaxbhk6gpN7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeRtWX8HqtnumaA7NXpFqrAEcx7BFc9wwi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeSfK3VAG1KXipCR2tYWsgJHKoEEET7gzZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeSh3t1AnaRcRThkkUTvvdMEouixCADeVh", "assetId": 0, "balance": "0.00000006" }, + { "address": "QeSncsfu47Un83UfzbG9ZyM7iSoEuRbyVt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeSzLpUw9as4LUHTJ3CNK6SW9okCkU1qMG", "assetId": 0, "balance": "0.00000988" }, + { "address": "QeT3VhVrzx59W5n1hkX7vGjFKZXD4JfhYg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeTdXDhExVsvUqsbuRtzwrzXtgNuUQ7f7i", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeTgFSAQj6AihCoJ5cfNJt2ZCDkSUGSAnB", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QeU2HvmJmS9oxVzaTaeGEc3A4E91JA4LCc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeU4Tp2Yg91j9BhNV3NjbaxqWxKntWa7yu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeU4z63x84mZJmwjxZLKWkJbRu46iP1H2z", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QeUE2MQKGopfmrcLknKfrDnJ8ddoktxrHr", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QeUMYjnQXhUbjKdpfQAu2V7qSQ5xmwsKr5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeUUK4frcuD582BxU5HrdMK4L4eo1ut3dd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeUVR7YiR5norXgmkkjUQXvqt91QzeDpXX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeUVoRyRnyHS8UbFu6n1myjvpoKNM2z8nM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeUkvrPLVK73rnhqArxv5KFY9JB8gQgtgx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeUmae6NcJykQg7226B5JuFFog3g6Ugpvr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeVGUYQSiVgAq4mZ2KQswbkkxBxxH3jb9Q", "assetId": 0, "balance": "0.00000988" }, + { "address": "QeVitDrMfmLH9nepqGbeWVHZyKuPF6inrr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeW15PBsKbUjEjbpWVLErCjnks8ZDMa3YL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeWY1PLtcDdMXhYSE4M8RZdQiBs6e2finL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeWYXR8i82N3guy3i1AchDqTxzS6chmPxD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeWe1z5ftZKX3zKYznijBCKK2jCWVsypBz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeX1Q2qkuSJU46RgrVr8mZwCLmPQrAbE1K", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeX7tKhQBTrBeX1CBgdunoT6UgVbfDosNt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeXSxYyz5Y5Kw7r3aHmUj2gbP4NwZiQEZD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeXU5Wd4znPAoFTDC4SA4fisXwQThtYatQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeXakcU5j3FTP1ttTDk3szysSvb1iNmt1C", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeXbTreN8puJLaUfbta8UD6tqLx4rQVwxx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeXhkeTsBD4LajeVcmrHgpccekQPx153Zp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeXxivyiwTjFfX96qKmhFBboiX9LtAvMQ2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeXzPbYVZTT93dZmDTNAfZ58qDVeYXya22", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeXzkWrJJ2aSHSuCmRe2AVUzfAdPGJGqsD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeY5cbodjaunb2anyhQMwurmZtZUuuDCc1", "assetId": 0, "balance": "0.00000988" }, + { "address": "QeYKDhoD72XAP9fGXrCp5qZJxDppCM1689", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeYmaNwfujvSHSmn6Y2asvBshCnXCe9WWT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeYrJpWhDtSRgPd1Lr7JzARRmBsyu9ekcs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeZKBY2XUxtPCqgwBUevzmZBRJVGK1T4KK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeZm1XBbyycGh8mdcoBTGpD2Z4v5unA2yt", "assetId": 0, "balance": "0.00000988" }, + { "address": "QeZqwJzwixqsub8PEL5hgmAGS9e7THhfUm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeZujwvXy2ewNRnXAH9iwYJ4ZqWHr1zi5D", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeZxatPHsgcspZL5Hbd4awv5eUzXvxJa3n", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qea6o68Uurbw2uceQtm2gZkcfGPZbNQU8T", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeaDGU85fpffwsw9ngmd98QsT6NaFyFFed", "assetId": 0, "balance": "0.00000002" }, + { "address": "QeaJHCCTy7AebbPeF1scsBLbezcBHAKtKt", "assetId": 0, "balance": "0.00000652" }, + { "address": "QeaYLvZqLboNrZPDyi5xmvLnvb7TN8RxXX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeaamFhta3kGXJsyNVBoTFvmTCZc6DdX49", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qeb3CR2Jyz115EHnQUKZtMdAGgPDPfSpZT", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qeb3xMu1hFsC9tFW4PASFCGRpgMCMUb6kc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QebDgvEhFSB9CTjHuFQTnaL51LgPqPHG6c", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QebRRMJchbTVzxywadonSEXpJarja7kUF4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QebTPbKEt6dGmvVujZVpMLJsLyVcYNG2tB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qebbwzfm1TG6kXaZpMXeqVumpBh9qYfKf6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QebdbFvthfusXacivaeEBHaEGuWsypCseJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QebtsjRggz1GQkWy1J9cFLQpJLnsCkXSYb", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qec5kasYW39aBgRcMoC91ouz9Xm98Kk8DC", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qec83tt9eX6Ng41GE8PU91GWMi72Hk74K5", "assetId": 0, "balance": "0.00000988" }, + { "address": "QecBnc7Na66KG8vxdHj6QCpNvKeLUtFCz5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QecR6a4kkWmV6wSXfCVJUkUYwm62SC8hut", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qeca2DTj68YJvVbabVkBC4fHKkJB9TQzzg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QedH7zeuC5sA3fHWxFtmAkbBbWyU1ctiSx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QedXksxS5RmsmnQJNuibme9YsXei4KqTed", "assetId": 0, "balance": "0.00000015" }, + { "address": "QedoDDg2F6ns76pFuJ1NUDZqdSk2biUrQu", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qee6SvYFBnWvy5LX9FmcQTYSs4HhCpMrHR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeeG7cepu5oiGyt643W4vxDPpH9xNUwqaa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeeJL7LqbVGyWCwDgUnHLkJ4jLmo7PAne8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeeSLJ1J9vgkxYwNYxXzkJESsmG1cb6Mgw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeekTT4CnpyZHoQbQuVAUcChvTEEUZ4hNv", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qef29yx6KJG3QPZFfAgZDv3pwLn1rH9oCa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QefFbxBLutwQvnq8byXfBCroEP88KGpduH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QefT7Km73jHHqRS1yvQY68DXwQMo1KGqqe", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qefp2EeHDoEPhVids7MVkCWo2v2nGh1EW3", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qeg8Q5hT8b1ZvWGAUDAt13E4P7BgRQVvjy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qeg8dXwpBqz6xzY16NPZKjakSdyG3synq6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QegLy35orqRzTS9pM8G8shMRQrqu5C3HLY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QegQqotkrTygtoFJ7m4ZdZx1xzrkicCJKV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QegSCt9591uW4D6xaQBGWw6PmuPWB2PT7Z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QegZZkVYmMbEcSGGqWFULLnCUePzcCSvo8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QegZjq9E4XtNq67jYeRkfrtrn2XmNu2H6i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QegaQLri57ACzdLfRKB29CjPeyQEmwPVrw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QegcAMLvMHFkfYFQd9q7peovpmT4yntGaQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qegfu4nqTD1zocqK9DxQdn7MVvWBLwyhuc", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qegi7j8QzzhPLT1RZUi9ibuCosNKjXm4et", "assetId": 0, "balance": "0.00000015" }, + { "address": "QehWFtUQnwG5hJEowD2esuQ3M2hGpismQ3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QehrhtG53L8wzZHCJhbWP1a93cN2T2Xh3M", "assetId": 0, "balance": "0.00000015" }, + { "address": "QehyD71HJB7rpT5acSUbFzQPWaY7MJMCHr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QehzxMxVUH8BiVhgKtesU2FPWG8ndxL6nr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeigiSX5APbyw9CRGPZnTNtn6F5FUr1zkj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeirNM3CmQyHoVD1xjYbX382dpPoBBeTh3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeirnDypth5vo56sbhv3CCFJgen8E4GPVK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QejCCwUThn8xzoEKBd6gGRMTrLXgGjYgp4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QejN6RFz3CQ8P3MEAeMYTLnsJccTPWTrSU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QejxSmQMvcriEzyFtZBuGYxAvphu9GmTsF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QekAWuiw9PUQfygRRF31aLemzHBLkRWpiU", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QekKnB6xJYsgLkhK49wGFMav4Zn5aorCGz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QekMQ2nkksyvbDskfpf3Bmc7U4YQrcNHyk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QekS9Sk9vjpKnxk3EbGsXUxJstkhqUYPwv", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qekm4HQkffg2WYh9JkBtFqYKCRBtiX8X5E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QemAuqwRq563LHdweUjFX977gLjwWNaoVX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QemTGAC2RrnC1X2t9TWhbZZERA9tkWBJou", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qemc5GZF5upcmGYR9DrG6eGpNsj4xE2SCy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QenTu7vE6UCASBJE9gmkkdaswMRqWGnLgP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QenmLzz3ieRuAx9eet99tPXe795Vrq3bkd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeoBkCSDVrhwbegqGaYDKXJR4NLa9yXNKy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeoMcNUihhUKPZn9pC6kREEXgPHjdYh5Ti", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qep13Bfw4vBSkoMda3Cz6mqT7RJeAtPisd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QepEHyprQmcetNr5SCKBmkH6nhMUEYDnWT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QepJvM6NQc8a9hxL9YMdyYVvn8JnfiCvag", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QepjWDwwpq9W9qdccb75iM9RAixwMiB6p9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QepkE9dJdWsYZZdYRP5bV4NbzQnpABWR4m", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qeq85FoJpxtzoDM93WiNQQCXiuiFynRQzm", "assetId": 0, "balance": "0.00000002" }, + { "address": "QeqVA7cf3DP7R68TaGHuBFt33pnPZAUeUk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QeqbaS58fJCvyMQUrLxUzQegJ1GV6RNgLr", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qeqjj5EtcFrs46aqYQGTZsPS94EfDbbjW9", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qer4mjMaVoxsmc2vDLJbuzRhWxiAEynjPX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qer5BKMGiFNpP1JcpBa4x4oG8y7hYTr41h", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qer9AewWM9fwze6F792MFaP2DvuJpcJrcB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QerME3ip1EoPw9ixj8DxkzVAHqrQWztqXn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QesHnkNavRrCqrvGtSPss7W3tGQiV534ww", "assetId": 0, "balance": "0.00000015" }, + { "address": "QesLwqKwj7ekk9nSnLYa6k7JmNcdp2kZH8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QesUoX7rrugxqGFCk4AYntQkoxvXcLpEoS", "assetId": 0, "balance": "0.00000988" }, + { "address": "QesjCjVyH8R5aWjN7srsoyJiL5UQuTPug2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeszKMgV1Kkjx6bscSC1SX9r8qGU41LJJd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QeszzSayjfnEUtJhRjLg5QeTwEbRCsafJD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qet86Wr9qGKjko38Hwqpjr46Y2h1fWkFBV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QetBL2P7uLFhfvNm4wQshrggipwngCVgjA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QetSmR7YoVpWopFmCnUj5m2LuyzJwx1Veb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QetdRqqtF63DogeHcLacGjymny25q7EN7J", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qete3W5VuER7JiRAUsCMKcJKNXqz3Q7wFY", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qetide9pL5habC2jHoJcjHNEkim5H6f2j9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QetnFFEhsndNkzS8n7cg35G4agS8MCwqJV", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qetpzg8bEig4AXXH57FYbWav2YTrF5qSxF", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qeu2EpxJDgUqArF5YzwfFWE9mqqiVkeBhZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qev3a3vb4tiYX1S2xGQPf8sAn8JTSzJSeh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QevJ8SdJQnTQeLLbvBBpvJcR8NtSQzL3Ny", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QevrUV1XFQWSpqbXmV2V4MqJoYaRZhhhuR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QevzViTDsgy1242X9GdLXr6YWmRriFofUo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QewSBodZQXk13wHWjreB9oLRxMTkwARkGW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qewa9jZbLjyw89YkR1mvDvnYRS7pAFVRX8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QewjmcaX86q54JKhSkcvhk9tdperfejscv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QewrEYLQ7anM7UyPvLEEitQpfD4pjt1pQQ", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QewtbpeNfB7BJ6AEaCveVMpEhUwChfRiSN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QexK77WuBGSxqUa9Z7iALhjmepBhP9o9mT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QexWWjQYWJiJZgJmZ6Dxifuov7szFMN9YF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QezKrHMtM4B8v2ia7GswGbVf8jETgJ3wY1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QezeJhk613RftYbrrNeafM1d24rR6tfVeH", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf1EG3MJ32z5bx9H3XeTrujkLq4vvm4jzz", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf285Bs4dYJRK94BQzkU7HxKqVWp8SZg9E", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf2PE4YX3ignrp1oTape3cSvNLzypUQxgx", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf2VNAs4KP2P5WgK9xgnSXsCsjC2vpq7ft", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf2eC5PFMzfqdcS5xq4vfE4n8Hmt3iuYuk", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qf2tsuCHEnJiNMgjscoShztacDRMJSaBA3", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf31syGwHzKFYWuW5fMrbwkMaWbouNjE9Z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf37DykQx9EPPFMiruhHWVQ2fZqn2N6n9k", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf3AhGcd42o74oV7mcEB8vB37cE8dEW2ZD", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf3cHHqYBkGYFJHo25HtvyRqihY6N7hm4B", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf3gdKYQqKgXs6sAVkyW2uHkautxNbzgvJ", "assetId": 0, "balance": "0.00000652" }, + { "address": "Qf3p1wauz5g8St9daChNw1KvR8KPvMX4QP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf4DBqcHTfssVBLNemQ6ADLqSG8fY19BLs", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf4QdaFNUN7m3eKJfst1vzq87n3cgER6gT", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qf4XyAbBLLuAaZ2tpQWY6F2TPUzTeqm49v", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf4dxjgNmJbncZPFv8QS87bpetG5X81MYq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf4hGssyZ75xp7JKAeiqVkB9pyU4dSccWP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf5oFcU1sMFfVUte2eNRyZDuJDGxgwyfUD", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf5tud5WSmB2TKActW76rbnsU9s48tGZW2", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf6277DzNzFnS3XPC8eTjJ5UHENKnZamwt", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf6J2WmXxBRKzSFjGtEaCzpbtWySfWBmZL", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf71USG9dngAa8dJWfuaS3HQfvcBP22wK5", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf74ZEQ3wgbv5YspoHypozosdPDngf2qM3", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf7JQU2m9qG9XR3YhLwvqjMJiVLs56HvJt", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf7SDWBmyNtk2SqFFqpqW1AfoDzbXFReiq", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf7pUuX26rRv4pLLgzhR8uNYiJAyqRFo3s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf7pdK85UjNNi2HVGc4jgjLnv1bNzGT4Ya", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf87hsMndqhPqGwYaunJdokWQubiaFy59K", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf88jJ2X6FSFTbY9r2A7MpZBaeP49BY48G", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf8FjrakL9sUSQp8q4pBSsuxmUwjMBGSLj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf8HiXXy4uYaiQ49Ueq6eb57sa65z4cDoM", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf8vYk2raGDdsYzmFQHD8nCSHHTtASPngQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf9AMghvwAMwoRi38YzDNNxQHrEwAwgyNo", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qf9EhauXNnrhAr4WrUehnz2cJqtnfwCeAS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf9VAxDvXobhU5WjtbCW42uZ5kMeTawoCY", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qf9rH21G7fBhTZWmhcpFSLsLMCSqfEwU2Z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf9yCw4JhXZroHtpg6JotSsMnMX5TTTgk9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qf9yNpF2HxKAozjmQYudcThmPrY6wH3g9k", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfA2YkFBYQK3PXnye4nFEGdKY27m69swUz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfA54q8Ay463jo3qFirYCUBvnCRFaamFcF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfAC6Ur7Uac7M6N1RcNZwgadAHTv5X7BKV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfACggMSQRPwvkzRDmzv6zVazjE8nNKLL9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfAkTsdM41SLcMb6a3ZHzYAuKE2mXEYEXT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfB8Fc6hRt61ALJi63T79NeXFoF1V45aee", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfBjZErUqwNcr7AbphNM7KjCXAsxqov7Ev", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfC8qJ9Y7PuMdedVQn7WAuFT7mAMnoMfjE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfCVy7iVwcLyyhkPPomRkth4xZqk6Vvesv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfCubv4pHcFG37hP8wcQ1JAtRor1uVoBCq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfD3ACRuucfGXvLzureMW8xkru3MsiWWb8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfDG6wvHCYZ1txxuGY1ahFv3zxmJ1Zh1hY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfDaxmD8jKi3TovWA1NA8RL5rWYXRC12uX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfDodphxPNF6vqdsg8q7BpWpyrb4yDruaS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfDqDiFuHusVbFuErmd2yUNZWvNviTQo8Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfE39DDqjJTMDCQ4qchj8T9oLdL4A8pGXR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfEDtZYi98YxHspnhoc7GVY5yH64db8JxZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfEGyZ1Zjws9kqMWTbirBYk7rFxE4x8NmS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfF37UirnpjD1HTNBFD4a9hFTcjhsvvLjN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfF6LCrLyV12QsADQMrx4iBinn5Hx3T6TY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfFD4onUxa35qtHyXyytG3z45FdY9UuquW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfFG718f4fCr5Z4Ta7azVUePrE5UCg9ERL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfFczSHCfrWFDpwD6CvjaV4Lkxdsd9Bt8G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfFg9rD8x4KoM8qBHmCPDRdrVbgpurWJbf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfFog8yyxKmhE81N9142DW9bcPMoafZSkf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfGbo5uujXy6z6MGXeFYkrkv8wjm75dgQm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfGjUA7NEBH57uo3XX2w97ryacRbuBcsKa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfGsC2kBzYwDf8XtZKhoEMpLU5MXtU3BwA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfH2T5AeekhJmgCRBMA6imd1qVvPsJQK3U", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfHEFVjE2krfko9WTx71tw8E5QnZxDoaUW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfHHN6562E9mKJ8DNLcHKRAXnAGoPEaWe6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfHJ5t71p8kH2ZryiHBQJ75CXDeQDm4PbK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfHs1TcNcYnhTcaxq1HzGigJJQPfJ22NrB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfJJ69gfHDiyUSopqj1etnAnLZGBUgyTQx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfJVbN5dRnMUSedZH68HhYCTJzjbUo121Q", "assetId": 0, "balance": "0.00000988" }, + { "address": "QfJeRouW1NwS1JEYveJgMcrinUhMWB6Hnc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfJppa7tgDH6MysTqBem3iPdgddjhgSMiS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfK3j5AoCthDTB3dM9QEo6P6gjSUe3286a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfKKzGktu6FzNGWrzxcmVJZ3SJV53PMtTw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfKMxFjc6bg9hmPALy7J8ZV2FwFc4sKaAg", "assetId": 0, "balance": "3.03000000" }, + { "address": "QfKgAQyaiVFRURz8uhgpknG7E1mJRjnwGL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfKsnQFFJWMkXEz2bdSuB734uMNpi5VAaD", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QfKuXrm1GKhNLWGmS5pmgXyTaLxqVNDV2d", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfLe4zWTz3SPFeWLPePMo3p6E8g2XxgXeR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfLtVsiUektYEfr4VB7e1PjQyNqkAKN7Gp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfLviyamFEAYTNbVxhiDTQBjB4o2s9v9e8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfLvmb7RDNrbk3d59vpxaEAj9NtP2UmbEt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfM4kkHg71LXSrCTUsjXCfdiSAWhvEFdZt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfMPvGEBJMhhNSfhNqRy6qRYvybdnQAiuJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfMRgM2WVExup4evosrrubY3Qmovv9vjPR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfMXNnXNBXaqESjXpn5nT39DoySDtFbXQK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfMr57hH7MTJuowRLnbPVVtyMeENKibyn3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfN7q2K3aTDvEWAUZ151KgCnRfpSZDqGB5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfNUM7RH84yoNNLAAhU57woYxF1muSvMN2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfNjYx9bTGV2TpGdcisG5Z3PBLhiiRSpAw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfNp2eVqfn3TKybU4vSPY5AG9bCh4eoiSS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfNpAPPZ5an5DCKwnZ8tmZHfTJYmfVeEip", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfPLP8q7ph5JULuqdeRTmyFeDa69vtkWrn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfPLx3iTEgxERVJ1rGuCTWPCL2SXXozA5h", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfPP6yzdy27cvJvhA1bbkebq3h1aZnoFYX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfPQM9BSsiV4UVqQAeEAyknkHZX4WUiPzs", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfPaNtgahJVJyC8moBbnqPknGq4L8taArw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfPcwetW3BErP4ySTurxFJSHpNkNXPEhGk", "assetId": 0, "balance": "0.00000017" }, + { "address": "QfPqpiqZEWcveXbxTfqdEp2TECTBgjLsTH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfPy7j7J2qrmKqNsvMg7uoMSAPtLufpjyC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfQHSwXZJAhEWQfEnCze11v9cF7QfhH4XY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfQKG4CMEjqGQG2WY6YCVVDRoCQYFGEiMH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfRBMi2NwhByJD6RiugjmtJxRbMGgEftuC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfRFnfbf3t6rYKSExu1iQYSw8GHhmE5PvA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfRrT6PTnFMDbNBrtnt7FHJDcMpDyFJUf7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfRykgxR139CmZ4nDjmFQvaSmNiv576ZYT", "assetId": 0, "balance": "0.00000988" }, + { "address": "QfS1V5sD9PmEV73faTAGDYN6oofWmU4ckQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfS8UzKfTNbMRGWQWDPgHSMPacik7u8ha1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfSfJQKZ2WHSrP51B3uba6SdjYDSje3Z7H", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfSmj4i8cRq4oG6MVZiTrQHA6aktwtWvde", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfSnJbskaE1dQh5tSVtjmEtegWQpHZikn6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfSvbkSQCZwDbQrmhLTtMLozhw6rwvYLdn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfT5G3rbnnTT2Rsr8CDcyG2Gmh7XVWN7iy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfT8JcwY4yiNnZHf5dBkXEaLSP1uaGhyYj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfTFu98GKAFcawt6gMe4wWV2fK36gbbQeZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfTfrovRHL5cd1DpupbQNQwiaBQt9BKXp5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfTxdUv4M5LWuaoybQwh1VZ8843Wqq1r1t", "assetId": 0, "balance": "-0.00000623" }, + { "address": "QfUEvXVCCcqw8wwceV7B2rD98yUcGUa7AY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfUH7AzewAcGR6s8jJ5PnsUxMDDZcpdsvf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfUK4W72v1geeYJ55bXc13hGJCwShu8gZb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfUVriJVPkZ8kTxvrmBWMJUcdYB8R82KLG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfUe6FtZXy95L8ZGnc23FKsHJS3irRik2P", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfUpGVsfUZRNC8dXY2bNJQNQKtB7eUd3Fj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfUsnmuDNTHj777CitBVpCf4x5bTuaiJX2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfUu2KAEuoxBKHMNFMKaryyoX7vRTSvCFP", "assetId": 0, "balance": "0.00000988" }, + { "address": "QfUvMcRJnK2rxe9LteEx1ZdrHG4vxUYmNV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfUwDi7SrxCR2kTU8v63osPwUegGYFhQ6H", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfV7zZFR2xLMHW2KnGGUjYr9ZyhHc4dYCJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfVP3u3djiVteHE3UFd9KsY7TiwxSxDXqF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfVdrzkvtssywDFJ52jv3Yte5w6WBs8eeg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfVtGC2DsYFKc8Uke3yd4bRU8fWt2AG2oT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfVtqS4KAUxEDcTSXUs44ZFFTm8ztDBCEQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfVyJ87Lari96jJ9BxUkYZwBn99xArH8jU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfW6Qq4y4tAc58RJpUeU74kvBjEvh4uLwd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfW6RxcBipsdFTDMnAmE68FZtUQ7rhhk8F", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfWBse32jfYSFpb9aQjadXLXE8AZAYP8ez", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfWCxeUYrtB9PB5xz8jpnCHMQfDyif1148", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfWGkwNRGofVfF7M96zYyjHGz4ZVQri31X", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfWMTHdzFXBFXmeVDnWou8YJfsZdN8HSvL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfWaK9tttyCC69iV8JMfmVYwXCieEXqCxt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfWuDf4QE2ygs3mT1nokqqgQgFfgYm2BMo", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QfX7tNmpeSNJnd6omykUC7RCHoL578Uy6q", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfX9dsYhFnMY9h5tBdF3KZRKYwVmB2RQLb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfXQrPJtQuDoKMYXpNjNFxsULoGLqnRWwc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfXZQzM63UD7tCGvQvE7s9CcmqNw7Y7Cgi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfXw7L5vXkfoSBK3GMwmCne46QTJU79q89", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfYPjBw2JKqXrksxqfzVHu2EUrrD1Q21tg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfYbCYWKrxDUn6kET58Rd7hnFik4nhFmYn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfYi5yAgBQDUoopiXdzivQjujorbJxGz6j", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfYsjWjfNTrT3RiVSavBWTE8xgxKdvxzyc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfYy5qFj4Kb7dFWLd9s8218gSxQER3h13U", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfZLq82MszyK7z1jCANPYf5NfV7yo15X6N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfaGRoKB28vgztjTmCDN7tdqA9hAic21fZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfaWdGCniBAizwNvjzBdpKBzYm7BuB3Nfc", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfb2G7Gu1r2efUURYFWVC5yfVXTCmb2u4Q", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfbHgdmveAasQn2Mrz9cecuWgunmeh9n5V", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfbX8JJupEw5ckNtU4upQgET35oLTr5e6v", "assetId": 0, "balance": "0.00000988" }, + { "address": "QfbZciT4BsZMvvY4tREY6Bu87fFLEFkKZ5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfbdzRegt492CajYCA833gPk3jWmzr4H9G", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfbqiBzGMqC2LXXpkq26dHT9E7LhWBwD8q", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfc2PttSHBouzANTQ1Dcudo7f3B34jWNxd", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfc9ZXHSwCGDuSEYd7e5ehhWJPdihzSS31", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfcSr9oQTuFbCexqzM6U5QmugeS5g1L9Uv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfdAF2UsTbQabX9pQuz7umWa4mxJjBH4k3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfdMLMLZC9Kt15DRAdxwNfaYdGBEMF9Sb4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QfdRmMFQMy9XZi8cRtvU7h9Ga5396XVqKA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfeU9nWex9UDjVfYxTNvsch6CxatFz7c52", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfeXrTGvFwcp1GU7nu58BcAGGfdqwUFJDN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfegskrtssZUKKifuY2nrTYPADTsUtTjKE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfeoaYdfxxUSJfb7qvEpo9dSGxWGffXtCj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qffd1qyWXmC5ZgUc4GQzPCHkeH29DS52H4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QffmCZ1gCq4yc5FwomLGJQTpiJBVXayKsv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfg2UdeXa3tuq7emuvx2dHeTx8bj4vznMC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfg3r6rpVEpS8Q9aF6nuu34CsPwKQWJFoW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfg7xbtzyCQgGp5HYNKFq1Ko3aSdcEPYqx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfg9CzLFs5YiXaQcyPhjMuKh17LF7ZbJVt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfgTg1nfA9uEn5o3zomoUzuX26n5dPGFFU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfgUNcWxS7NLduw48YL9PpbtNX6KRqzqBp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfgqBvCB9uJSmRDEMuTLkSH8r6oeFr9k1x", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfhEVHyukAP7WvkBzvknF4wxvczwhLfGbz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfhJM5CpX5MhfjCcopwfw7pgS6w1hVJ49E", "assetId": 0, "balance": "0.00000004" }, + { "address": "Qfhk2FGfVtENAcmELvEaf2nh5QKmE4VpUf", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfhrz6P62teGPXim38wbB5mpZGp1pi7s5G", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfi2uTby5hGQTYuXQWa5AqV4BvF8SxSRPj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfiKdwmmAqA7Us84xzTZZvLgZTUxvT6qRD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfijQmQiVZZmZkFL2gKhCLQ12KE8RQ7sxo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfjL32jLsxtumbfx6ufmfCFCBccVCQFkrh", "assetId": 0, "balance": "0.00000002" }, + { "address": "QfjoMGib4trpZHzxUSMdmtiRnsrLNf74zp", "assetId": 0, "balance": "-0.00000072" }, + { "address": "QfkEBBVTDgEQtSLARtfh85cfmukEEAnzBa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfkQqkrVoymztaf7hsmhDdD6WpELY4Frd4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfkVYir2e1CjNgs48No5nhG1jAUbJJj5TK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfmHQqrbifiZnRNAE9zUsTeebecGAeidow", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfmM8dgfikTB2FYVuJ9owzQXVm8wP7T4QT", "assetId": 0, "balance": "0.00000002" }, + { "address": "QfmvEzvM724awMjouF5eJnGAH5xQVhWGGJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfnHaAMcky7f1BdQUYfH7mkzfoZQKPPVfk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfnbnWrRQ4HNDQvtg3wG2B1eC4ycUsFqZz", "assetId": 0, "balance": "0.00000988" }, + { "address": "QfnjuQfLF6B3PF7c3a4bAY4xsYdPeurxa2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfoBkVvuznkFJLkT1kSJg3Dwm8GzUTaa6o", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfoPTVEkX2tKQoMLEWoB2a3Uj131jNCoUW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfociv4PcUSRCC9sVLjs2Z4MNFanPV6oJb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfp2QZLUvrU8obR1GNGPZMqccuC8eiy9Hb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfpFV4xnC7w3Uf3vuQZ61SCQVhcMrMsEHR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfpmpmwPTphyvJENPBWwQEEiMV1bqUuAiB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfpsoacGBBitd2aemfA5XTdnQoqEDg7dEH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfpzpXU9hE9o2wninufJgcLYLf5VPiVfi6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfqVzarD3CTi2cixZVQ1dMWpf7kNZCa3vU", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfr1TPerhmUCJxHDhgGogowQzKJwEpZesB", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfr6suiRoJVGWgmxrAb5sdZVWWuPm1aXqD", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qfr9WX9W5NL1rdCNhPF9BDSkJ3tKZNnpXb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfrL2LC6CWioeWPvAuyEUX4Hnmes4nXUn6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfrM4Z4s5Bbe72jKSoH1wzdJkPH3NwYkAD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfrX12sLWPSC523aRJV3TvAjcwFPxwWcRY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfrox3RWBichr6uW4TCgpNTkBxHSvFSYyx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfrv9B8b8pgeMAfQFYz41Qwg2HeQYM99bR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfrzYiNHhfFxBLVjK3mTDZrfWiWoFXqccW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfs3L8w6W6xfwqitk1VhDkTccUVgBnK9VP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfsNZhU7fNCSF5nu6t16YpkYsTb3JcFpSD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfsT9wysbuXgUVhXEj8qohmeB8Ho2GWgL1", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qft1Ckorj3uckvuTjokt6k2FB15oNkVW5a", "assetId": 0, "balance": "0.00000652" }, + { "address": "Qft1ktvJ14eBFjpJaphT24ks4WRcN3K6tB", "assetId": 0, "balance": "0.00000004" }, + { "address": "QftJHuv3kFM1nsQke2PRDEDtgPAtqkBUWX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfteKoUy2sWw21WRNyrxZZ8pVm3ahnv6iV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfuPYbetpPDRqBgD2QKY9YhNeTKXLUpeSR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfuYhb4141sa5tQY7C4CUxUPWz9veFCxnj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfukcV23TV6zrmhjrYPDDUFgFzTetvSVDJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfupYC51nivqGzFs8MJbK1v3yTwoQGA4Pf", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfur43bR2nNXwvyDkhNvR1ZyX2aZHSbzch", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfuyB5et9PojT1inN3b56U555nJAK7YxUi", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfv5s5YPvLHWf7Bv7meSqUHoLt8A6Hi6c8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfvFbgXX1miTiiBnMn3s33pB24tj8Qf11z", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfw4QgH1K1MqKjzgvkY1jAiJyRuEfAayeZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfw8iJLRok3vFrenNxt2o4DatGY3hThsmr", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qfx2Eq84Da3KQ7hsRMhxDFXSLyrU18sUf8", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfx3ovcgJ9MBzqKugb2MLp2PFyCbqG6abD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfxRt8m59vNMGWBtQpmjQqMfo8ZF1dmCDe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfxWEafPdxE8gfwqZrfcAEzuYDCDjucUUi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfxZFxPW3DCXJ2BMY9U2LH7KTHEAMepqz6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfxtv28njg67PMfRXENShNQVW1X1SXajyL", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfxzh2Uhe84A3xRzjkMZ1RbeSBK9qMqVBW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qfy6dzpjNRUXD5vJ7ZzhWPcw2VwH7b2Hig", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfyESsppKgCqCe456SXxiQqShjQPhFsSpP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfyZw638LTi3WeZKPQimPa1T77s9fu6WA6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfyccvcCKNSjvCG5DADD151dj3B3WK3Gyc", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qfz2y3qf4jMjAmj7qvPYUR6ac8bN313dfz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfzBdP3awhF2xx6QxGGnbYTjZg2QowmDZM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QfzWakZEAL6RyMyjh1pwhJQdz4zJRbqFxu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QfzyoaSG3gB3pZC3dxMng9BvZZDuwjd1g9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg16aPHLNRW6hK6U8tdXYo3obk9xpTiGfL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg17mqQYCEgWSfry5sVE2HM5MuBTTN2PtC", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg1KwpQFkX1teT79uf3rhLVtXR9RKtWL4n", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg1MGVa9uWBrCGpb9nk9HyfwBiBJa59JCM", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg1fd1j9hSmPiCTh4w3gm2zs5AP1a2WEJr", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg1jKE4A7MN8iRohupN95MfFWciaK2HbMK", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg1pxngTfWynQJhAj3He2YyYEAEjevXvBj", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg1yRY46MGHtN9STaPXDb1oiB7RUkA5AVT", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg1yST5JmwLQDwLYipMJ1uFM5pJNXQKk44", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg1yzP82SghJT7o3kgcWMMF6UYYb6BS8c1", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qg212egzMX16rDyUzkUgtJ6fEJ8p2NR3m5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg2273uygJvXdtp2kmjWgPEy48nX56ctZj", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qg2J3bYVePsxFVfGGreXaFxNxBcifswPCZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg2c6hRhFisf4uFmT5N623DfTK1EGLcRGg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg32vHmYCUq16co8mj4Ljb8bWzWu2eWmyF", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qg3JsHRBoECqF8eq41DL6SUw1uQ9GG6sXM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg3gumomFcX4JQGgfYfCAAUGhjzRDzA9vx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg3jvDijCvu5hRpMtA2tnJenaoRC78mteh", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg3vBDTw3hJ2MmNATVuMLEHMwUHWLUojCk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg48jvLC8bbk6pkz16uRcfVttzVy3Rt1XH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg4ArPfSMVj61nobxgAuAgWhuUqusTzE4k", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg4Ha15CFU8mEEio64s3qiLsMgkTGaiFRh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg4nHGdtF6aiePJiMVKemoPziA7b3SLw5y", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg5ZkkZxe9eHELyQb2rRuiSmTn1Y98w5iw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg6B3mCqHBUY6jm6fL2ddtUvWTZea3xcfV", "assetId": 0, "balance": "-0.00001793" }, + { "address": "Qg6Hv8nTG5YnKER7iMSE4ckeQGEnGfyz1W", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg6PpXvEHvi9c3v2qXnLzbhEN61fvuv19L", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg6zAit44isxgBxY9rGk3koiGVg7Gvnzjw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg8BdxcjmKvVV4jB6HQ5JFbtcaJsJz84tt", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg8BnEkdyXsoemk5n6ge4nVnV9wFRyjdST", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg8HXL9zog6MmJNv9nouuNFCCfQNEsECJW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qg8HcRDnXT7Z1X71StTw1bozU2tCjUYHTW", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg8RKXUfo7YK7SHCoBgNzkn5M1tS3SNrgk", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg8fxbR93cLo61QVJ3R3TbNocU6QSf9R2a", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg8jNxmatQdmXgHEoVugE54u3A6FsG3Jnh", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg8oX3kXUbrDkxyfTUrFjn8HGcMbyHi1Ge", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg8pH1KBV3JBygsyso88fUozvaNKE78fq6", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg8y5x9a3MnsFvXJeEN3GMSbR1RXXSHeQX", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg9MMwQefujLWwSNpDfFEuJhVMxENJCemR", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qg9d4zDLvzhqqjDvRz99jiFoRnygTyzeZg", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qg9qWQTeLRTmcbFN1gigu6qfVUnbrpMP11", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgA74Dp1MkqSJDEsh16htGQfSEVbvohiTS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgAL9tzvXkmft3rfhAtce8MMQ7ahgQ2cKJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgAN68SuUwe2AoMuKcE8dmYLyiTH8mdzgj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgAdKZQ2KMbdnCzMwnraRNcnryZ695tMQA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgAo2Taug2Z369oLb67GxzAkkXx4wnvGHi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgB1mju3VjZUBfduut1qFniypFNN6XR9A3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgBfw49fpZzCL7FRLwMsq8677ffZLk1XBa", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgBsUHuX3ee2FZWNdM3p5TMtY76CyePn5a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgC2HQYg7vyz7EeHBJSPwKoqcGXtsRFudf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgC4fDTDUZ78gV14sKFzPcqTjg8bNRWt3A", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgCQq4cFaGrJhwvKs4XwvccKiLZ8GVMCXR", "assetId": 0, "balance": "0.00000002" }, + { "address": "QgCStpMypG3KN3xa3hEoxvkkGxqWbL9mky", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgCVpq8ka18s23shwguc9YPwy9G31JPt62", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgCvQzmZL51EogeziVukxSkT3W2AqpN5NQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgD79tATM18RdSM19LmFjHYtFYD15Cz9Lw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgDKBLLPfsCT3zHvfGidDB6q7ooxftcVGF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgDbJa8sBJmvteWcdJntTGWLcnL3FvZ3yP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgDgE6AAfM6sBhG2ZUZgCvgvPpHvMzWQRp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgE4hNiRUEqfEzbEjr1eCHwCnRW5TzDMwC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgECFJiiri2dDN4zA32URvbdDid2cFrJwM", "assetId": 0, "balance": "0.00000009" }, + { "address": "QgEGaSaoCj1bGxyj35qaZcpb23Px2bBJmq", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QgEMx2GrSKZK7SrT6RvKLpy6ebqXgHy9qu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgEhtwA3pciTqfo1ReGU1qjzMvhnoLcAw2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgEopNtNTZG8fdRA76ixCv8vWA3ng9Wf87", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgEtuSP8tFW6Bt8n2YfxxoiMVFVRiEH3es", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgFAwkBeTonkP7TdFWf2rLU6ke94pvYoP2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgFjfyApbjupAa1PLBdy5NGNZWXEha1p9T", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QgG8HjsaMTXco2NSy3eRYVK9WYwcuuP4eT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgGbLwNF5Lza923CcEhqUB9FLDJJQUHQXb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgGt747hzpyXQBxFxVA4fTmy9eFFdSmaD1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgHRqFHrhTDL4TmnzXpQDtno4q24Q24uL9", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgHWF9xV5A9A138feDxJzH73ETar7MbskX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgHbJSB9qjYuQyT5BR164e7PPyA3GjkXAB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgHhee3CSRavmr7h87XSsLb3esiQUyRjxj", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QgHqsrXkKkAYYkrSEr3XP6ztmoAd7ptktv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgHrkQbJEc5TZ6vy5APoCyDq9KQ9Uhro99", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgHsU3UbVH2HWd3cZKsivtCTMcZjsyEYjc", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QgHycouFDAwm7KQqcHB9xQubzBVX3fcuSM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgJ7qbDPv8XLVYSDKH8aiW2Hom1yYXEeNg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgJFFuiioRMwyE3UPVBdKqKreAoZkxf1KB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgJFbjrz5BF1dWrsYBPMULBr5Keh5QHzAa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgJN2W2JpDVRJbWmTk9cDzagqvFJXKShcX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgJSNqy4i3jMkQQEpAJf8uQ5iQNpXEwmkU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgJdTosTZQPzBYiWQSeCDw5zGWxa96zfkA", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgJiWjBrAfLVNFbeSvPbxXajEgsrQqut9U", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgKQLaH8LZqdax84mtoMpTWu9ZS8zB8Vnw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgKXnkDcBMGUmdjLykVhsYaC7eTEQiyYpZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgKnSmQ1vpCsUzY7aihzQnAGvkuAkBcQsG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgLwjzXhW7CFEguNU53F3MHmQein7hKj1F", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgMJYJGfQx9z4ApAmaCW9rZcpGobZkqdus", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgMZoTLXD5zzUG6RtagLpkhut3ABqanK2m", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgNA1zHddtyccP5dwZPgLojXERWuTnNckr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgNDgzNvs8Vta8Q6XXynGpnQuzqfcfF3mB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgNJ3HRYb2mjAEJLm3tHF8zkZVSzvugsqa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgNtUZEQAbDAn2zbBVu8KLexZHLvDN3Rcw", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgP4HvgmybudTgWS7Z8jWjn61xrJMBQGPz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgPCM5aYbQHcdN3os6MBBBgmXHxVv7diXq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgPHkLXdRAjnTvitYXDFWTePVgirqqmSSi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgPaPkhB7XiWaum5u4R5NJ6fgddZAqjSCK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgPqwboR47TF4z5mdcQw7jfHxCqHNv4zvN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgQToq7iEzZynMbXiGDUswmCCcVzFQuqVh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgQY4xDdP69r9dpasUCtQ3ubiHUzTzRWHt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgQaVPBu4uDMVWG48Vgkt9rEeEkpaqkG48", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgR39TZPxAvhg6BUefYTKWqWBUwiwxBrKd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgR5M6ce5he98GFEAB2SMyf1yREoDuYTUp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgRFCg7s8znbqCVmcXJ6AGgfn9M9NhQQqo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgRRUPpxF7JVqU1WVjeBg4e9pTWv2AYmHH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgRfZM6pz7JoX8N3YheCqZCLkZbLZmAzKQ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgRhyJeZZGhZ5gzWFgCWH3QwHy92Er7sw8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgS7aC3TubsRgPUMcjEmdw5aoCinWnh8XJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgSAerg6UTt4Le4dVsnQXtLttdoED1X9oa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgSCGqwFGBmb73hXk3sPi4C1izfRz1k1V8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgSCgLQWuMCd9867ygUToidDaHstCaaK7X", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgSPxuobckyKpMRzxY48EULXZYuWwnVyif", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgSS3a9eSG65YFjhnFnbPEMEQVVbtTpXyF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgSTYg45oHikYsny26zqGE4o1JWWgtEQhR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgSsS2mDqD3WFTB2bB2XWCTbcgEeosAgFR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgSsVgQwFdyttkKFSQcsSbR8AgtaM9citq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgSycibKyhxsDpksF3PUHk8227KekF42Wy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgT9rE4dvqUqajFSs27ASgLA9SXnWC4kNf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgTEH4mTtYn87y5uhU67aydXQ9H4z7bqNd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgTUbbnRmnyfJ8WT8mRL8NnHQD3FRwDaLc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgTpMYmpUPcR3UWHmmdTLeqPH42p7bQd9G", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgTrMToeNFjjM4K4MVjggUQfx1y2TaxruZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgUQBQMhMTmiUHbdTVEdd8mvYFTnH3Ngii", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgUZ3UacgDCmEoLVsji4y1M9uiafS7n6z1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgUb1VgY5YLX4mvAm12g5gJv1saSSnajCN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgUehK1193u5kGU76cZHP9zky5zHYDTZPw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgUhCiEHoA8ERQFzog8ubuCd321f1VYbDP", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QgUoYLdDX7QBPXENvYWJu7vwpGffXaekTX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgVFtCKPSpy5ovJvFYgfwNrfAopFuQjJ3v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgVKaUopMVHhfr6dfSwDByFxSGVgtB7xHb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgVKsJDgkYCoNmhGgvkMVFdRbpFctEapDK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgVRwXN3x9suBrh7Dc1HPnRiMeuLqZ5FJk", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgVZb632eqF1eLQm9gBGuBtyp9Dyz2FKUK", "assetId": 0, "balance": "0.00000002" }, + { "address": "QgVpiZgbhPuX99fzBK8JqsxUSaFqTHGGG4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgVtEwtJpLbC1dDpZ3dgxStbg5rSPGBntg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgW1bGKiU5JaQP51bLd4hzVZMaLgcj7Gfq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgWKQkbBNufQrNFbrPBAWZGUPZgbPvQqwK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgWSgnSWW1Lsdg2eHwekvqE59teLfzCpzM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgX9joXhXZrtk6HVZ8TgfwVWJAr1gnwr4C", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgXv6zprpKdVmTV4nbaJML2npWDv9weBkt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgXxrkjbRvqyBstQZn5MCDiEyEzdNFwmj5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgYBgwCRxPquwcgmKtMRBuTwTMssn6CWDb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgYQpgDWSMi6Rma7VqzYsuG7TWq1ChSxEv", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QgYgcQV1XCRkHDHEG3xBFJdVHD3YdPz4M9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgYnMCdhavoixB7kwq1B4m1Hyeg1rfMU3V", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgZ9xCbugK3msYHjfBGfhse9UfwGMaYeyh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgZVpHDe9jYfzo34aHs2cuPTZzoeFx2Rdo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgZwt1JFAgWTxrcn4Vv4iJkniMHYuouKgM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgZxJQEuW8vaxfjdewccNWxZpAKiY916ZZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qga1LWYfbXkBaNm5LCh4HwSW8L3g8MFzSm", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgaSxt6iBj5ucCB8Lfq23QBnVxDuci37tW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgaVHZauF7qjcH9Va4gpXnW9fDHhUunZnv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgb6jn7XGdUoCiXH26btydy9FqcsvANuAP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgbTscdLDevHJuH4YDE5Km22YpMyGZMEDr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgbULSSzWaUWY492XYJtpE9TairZL8Vw6d", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgbbmbCAAWnkDrSXUgHAEeGBgR6ZqkKtEK", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgbg6hcwZ9XzkQ2CUhQAQ92h2MDD9Dypcc", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgbr7tf4tFRz1qGk6etCnB8zmk5SRZAPKj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgcEKMWiC6gHKLah7zmRzNhKEWf33vvH3A", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgcF6KgVZ9eDAMHJdSEeAtp91t931VKZMv", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgcM2FrKg41DcbknPKVpZMxomE6hfLA8jE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgcMDLPnDQSFuN1YsE3rhNo3sLuEiL3w8s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgcfxze9chovHLyfLvqKyTB7frVq4G3EJA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgcujNPVvFYbUYUqeQio5bhCNx2hjkbNYm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgdBVZR5REJ39dDMbxRNfyXNL4Pz1gUVqh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgdPu73pSdu9uCAXHJMvLvqMxnLQEi4dhr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgdbWueLs2qztA3PNhRw2q6FEjoRk9oh72", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgdpLKawcKPuspfZ5xpWZNvn1ZPMe68wXx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgdtzpB7MVSBjbnmaZwvDt8nXBF5dgzxMt", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qge17ZUEWr4eiViCfKDJtrNcS5qvGe6e3u", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgeTBahkoyWfXtkPCYG6e94TATR2W9jHC9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgeZQRamaBtDGRfT4Lh4aaEMLSWJkE7wna", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgebTzZjxqQgreZxzAGB32FTHGhgyJ2XXv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgesuKa3zwx8VAseF1oHZAFHMf29k8ergq", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qgfh143pRJyxpS92JoazjXNMH1uZueQBZ2", "assetId": 0, "balance": "-0.00000054" }, + { "address": "QggDm4X2T35MLf5BPczREwgKtd63VPAUti", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QggDzChiD4oBmkoYXwHWCvvDQco97xWxfW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QggJ9Rnh99rRJMjsp6nLSuR4m8FAve8Wfe", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QgggBPs3BEapFR4aTMyxTYA45JnV997zU7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QggnvRNYvB4QmwTJ4KNcTK493acPCiYQuR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgh71Tq5nLXqKDrr5ZuYiw9tr2PBazKfob", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgh7dq2PPhTgbTxCUkVAkkJhqYXJMXW3V3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QghVs5Ks9u6tGrtYqFizEoTGGyREo6zJY8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QghnzTeQukMfyjnSbpkutNtEBNEQxwmjCv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgi4SrDotSGoDDn2wUTpkZuvQELNUiwbfd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgi4Up9dRKyPpRP34UBdZQTZd1igh61qEw", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgi7hd2um8nxJ4voxo1nmqSnv89hewXxED", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgiSdwrt3Uae1jRd2m2MJ5Z4633GaxSckS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgj1DNw5JGNt3KgNTp4tSuPG4JhHvrCwue", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgj2Anx11nCFYnZvuzRgMB5HgpkBMAmZ3v", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgj6UQBnWLgY6STTqJtmo2BCr2hAr2RTXj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgjAQbYgivdHy6CU5qSF6JmKmEp94KRakY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgjBUa6v3MVHC9xcdgnhbg752tTvCmxAaS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgjoh4yUFVH4qCVz5EsrWohk5zcqELXzMq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgjopAAxwayuHFStLrU3KkUQSANqts2upc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgkGF35JZnfzzzZ3GcrLjdiE7DWGzsoLGz", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QgkSe7SJfxNEmyui35AWHbnYpApNE4NhH5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgkvmAFJyL7fV5ayhL2uSx2umB3227geT4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgkycXhvEzmXh6buigfQoVsLV6QpN45tUY", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgm4oh9gcFFBmcFdaMN1FiCNsveW6DGGaa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgm5kB7wN1HJUjpAQY3BS99qECCGiXHXvY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgmEtScSZWJmTUAidCZKj6gDr3LznZ6rr4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgmJAg1X3MaQ1kp8ABKe7j6okY3RdumNfE", "assetId": 0, "balance": "-0.00001793" }, + { "address": "QgmLCETLMdBszG7spSYcRJeBfs6ZpyH4T7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgmUXRwwLCgp1eDx1E8idreNNAhZXStuzL", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgn4oKUMRv2faJZDy5jAYXe53RT6N6R31L", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgn59RKAKMyUzSF6JQEc9AnCENbn2ZGFzE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgn72dRFbuy6EVi8bc6ysdEU16t4b4JWwz", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgn7LCPDWs5wNJvbD8iL11RYR5JdmnhQZd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgnPvaxcZ4q3KsmETqkJ9Lxmh4rb4bC6nW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgnUzid73HyNN9vScZHKkVKfVVJV2y2tLC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgnZT74VfseKiSPZgzMBVE7JpRs3N2dMs2", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qgo2UPytcN9f32MUnWHjjtUSfzPKJ3rkWK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgoG9subPmSSGgVSjQutdgtCtThVGcyzqq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgoSqX3aWwGcuFDyJ1WWxsJf2RcP7txwVc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgoVpjTKxrnwccLH6SaPWBQETuFsw6wGdJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgoXoYtxr8JrQ8cswTULPo559rUTg6D1zo", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgp16aMcdiS2EUkxCm5NSZgB8DixGK51zT", "assetId": 0, "balance": "0.00000988" }, + { "address": "QgpAyceyQLzBhnnkpFGzuVZKMgXi3maz8Z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgpKvT6PN69tRSd3G9VrQzWeJkevja6sdb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgpX5fLyXwjUECcZLeFsqww3oYMWpBMavJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgpXUK9QEyJgFedP68iSPqD91CwoRnpB6X", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qgpd4UoyPKJQxGYsD11xtfR36jsdse1484", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgpe1NNkivhR7SeXGaLKua9kWNu2xq2G5X", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgpsvo8FbqHUAnNmqDYUpmdEVsb57DXFFo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgpySa8eMhAzmR2fdvB7y2P4ELf2kJ2ngz", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgq4QRor7aDQ4yGvDSJmWuPsZ6RQh3BMLo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgqE9pLELEZhazBPf1ewaTKV6mGnQPjKxe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgqM5bKs3tNqKNAnVeaQp4oaYMXCmX6YJr", "assetId": 0, "balance": "0.00000002" }, + { "address": "Qgqa6qPj78ous5RQR7zMRXbEEPcyd3kHk9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgqewapGDLak9E7hUPAB8PKUNPSf8yEMst", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgqn5wW7e9qsxFY8Z9JCKkSxQeim58ysTt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgrBbg5CN1dbafKFubm55W9QZ8CckWQESS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgrU4N5h7a8Qbxkf63mjJqyALzkG4tm19x", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgsFWrpyFvbWQPhwbG851EdSACJhMKeUFZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgsTsjGbShBEctkF5DvfCwUieeRs6fZN1Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgsUV6htmWpf4TAFx6Pc9XZe1PNyDLZY6s", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgsVsKp9vSqgmaGQpAqrR25JJAFrbj9Lfv", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgsifXqfJtdsNbxqGYh3hExEpWWZDg9rKK", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QgsxkxTBhBwtccex56cwbaYydp3imnikJe", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qgt3oyJM8YPRvWC8rccemReQDsgHRaSGfn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgt8EG72vghnhzGvMMvS6rpXsJkJe8QF15", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgtGz2WgKfVWae6skYCLsHt98TV9QXceVi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgtc33e6baoFvmYavx94BEPTFd1eu99bDo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgthC1CqAbX4yeY38FiYbafGvzHvnCUCXC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgtrKCWmHHUahhgtDhxSFBegqWahGyWXyk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgtxaGjzp9ZXMo4VaXfmBEW5Z9nhSSPxTv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QguDWvRKfdRv1bHDV5wqqnY1drJQTn6365", "assetId": 0, "balance": "0.00000988" }, + { "address": "QguHD47LDHyLP83GbkeQiHbJPbXkf4cnwh", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgv6TWj8ySgX24pCafK6Yrq2cJutBXsojz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgvjnkRYjeRqo2zi3P6hCPwR28j4hPZd8g", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgvr4gM3cmqGn9TRVFftEjc2iVb527dm7p", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qgw7ysacCWMCXX93H5RYnENeL3ju98yjwJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgwhvMo2nfs9R4zU13Wkpg8s377zmkV76B", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgwnV7uzpgXyLuCoCtVBBDULLxkysk5fZT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgwthhrSoqVeMcdWv51TYvj5z98xRuV8Ag", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgwyfZMs1w8VPWyk2N4v3Zgu4HEqKfoPzU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgxENixDEo5yUmLYYACgUvbWFbPv39jyBw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgxNVApvdh8Gm1vNuZejf2oNz22TrM6RYd", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgy4EMRLLUAibyY943RzG4bdVZ9p7doQpi", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgy9yYEVUUkmVq8aEonwEyYsEqUhWfccmW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgyQVuYGdBp1t1574TBANEgnEaiMjARBbc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgzmKdSDsawVLryHD22ee1nRP43xttG5md", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgzrKcQxbPvR1NXhRUWA2yMmFPZQJZ7U5w", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qgzu73KYb6bKJoSathS2pysHZaHrchJM4g", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QgzuQjMNzXuUDF1cTh2BWVJRqXhW2kQpj1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QgzutfsftpGL6Xpf56xKqa4oNaQKhNeCh1", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh12GxSXfuqSGNXW89NKJ5TYTgFjcfPAVu", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh17S5ttw6MQkgHcsNmC9BngD34EeJPuSs", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh1KxMH8vq2Y9kE1oD8HGYkuPGpmkwjXxB", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh1j8tVLG2LoPGKyh1vuDpB6XkA6dGyv2D", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh2X4pqtujUWNdPQfcxn7tqFTE54sDyRza", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh2cPc2Bn8fceg3wCCvkSk38oEkQ6KxWaG", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qh2rdKV8Xb8NLRdFuwm9RNjpjxP1wMz9VZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh3Q3tgrLFVZgEt1dgWJAAFzLdNbyEzxun", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh3QKXfePVC2w5PpGuRnrJwdtdXLxYhVB9", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh3Zpkkq882c4hpoBYi1a8XkLKAuAkFsHX", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh3c5j5U47FF8LURh9bX4FGnSFKx5nREuf", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh3mYkdTgrfXYMCMjbs9XL8RzKrZC2D41b", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh45mTMQh8phHHH5MLtmXUm31kZSXEu5pg", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh4EmrLoRwePL6mi9XZ85s1d2pkkfzj3RV", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qh4FPMTZdq7UGKmtjCLdg2dh3avGLkdwWo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh4KF7MAedkRDVaY4vKmwSbqYgf5HowxYn", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh4YmAEByL9GWPokRqXi1D4zBe9N5uqFDi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh4dRQmRAfqX9NdtvAa5Ke61A8sRTP3X11", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh4i3VFMFPZ9mSQjMPWRsqox8ZxoTSVenu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh55kmNyvRAmA7KZPAiZ5gmJSLGNNAxL5m", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qh56zS8o7t2Mn9SXcGYbCzczRgR4irBtxP", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh59Yq35xUoDgf4NRYFD9AcH5PwGMB67ys", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh5hXpLCxKf9cnJWgWtVs1FmNteNx5UFxm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh5uEeiaWFEPkrmY7vUnxLGEJ5i94JqcQk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh6M19kTYVgUt3XkCNSSfeZogRCtW92nHx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh7S4wbHX9riSm79fFgQxkwCXzBJBoHjHs", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh7dXjrvs41YVc8qkXmaNgw6Pkbwkp8NhB", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh7dzXvguacAJioftujrv3TMw5bjbim16K", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh7jNmrEUdFeLKD7WaZbJde4DwdRzzdbgg", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh7sjLN7Mnk9zNfqKNx27qNh2X36AmLaiB", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh8YroqR1PbqB2roqR641xPZsQo4EWr4CP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh8bqBSrr5sxS6bFGKGodMuKR87tbQpiSq", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh8eTyvLZwTTakE1pb4k2jzN45MWUZ2NUw", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh8vZ9pejzdfBmBtcyi9tgJkhqQfjFQR2T", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qh92fnNyC2w4QAUkosh4C2JbFpiF6HX3kd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qh99M4jmeNHb5aSRmNxMiWUPhKZWXnGQZ1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhA3DNHr1HhP87U8RjCCAtd15oAqZFttN9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhAKBmpKgNKn2FmLVvW3xvnWLLf8ogfWvb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhAN1HWg4JdPwnMNv21TzjeSBkSo7H6RxY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhAZCdGA55zsiWp8TybJwn8385ZNwxCtfL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhAuREkD7XBVwe7zQtjNc6fZoViMAegRJV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhAxi1seiN9HtacvNYavYebHybeMyC8xyC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhBR4CXEUCBNN76Kz17do83hh4Y1U38ycH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhBS9XmciZbgguz3D3Z4ugwC97M16vJkUT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhBfThRuebWy8TpKFuXPZqkLnhXyPVK5Yy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhBh6eZDSRQpfsoK6JyQ8Ay6QbGSwoRv8b", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhBtP9VuU8TsRqSN5uaDuLctD8aAWExNAH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhCAGBqSZBbnmkknANxNxGrjiS5ABiR3Kd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhCLfQYFAauGWzgjQe3Dj149rX9EZ4wheq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhCYpWu3ZA3NXEVBoWDSpy8iJFUwFrZhxF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhCcceGAcpqFUqD6ahzgtN7M8RptYFPCFW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhCyt8DqiumxXXJka9ErkieUWGW5AA8SvD", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QhD2RCxdxXRKku893rvdtJbnv1bt2QR5TD", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QhDAVTcLS7HC1pSfRfRj5u5wNyCQjXm7YF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhDB6tpqrqd8QRnzrb8fGWQW9ML1DZGtic", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhDFf8JajFHTA8J4vM7PNaLnc4X7wy8JGQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhDPwTaDTzjSCwYHZvphyqRVhHYZ9CWmzz", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhDg8PsFTruuc6FJLyr4XVraTiXRz36yY8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhDzYp63FWywRKR7mxG3VDqqMH6jQmw3RS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhE1vnffYzaKWe3dqoTKfutAqM43Ws1vwG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhECmqSVuAS3EvwaKxKBqNc7ofzCDNYqN4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhErHKBCPYacEGh1ecTVGgwSDr13r7fJm7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhErhzUapqPRDdXYyaS9nG8cvbCibhRUpq", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QhEskE13ge9umFuyvPvhtNKPdrFxbkMkVk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhEyRCo5EmA5bJeTQT5KJt6WAmvxupymfT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhFBeEbDENRiFdAjYDefJfM1g77UtoaSGR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhFfWeB8rRVZ1gNdUft7577qiCXK6HnvSp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhFyPJvHTuqcQezKzKb15UbKVUPtg8Nezb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhGCGJFLoD63LxcYhwABqEoGxkKan5dF8b", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhGDydbAa7MYPeq4oMyqUvtfBh5mbK1rsS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhGLAxHKaZtYKLZMZWQZQ7HtCUsFvi9pbr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhGbUmsdW6Nz7CoQrojksCYT4p98cRvJtH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhGkxhRsi9buFjLp6DCD8DkuqJcyGEpyeS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhGu3fifTU2zdBV87SDB49FcFgSHuegGFk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhH8txpLcffTmttkYjS9AWqi2Vwz6hHCBp", "assetId": 0, "balance": "0.00001003" }, + { "address": "QhHBvVmmMz5EppmLEtm9YKEXNzPMLfb3yL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhHRNGM8f2UoVkzDaGaN3HsTHdSQWSFnxg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhHSpQhPzWB4mqBLDoSiFgN35HjKoiAfMx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhHgYZ9tG4St9FU3j5BTpoPWFU4jx3MDz9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhJ4w8vqoNF22Vzk5oQqWHk7XajciVCHa9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhJHsagiEnmGb3Qq8NLNziMJurMp7uMNvJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhJLk3EMCchPHz79wC4bdSi4PqwbZpi2oB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhJPycqw4jZUTBBvhf6yWuAEHeTtYZQ2r4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhJsLENEviAT8Z9v7uKhbzhQCiWuUnabJp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhJvVqd1UysfbjnVs7WTFS3Ytzefy9oCtd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhKWxoUyRLBxTJAHxfLUwG76wQbHkhd26N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhKdasGkwqF6VMUUg4mtsESF494t4e3LK4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhKfmyjhLjehLKdLN13cZ89NiPe9UPCrb5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhKvujXB1DofUak7iUXpjywN4qbEAbdiVz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhLaYDTnijSjjS1tRSpuHhJ8pGwnQ199sx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhM6LS7TCiAiWMbvXWMWSDNJVEwHPdLb94", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhM9FYEdB3eV6Dk1BnX969Ec8aqHNTDohe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhMRhX4aBLqX9voKqsGWJ4gX3eDgf9CbLM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhN1M5ujmVuMAMfiLWgV76TJFUyaXTHDt7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhN7iqqnQ86s9qSrwRSsubRyBM9hKdsH43", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhNPzuaSorcCY11PB3sGziJ1TRTAMVTqg5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhNVZ7x4znsX7W5Z5UbtEDsR6rBP1z14Vm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhNXU47g1PXcuRhofEZLqxjrWmfAE4MzNj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhNgqbYCa7iPCPMkoZ7EPobThX8vDvu8dM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhNqej9ik8j1E3hxfwUDqGxGJZTKfRydbw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhNw8Q3zZ6ue2FwU5jHWoEvQeexyzBo81e", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhP4K2u2LonWKJF8n7GLxzqTVneennHoA3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhPJEbyWL1bk5UWVm4YwtTeDtyxCoRFcDr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhPXuFFRa9s91Q2qYKpSN5LVCUTqYkgRLz", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhPaTaGqmif96dcQWoSriJZDBSAgEST8dV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhPqmWV8J5ukwCyV98osmkHpUC9iLoTFDD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhPwExMZk8mW4FvH2HtQGbq5mU2sTHNS9B", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhQ4Miw1rnkVUorFGW4yJH1vnLeHg2odDT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhQ5SwUJ4AMhCnqJ8e5wv4Vqk6g5bqQd5J", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhQRwUDRpxmYQxPuHpnLuKGo9c57NQWviv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhQdzLn36SDgrgoMfvdZAkoWtTUHpB3acJ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhQoYXeux4qe6XwLKVk9BYm6G7wsuRcwbu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhQsFX4iYf9f5zQp5CLQPQVzSEX2fTcSbx", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QhQsyTc6hELTDFXPuc591REcfCt5391gmH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhR9Ei3haX9MWH5Fjs9sK9LXrFHMhohSmA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhRTuRfTwRQTN71FS61RKQ3v1npZKvdmYx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhRdGCjAR4BCtNj3bezuXY45Kd4t7vK1zS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhSGtjUs5JnqBgLYLfoHKvtMv779Un1Kzk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhSvJ4Wo5qfaknKRvuAr9eRU4dejSaKhCU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhTBondVcuVxZDdnPpe3QH24TZkYAUN28a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhTHNmR4YgYJu5o2uzGkgUHFpg1pYu9KU3", "assetId": 0, "balance": "0.00000655" }, + { "address": "QhTJmWYSNkHqSu1TtfNZtLGaEo4bbiEVts", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhTbE87fnyyeccRqZfwwNyV1NsvhZd7PBr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhTuJWA9wHn6ow1Q27wMHHapREbqkqgcvw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhTukkfFeSX7PWjKmErgM3aDNAtGStCEsc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhUuV2GCK2dyBWmKAUwWxJgyZBGcXos7cU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhV6Bx9SGQFJzBvFhh4ipbmuoMZ7KmqwVo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhVG1QQLt9Kskr3msXa48FL2LjdRqaPfsk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhVqrDRJitzEgCRQ4WtKPHuvXwpJuZ7AR2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhW74PJnhiNjMXbCNRPRGHbcvT6kXk1Ujj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhWWreqpqRD4FGRogpaHfbeKG37QafnqMy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhWgHEgU27p2LhEwYqh9wPXK3pVZLUsrQk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhWuJATw7nQQbhHAPzHiT2R3m53qeZjwoX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhWwEKLSP7JmfU9q97Ff6B4fSSkHEwAsci", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhXafZgDMTnKYaQ1ppYzL4dvCrDxv7UDYj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhYD58sT9N8b6jhNgcmVnLXRuVCnbnuxUd", "assetId": 0, "balance": "0.00000652" }, + { "address": "QhYRo2LezXxbhD5e3gKPhFUQnxeKc7pJvB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhYS1Ag1RjYVUSGYYKyvQXSRWN9nyFGnnS", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhZ1uDWGPwVvNJJC6B9VX2eSBLhy2V8SbS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhZJFejortmvM99apbw83n9RVFhFpNUCLF", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhZQVRiDac2riLFKZhT9ThagSgg6EUj8hp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhZjfTAu5esJ7iB491TWwZMfmjymN7iH96", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhZq6fStDxvhgpUf218Qys81AAYCVCJ9RR", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qha4hn4kGKntLpssM7EaxmhoiiWFX2pZEN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhaBWkpeDjihaYYCmqTMK31Vz51Nj3LiPt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhaC9J9BHrz8KUCoiWAjgwo2yeCFVmFXFF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhayPubeuB61sEimCa4CagBB1ePKP4MRTQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhb9e5oRB3Uiu5wvszwQzyu3qWnQtxBKM7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhbNEibdFvnmGyDsg8Yhpzt8dgkfnw3MqS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhbP4YV1JCNYtCqmhm9rkLwszS3Pq4S3NL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhbUiCnDkGKAtFDrNvrzx5qbxPng5hCYat", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhbbbDSdACiwguiHHB2sC9Uqy2HAGmqgwm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhbutC8ZumS5JWrFDjki64kcd2SbVpQYwP", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhc6d6roMNZio67AGHcpWg9sB4jyQJVtYJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhcYwNZyd2MaomRvWK1nMEQwgXoLJxPskT", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhcy2Cf4Pdy14K5763Kz24At8ySretGyJT", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhd1b635VGhgEEWJyJLvad9aFUTdzdSpMt", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhd4XsxXEy6ksThNEfcSkzDqhJZYzRWpiV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhddKWKaLTpFwhPzdNE1PXBEfvD3t2KTw9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhdgxEwMpfvQtuHgkAuLjCWbMeFtevDHTo", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhdoF3Kt3dV5DkuPgTmvH3RNzgZrSK9o6W", "assetId": 0, "balance": "0.00000669" }, + { "address": "QheP4ZKWsYzu14fewMdUayA4rkXpuH8a4p", "assetId": 0, "balance": "0.00000988" }, + { "address": "QheYCn6knCWwxvEj2fnh5jw9osH8LCWnN8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhepyJTxZrHKoBDk4GuDVnDZfAiq8LoVgw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QheyVo3JEdQMm5Ae1pw4Z8JviGJCJFpRQY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhfacccxfRsup22pd4wbhau3FNhaEMWmux", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qhg5gdSQov4GoVWjvERHtWhWVNhenqSwoh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhgQww9Ruzvzp8dyRWvt71viyy8JZYZKnc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhgYPBQvzqXh3teJzMFTMM11aP7B9kVHD8", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhgju6yMXTKPMQLZf9PHyCXNfYeTyCXgU3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhgyCjRHYMCB616GpYJodQ81DizfZ7sp4Y", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhhCRNAMWXGphxZB9NCadExSTej8VKZ7Ba", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhhMhgBSHYLvemqqwHi7HbEiwUJyF4xGox", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhhTnqegSXgSut6g1JNc76FYxkra1foM7Z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhiEZfzmYThFSDEha4bWv4KVWGCPPTPm8a", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhiiMuaL8q8euYVbT9jXHMxkziQwUvrxfv", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhj1f36jqyVUf1ifWa7qr6CBa1StrbquQZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhjHxXJap8qj5YAuSXhi2s6Qe7Rtw9aeNy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhjJz3Fxnz6SYtd9Y9zBZKbsvhsKFVzL3e", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhjNpx7dxPZXknfXRk5TBa2vmgtJR3twBc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhjYnYoQEFwNaDoA9NG69VT1Qxfwu1n5C3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhjtbHMcd6S2sq88BfY9CuaY1ZuyTHsza4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhkPHuFvRPVENeocNgkfYQYkL7YYBCVZLR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhkyUQcRTxSDzTzUGJoPGnLC9N1ZiDcwnt", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhmNEhgKv1ozA7Qv9pywcbFJTpw8EWwtnf", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhmn3HFFwz6DDek7mnhvteQxFD9qwqyyzx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qhmy2dgcGjyDBBLLLVdzyTZ7VaYMDukPXr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhnQNjuyL5CLfBRT42jR1s4wM3eNLPqcz6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhnmS3cLur6hqyT5HVQNetV3pwPaSs3u2c", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhoHUoy6treZZBED45DD2qhsFUB91ZevfW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhoNpjYgwVWq2d3qR2UzyqHtr6Jrb7xwGS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhoxNdKwj6PjUQL67YTQHYYW2TWaeekf76", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhp5fzYgiWwbR9HY4LBbPwbK6jBGCnXGCt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhpSGZpz1spiPenVtWP6mQzVtNrQdcHveA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhpVR1VyWvovQv1HLBrmWQ785RFFawt7yP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhpWMq64SHAEAuTDfLBitQWZmB3Pn8PTAG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhpgDJ1iNzhm9ZfQS9rS5Z2avyb4MPLDs8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qhpk9VA2AeKmCFPsftmGzcLSGwYKpbk2FW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhppWdaBPyMkqJcufFwyEHpCwryS9FVZEt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhqYLLdiHMdZNAr6V1cUU6wE1nEua296FV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhqoEihESYJnVDTBWpEPsXub6c7eCJgAma", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhrdEU3qANnsWWZ9wXvxyypx8qR45YJeBx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhrjQ98dqLorf5uFNPXgxmTKmKxBKBqbZe", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhrx7qVaw76dvdEQnCgif6GdQYD7a15449", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhry9f4h4nYr7znH7ESP7nwjQp4n6JhoFm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhsFm5iqWAVoe758WCdwtEzgbEXbhYv5BM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhsactZ9HZTkUSff3fWpRNxSZjueunYiF1", "assetId": 0, "balance": "0.00000655" }, + { "address": "QhsfNAyamYq3hZAk9hK2F3LuN4GrMLxQet", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhsfjGcfoi9uRPCnH2ftAT8q2w8T3ofyVD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhsyA1h3AzBomPbY1cGmWL3tqUoud8MLHe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhsyAhrMizZgtPreoxcyXSWYYSYvqcbBU1", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhtAVsXgWi7WeqM3BWVeuoDkYkYoN5uuzN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhtNC9EVUdh6a7aw9zDUCYfzc6VyJp3EMf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhtcJS6GbqL2GsRLub3NKZp3bAL4MSDkNA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhtrfYApSAp34r6MieZL3SMpF9rFZ9TDtq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhtwkTFJgcCsinDpQ3wC5eZZ5v474cVAPG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhtzSkhwaKvyZ2eXi6CtKMed2ne31J6xut", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhuBhMgr2dxX8JGK8ghBB1SKXGiBtGjCer", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhuamzFntfAzKZvYNeTiDqERTVb9uVB64f", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhubF1VnZZjXwLuVQRLHZMzCWnxhke1AYd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhuhTDiSvQGNDtJ3idGjnH123dCd5J5BDh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qhuos9t2XkBCmiFiroQFwQ7CaULAZ9YBnj", "assetId": 0, "balance": "0.00000988" }, + { "address": "QhvDpMWRhVhvjoBW5a5FgE3oNmARH4RYrr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhvP6wSfpaRqTuazRTVgMcDfn82u9X9QLq", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhvb2Qsgf5RT6ejtjtyQjigrUFxpV2Sn2a", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qhvv54AWoBouFtvdc8TEbEEpxshgAp97tW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhvxFceK1XSxQSFqZTnbWvxKNu5zds5gYQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhvxGvvGs6yxv7mY1QyF5DrZfXwbZbTd1b", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhvygYEj8YJNNQYpLGxP5yMPhJCri9gq1E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhwBmUG2eSq3fPoaLJS9y6m4dgDuccXns3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhwLxeCXTU19fbxW3yCLJJZPLBGPg4mY1G", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhwVX6nTbs3V6CBziafZLTjrMtfAfKFqT5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhwZ6thwxwaucJfbNxB2LoA17GZqfaA1D7", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QhwZEHEAM2NVJZveabfUitmH79TB3yXWZU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhwaXxkLcNreHM1ZDLmRCiaPmkwzLrgLZJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qhwv7cYQ8dshToox7LRS33aPNGTedQaDgL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qhx2T5g6UcTr1GvXXVeqc7TuDQk3KZ3peq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhxCi7ngWjmscnDPdXzhu5HRdEFFUmMu3M", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qhyo2dqWVyNZ84EQMfvS4JF3F4nJddg8ie", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhyvdweC7WHu9mqR86B5h7h1pG8apogZBv", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qhz5BneYavU2C7xub6pqMGFLvth4ZR6KYL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qhz7VZ3FiaVEgbTgYrBYJBuHSGFLPSvSM6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhzjAMYYRhFYXUHv6NkxMmM6vptr2Jo3F3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QhznjNrigFpbDeFShrRZDZkvfF4NRub5Kb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhzoR8TW3Dj4ugLatx9kQ66P8hToyPxBdv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QhzyudB9g5TieSbCi2SBtm9sS8ia2hq4oe", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qi1Qm8d3xJe8Wpf6zQqcfZwKkpuEfeMskz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qi1W3hiPZWH6wfGt2imicU7upZcqHy7RBv", "assetId": 0, "balance": "0.00000652" }, + { "address": "Qi2Cw4zZFvHLQnZVhwjM1ygqbn6nDEB4ZN", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qi2kNEty9XejfSGyqbsf5vBowAWHx1zq1Z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qi3JyqyY8YcHPjmqofjV8CD5CsMDhJ9DZx", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi3N6fNRrs15EHmkxYyWHyh4z3Dp2rVU2i", "assetId": 0, "balance": "-0.00000056" }, + { "address": "Qi44uarDMtbDMBxBtG8ntuw9ctcokvnFPr", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi46onjPdTL5XXHRKRWpvcSEaJ57Kyd4Zk", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi48F7Z5MuL1zBUrFUDx4sh15H46znHYkq", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi4bRVwGsgqnzyBGV3hdKiDd3BUxDZcsgc", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi4dAAfhtJq5jKixtX5Ju5xSBLesv5r1S4", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi5qoSDYt2MdWURbf3CJW1zRvjdxXaKNuG", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qi69TFnEuL6ezhaFuPcra1uvXgEw4hWZ8p", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi6VoEE3Cf1gbyvzgvAvpG7KiLffNWzngr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qi6mWVPvejsV1dj7ZFmm6PLhEKLfAXjsN3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qi73xBLZg3PMJELtLd9GebkDCW7Kjk1juc", "assetId": 0, "balance": "0.00000669" }, + { "address": "Qi7Rz55Km9yVzzPPxusgBtZkrR6tYApX7U", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi7v34ytuEgvUdQRmMbcELmwP8DshdsxPd", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi8EEW1qUuG63yRShzKBh1Wb7r88UeCNZZ", "assetId": 0, "balance": "-0.00001457" }, + { "address": "Qi8JbhH7VPrgTECoCqx8YxTkGyv5RhpN5s", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi8UjXxSDTXKfAHEoJH58s1Gkvk3sGma97", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qi8fA34sUdfz6joATzqq1NHmxGJECkmj73", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qi8j8NLi2wBg7JUAb9qwctXwbyLbmbN6pp", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qi94m2u3WEBjZDn74NiN2Q7BBtJYxjQj9h", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi9KRTy5DR2WZ5XNurAQrkBtgiKpEEtR9e", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qi9rR7VQZKW1jfRUCEeaVJ3oCjqYZvSXdR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qi9sLUbszQQs15dGgKACu6eCEe6KjKggs3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiA5SLN3NASu4EStTmCAzvR4ih1Z1TwhwC", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QiAHv4qP4nUSJyhv5N5Ax7NgP6mBBGCjaB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiAPXUMmYA1MYRZURYJWu1jjL7eM4FL1zZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiAfPFLrvkjEveqBdKNKEiyAjQL9RHYgdC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiAqgu9PqHMmzsFtrswcF93UR5GJHivdau", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiAx5G1KaHkdA9mfPhZFfauhCxJBGV7TNN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiBBvdJGcu9izx8faNh3QxgbYL11eMpJQC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiBYApdEYRwsFYjt59UJqZV55wcwykvhsh", "assetId": 0, "balance": "0.00000003" }, + { "address": "QiBn4h1M2WRwpXxqYfRmxL9UNH5Woed3Jh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiBr92yzGnq2Z8YU1p8YhhXUvGmZTREXcH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiC48DT5qZ86CAK5RJDpvoWXfJLoDRk6qV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiCDGzeFfQB58zVLtTCh3bigG11Et8znCp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiCERp61n87MULXFSmwxrXKsTcdcasL7QD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiCLwga9QXiEPbFcikzeyd6xW3yaWqRqPS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiCSUXJDyDPv55Rvkc9qN1jyKsP3dQE6t2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiCxqvnJvp7wv4SsqvTBHnCET9T7bidjGk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiDMH3fetydhM5wCrWPQcn8GuktE3L6HF7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiDMZVUGfbNat7mBnjS9uQuvjcgzsDTcwn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiDPLeeF4CV1kCgwUj8ae92mao3ee9tdT6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiDYDmedAMFfXtVkkuQvwTg5LJmdgMjxLd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiDavkS9dgna8AR8HeQ2RJUNPVFvPaSzju", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiEFNPWZmxnCGSgSn19sQs3s8H1zgnCuLC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiELbWHFKqEx19S9p1NAUp9EYUeMsvgCL4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiERDpXv985tgbL39GsKrrkbrfmKBj6bpN", "assetId": 0, "balance": "0.00000988" }, + { "address": "QiERL4zeySk6VWp4oecTkZLAagpYi9UFWt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiESYk1rgQ8an1KBraxxYJnrH4ppjFDZJe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiEfNraZx23DNC2MqgaaGPQ9N6fcDJE8w8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiEmjfQ2KAaG4LkurSg6dLWWHXDgBkhfS4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiErcXGMb6wCGapgKttPQUn16wYxr54j6E", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiEyiCxmkeS4scqcLTZewuHNrntzZNXaeg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiF5wN2S6Q9BEWX45HfC15GBGWNdRkb3bS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiFPxXLj7owehgee2iS4nxyRjW23jYhqVR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiFQseDmnLeGV3S1qDNPTxCmWat97YKMUv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiFRMwynVTVbbMtwmbo5wFx25mf6Ee5Zzf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiFTvexMvJVmAar5vaX7Ahd9S3VpWFutRy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiFUUj4GvfHTTuAhFseuoWZm3wYemqxSDn", "assetId": 0, "balance": "0.00000988" }, + { "address": "QiFheJyjLgyvypKd15ncMH9DSuxNkJjq27", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiFs4a5VxjukMwEnFQAFHVf25AAXary7dB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiG7gYSoW5FKJqEE41pbiCq5rpDbfkX8bM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiG9MXyRsHXEsEWfGWkeViYTdrd9pjp1MU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiGCfdkSpDGiZTqPghd7DjwAoy3oedDYge", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiGN3Kce81GdoiWkztj58hypZ1qBUiMPnZ", "assetId": 0, "balance": "0.00000988" }, + { "address": "QiGYUKCcLM8XcmqrPr8Tb4y32tvRwqAMWL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiGkwhUZJRsg1AzcQofw78KVmbGeoobTyf", "assetId": 0, "balance": "0.00000988" }, + { "address": "QiGncSvGDY3JbAkhQkMmKghiy8MGDhq9pq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiGumRw6CnrTkWKkAp7pXYSBvtNsDPaoGH", "assetId": 0, "balance": "0.00000988" }, + { "address": "QiHWfwSJhmGJ5u77Q9STDAxgNzCN5Dvnj3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiHas58k7bCNz5Yfnr7FoBsgSMRwBuGeUt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiHc8WNhPuXwQ9uFCtFCZ782pTcAMF32g8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiHibtBZH3Zt8dDHTVth9vtkTu4hySpcxk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiJEkjd64iHqkouU2QtY18dGZP5xhDMMbf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiK6bxEm5aTb9sNBwtTVngyk4K49gDLhhe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiKJucR8XZwqnzzfpLEtdks6kk3M86a6VF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiLWhb5XFyfimAY6WFoeaJMCj2kei2bEFJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiLXNVG9KgwPkZrGkBnJZfA1pEMGAtDfx6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiLjX5hpDym7BMX3cWKTC72MSDHP23uviq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiLsnxus6hs69oJwLK4To1rzfkwXDT44dx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiLuBaRYFWP6QoH3odXSXUKk9jVjT5dXh2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiM9REuk8bC6W4sxgX959Q5PVW6rpRKjrr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiMgmaRNVDhPwaieBcg6DPy1qhy2pxzcbG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiMkrsFufkkQZAiRaUBRCZQWB9TgrGsqXL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiMmpBoFF6m1LFVwGAqxY5qo2kaUoCxuuq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiMsCTygUpG2ZdQ2KWg4BPthgqsGGJiL3F", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiMuRH1asGMrtAr2Y25ZjASbTHdMdZSYDV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiMvZ2FW1w8W5Fet68fxkLD1u1XC5uEWhJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiNExFwHXu96NvVuNa4HHrJiPNTx6RkjQM", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiNFHX3vKkSW4mAtwUPSR4oWsQD2HXBy2m", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiNJaSdYGeXhcrKTJKPySud97s1ejxpqEB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiNM4PK8h1vYS8jzG1MZfz2tQLBk86gxDq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiNYYUgJSuWcHFBefogGC23FziGFLCdhmx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiNpnkRzPcGpsiZRLCS7nLBhAraStah1yA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiNq1kcZp17YBTjAoPEyz7eXws1SNZvDrQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiP1yBKgYgreoM4qa1eL3FfHorUQZVtxa3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiPSSe4sZsykCKJXvhFa5gA2rGnutitRPN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiPyUzCTUx8d7tQ3VjR26YRs5cFAGkbvQy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiQC1oUFvbfjAfg9DbvUBE3tqjBHpJSjas", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiQUssukhoo1ft4G9Mxa8JpViqFW4PdBjJ", "assetId": 0, "balance": "0.00000002" }, + { "address": "QiQvecgHwJRhBqVtXbNnUvegHnA4TV1e9n", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiRDWHPRQcp6jQrtjqNYRDtkvGnsjryXF5", "assetId": 0, "balance": "0.00000988" }, + { "address": "QiRvPy46jPU8ub1WeCXDn5KThQkD8vqPwz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiSKTePoKGeo4WhxtrMgE1h2qte9F1bb4p", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiSRUnc4FbFX4Mb4b8i2Aa9ebXb6r3qhNr", "assetId": 0, "balance": "0.00000669" }, + { "address": "QiSquYMw4M7Pd5yzTQ1b7RuhjNFBNZocxS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiSx5zS63AVdfo3PgCW1mNsnRuaDagdiy3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiTAw3uZyqs8a3vsB97KGMkqF5y7yzLcLW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiTiXP97gxxP1zVrrEK4GLk7rKNYDahLAr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiTyMzYBYcwqaUrvQo1U4xeGSGLBRp24Pg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiTygNmENd8bjyiaK1WwgeX9EF1H8Kapfq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiU4XpwezyctZPtYPQA7tx259GGZWPHmRc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiUYSQayji6rYBvab9ZLXAL9vLF6CMqoco", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiUZXnabLyNvERFo9qFq9VYQhmQLifqbPi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiUqwnKKcU7hXSa1gNQRa3o7U6c2Gr5ZYh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiUrJtZKwQwap4atNduZB8T9upRcyEYafy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiUyeobVYSCr4QdtgukWkFLnoJ7MjuQMzu", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiVDZ6cZ5YuwgCHgXz7mWuJ1Y5bZBVyd7s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiVuTY2u8T5pMjgriWB5oQxbbPy4BAJr3X", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiW39k9iCrFmVHHEpgVtRWnpRk3hFp6wVu", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiWSXy4LwdHqHHHiipWuTsK4eoYFBSPA6C", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiWXMoNbdn4x84BaDaRrcwDoqsoemwBmzB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiWar5rMJ7hPZugXMTQJ1mNyCCxfJZHL6z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiWcSGoRwaRrNNVLXutAR9cNEPENr2kfDd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiWyFeZ6YQsLsZt8s6PU28iBTWE1dYDezB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiXqqQA6Dza6gw7W9qpiBeszGYxVsiou7v", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiXrJ7DdvfemN2soFyVt9KYGUAmZzjeNbh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiXrcs8pHLyoSZrVwewVpiJwuuZ6VPRQMJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiY7T1RXwudmPst24hKpturqgBzaKV7wCa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiY8e8A1EtTMiQqiMvZRGSV5LoWZ3uuDze", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiYUz6mDzDfbGLZQQSBHvu55j9BXXzeyLY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiYcnVLzTK8cXqYhkNTe4BdD4YQhhe9JbE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiYmPbVtfZQfAjdJcjAWunjRmJdLxDhAog", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiYuVgjzYfEwobgrTFVsSa2R3ut4tL2rm1", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QiZzfq2fZ5CH5aKTJqqpVwn3B52uaK1DZH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiaTESwieH3y9wHjfD1EqKWK3sNQkq6gnn", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qiazm75nG2zX66L4gCgAicsy1AXQRdjyV9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qib6gvm18cTJ1Ssdmhyv5Cain3bSRZytjA", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qib9Zo7THbRuTSNX4ADR8ykzTJhZCqF8KD", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QibJtoC3VJFJxTVoP2jp76MHeKYpXuMnUt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QibRwaEbQGSeVngKini7yDR71vFRn99tv1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QibSfidiWW9dsSU9kxVQkUg5zQGgbtp3R3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QibuD4c6gvXgS4iut7q3sXuVb23rgFJq2M", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qic2jxwZSk6GWFj9X7GEou2pmtqavrSugN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QicA4vz9aiWPQjBq8uCjCCTe2LpsoGB5p6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QicDWTQLj8ywHWXcEQdF8WNqw6n4vHkknZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QicRwDhfk8M2CGNvpMEmYzQEjESvF7WrFY", "assetId": 0, "balance": "-0.00000063" }, + { "address": "QictCzMEXWgg8zYdrzEW96V6PFaeEdk1Av", "assetId": 0, "balance": "0.00000015" }, + { "address": "QicvnuMr6cYbA4XqM2FiMvxBLueqxzqPNf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QidaWsB1ZaRnkWZyuX8B68bugqiSanfPZg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiddupC28nJuSbtPQurMaWNBCfGRgVkzEP", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qidexdj6BTpBrnaSy1xdKiB66Wdm6ne1JX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qidt1Cxd4H8P7FhTvvpLZEcLNdM6cnZCsc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qie4HicRyhz6d2ZqS7GRFfNzo6C4kLqLyr", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qie9k23BpoNcFRvahkHHBut9qSY97EQ5jC", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qieb2wyRweFfGG8EuiKSW9U84yw1w3kVSR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiebfSm9aafuoGN5FPqBTxquQcmFdtY2Qw", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qieh7PXifQaLr5azdypquuAEBu19GfbMuX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qieq7tMT9hQWoHaTkdekspah8WbRh9CYLJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiesK6SHN6Q9WSjZSQoxJoWjUBdjsBz435", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qiex82qXCVKgCvLbccJdjrAdjkUCsuZASk", "assetId": 0, "balance": "0.00000015" }, + { "address": "QifKVjUmVBAVa27up2Bis93CbGxhwjGRJJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QifU3HUBKQsAmLycLAAyCv74DMiAny7oeQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qig4BkfBkGFaC3jQvMaZz2MYpteUdkboa4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QigCYkMgA8a6ShovfDcPB1GZsfu5jfPCvq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QigWJ4FzbCHq5DjswYPkizpKbqLcPjEhXy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QighDAbgW9roCAujUi3DzcGEW6w5daTU78", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QigoGMawrqs2pofiPvk7C5uPHSTB3bFfgy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QihTTq2NDX2DsJEygJLV3ngDjHi9PNxmXJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QihqrZ2YuBKXrQJnYjLurdae9wK5fBLMUK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QihzNPXWQC5HqjmzqT91GzhNpVXmveGJq6", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qii8K2TLpxMYa9EfFzBxGVhBvUXE88sgCh", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiifNwQzLpSptckr5w85QgES1MTwTjxmMn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiixMGkYbgYzV4XVW4wKmVpDpbFPxkktxQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qij3Rdkaa3QfN5KjcNQCWBXXxYwQy4kPKk", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qij3sUzrzGekAy1VfcK2m2hdB2E1LHJa4D", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qijxau9xHywxd8MA915s7nQUhaTiZCVBtT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qikh3mGSNUcjEx2aZA5C2Z32GXaPLaQ2gg", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QikyzYHQiz76dYwgZVeyUvA6DNzTygQRZN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qim8NXLFT5SWD1KPh92BF4m5yndXvtu3PX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qim9oLR32AEJhido5fd9x5aofABTgi7x7d", "assetId": 0, "balance": "0.00000015" }, + { "address": "QimEKPiUBA8F11bioMCui3CK78ucb52uAt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QimwMddFyKShpqMcESqtrRDWjmrheuvXtW", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qin3j5cXgkpUUi6ANqUn7cvETgbQUQ8hMc", "assetId": 0, "balance": "0.00000015" }, + { "address": "QinJpbvQ8Z5xB81ZsLyY28XvwkMSmu42nJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QinNf8huGPNidjCAivYtgNN7Wc4KnzbqQX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QinSLv95Hc3oarr8G8qN5g1hMxFHymSMVz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QinhEE364g8DLmTur2p3NJWrGVRHpVQrp5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QinhvU9afjVVhg2EZmfnFFxH9Zi4DnBewg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QinnfWkM4hAbVQvAiQVKg3r3K6Pq7JE9rA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qio11LZyz94tsNYzq225YdrHGZd5u1spUE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiobLVKkCX2saJ8j1DXuvC6afUzMtMfjUQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiodTqHiPik6hLPXhg3qLnjkFvtKq34qHe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiomUFWMhovnQ74uZBKZXUXKQFJa7n7gF4", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qip3PU4ceT6fGApTyzFNWJzNcpuXyDig2X", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qiq2cxSktN4USyLPdgtkkaTXDRCQGJb9k4", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiqFzhDt3GNXG59HpwTbUpSUd6Cgw2bCqF", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiqGBr9B5JuiKU8LwzemYRKSzSYaQhzx8a", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiqkAY7wR2cPcvsvpeFwx7zckizGaSnMoM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qir7wDiKnKTvzwLYNgHcnX4T4qYVyT3TmA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QirKrkmyBHLvWLkWkZcqUgKFNwHhe8aVJf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QirUzAm5ZzikfKQEmi1kBgsFyySiAAFukZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qirg3LTcvCs8cw2Jf9EiZW2Z1H8smWBNQF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QirjLiuvGjWMDiUoBPCjUmo7trEAYErJup", "assetId": 0, "balance": "0.00000015" }, + { "address": "QirmkuPi6qzb3KhvQabGPz4zugw8zTwAkT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qis1kSR77JUx957Lw4oV1kZsHAtZ6bL52W", "assetId": 0, "balance": "0.00000988" }, + { "address": "Qis3LiCn7u6em37hxUN64B7agTdoKzaQg6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QisAaDF2ECotuoadGv8Db2SWQLL2LQXgne", "assetId": 0, "balance": "0.00000015" }, + { "address": "QisSQZ7Et7Rfzx2SCC2o9UDSeRZWMyFKWc", "assetId": 0, "balance": "0.00000988" }, + { "address": "QiskeHug546KSpqiiRebE6LvsUoe41xdZa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QistCyppQ4jGviH7rNUmAdXT23LZeWefwE", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qit8cfptq7zJXj9xiZRGoT8Lz36TeLjcsS", "assetId": 0, "balance": "-0.00000959" }, + { "address": "QitPXtRSR7xcRYVAknXZ4xKhezAd9N2w3e", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QitmkWuzirRyMWJgJwgxsqv2FLh18c1R9p", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qity9GkafYn4caCS836sUhMyRbPr7HVypA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QityjA6Zhzs4AH17FqV3HKkmgfpFtuodL6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiuFgdXsGHf2cD2kpQXvhPGR1w3jRSumTd", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiuPeBiDrxyLM9zEUfxsF2pXQWo7bL4m1D", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiuU9necLmXGUsjMNhsFeDWECRUrfkuaSh", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qiukd7iKaWYTZdeJeeSWDqajuu8uiV1uL7", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiuwYKkyVGruMCEjpa6Ae239PjaGPp4sjc", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qiv6QcfY1TwStm88LNrAG9pgY3qDEAEKTx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QivEPqt4vszVpSBWHeXATPDPxGRufooPTe", "assetId": 0, "balance": "0.00000015" }, + { "address": "QivJukpqXbciiihTbcsmZ9jfbDMjrmFHaH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QivitzPnfMULSVXZ2aJH5pQLsVq5Qcm8JJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QivoaSkh7nMpmcjGmwkMda3ysvPRvuQejG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QivpHEfxPUokg7LU8d1sA6eEgoCJGEet4s", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiwA3SnSWUmxsqEqJbpY4eHQ9QuNZmXNT6", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiwAU6mQoRtkCu6CbU2K5NU2TGLjRMKoYF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QixNiWoFJS2CVpm54ZGvxXJLFyEa6rZqqh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QixQUMFQhRyekVzxMTjxkNZLPoRSMA1tfN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QixT1G8WFNp2jRfCPnZzgyktEeijcbR4ed", "assetId": 0, "balance": "0.00000015" }, + { "address": "QixYPtzMLgf77Znd7FEGLdjuF7DmsvRky5", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qiy3NTJ9o7Y74Wa8UBUAMSYufeGq4vQKuP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiyLsqnZPb4LZAmb9BpNKztsWFvSkjRmKT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiyPfS2zCLtdQUeoAa8ssmsrQBaiw63Jt3", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QiyQFPojBaMkQ22hBzEwBrgkenQdxvHNdT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qiyvf43DDLmD7zUDvugC4cbtaswBQSDPKG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QiyzZQBTBB6bjQWTLvEoRTwswBR5s9tEbv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QizNRX5aVm7tXeSgDGFXPadQsqz7yWiSjX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QizQSrSk2VP3auqN9575n5PwT2u1Bctbu5", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QizaGXrRxSGhtVEeQWnxFP4LV1MLw3wriu", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qizty5eaxYYZ8mFpbL9TzC2j7dnXvqW2Gc", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QizyfcVvP3bTmZiyNK4xLy9Pifg4ipF9QG", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj1F8hmR98tYpDtd3PoUTo7XiSTXFT2KXg", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj1MQKe69W1mQZxBozyAbAjijrrHULR3qm", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj1N8kZCXVY8kzuWTnMQYmmuW2asA8nipZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj1cPcjpYZNoh8JhNUYrmqhaXqspajwwWG", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj1ggBuUrv21nrQDE28UYp6ChhNhcAHTua", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj1mXGAj3cxmAVo7q4wMPNMZMhW1uX69fW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj1rzptxdPJZgTAk3wQfyz4ymBbS5uRDaW", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj2Ammp1BWY8SxkRPfrvtFbh1XMKZ4aFz6", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj2EJwaa2FcB3nY8bYM4gjncGNtd7WrgZy", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj2LkG1Ax8tGgwCt152NnGuomuKf77LycF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj2USguA2xGYbUFHwU9jzJwmyGCiTaQEcS", "assetId": 0, "balance": "0.00000652" }, + { "address": "Qj2bWoxTdCkMsEiKefdSDL8VRwkEjb2VVs", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj2rzCafnmocq8uMRyjmvXQ1sTmqpPrbWy", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj2vrZe1qpZhdu2jCicQd5GYfTMWJk6mPc", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj2xV29n6ZVHCdxNHmjroqPojswceD5i15", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj3C2jtWg6nDzBxhApzyn2wwgE8DRJjUFA", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj3Vsr5ziczvZzuHitWgUxJiYSq9VdXFeL", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj3c4yLZTXy1Z27bW7S9RDwdkjzziVXxkX", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj4MnrSbxP9wCxx87EToGMUNiZxmsQiyEE", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj4i5gysSspCtj76aRzNZewwGhztmfC2JK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj4jE2CZHg2kR4Fewn2Err1iGA2zb5miR6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj4kadRYmzWz7PAC4mJXVQegq1B7ZN6ATx", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj5CiPjLegxkwfwMvRr3B4JBqZQrhDW4GB", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj5K8sc9BoqwS1ytZJZ5WZ4frL9qjLuzxv", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj5MBqXrLrGT85Ug3QCfBziNAPz2LV97Za", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj5TVcRCc7B72RisrYVkz2GAYrUQsmT47L", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj5s7uamXU34LvWgnU8pYcMGk2wBDUEdLK", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj5sjQ1CGbegvZ21pY5vWsPo41vJ69J45M", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj68zUoBYhXyb7Kn9ZJPbiEZ8H57HG3usG", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj6FUF6wBtVsPEyMyTA3iWHZRyXVoK79zH", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj6Gqz4ykwfXDMvHjDzmSwNnrD1nFv4eNd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj6NdK4qoLrsHkWoDNhasSkrLLsudFMDWp", "assetId": 0, "balance": "-0.00002752" }, + { "address": "Qj6SZkN2bHce7WWqpZmVn2A6hZCAGXiW3T", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj6Vp4ZjmeShvs8oHx64kwWX6zeG9sFFf1", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj6WxpAG9MKka7JWNmg5SeubRt2Hwch3ry", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj6iNevpDwpCC7mYWVUXgExfc4S1Ksxzi7", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj6nk7RCJcB6gB5SZYGnKrqk6umyVD2XWT", "assetId": 0, "balance": "0.00000004" }, + { "address": "Qj7582TJdSCyQuZj6BtUMfn3UP6TGoSiSf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj7NqhNmFVsrppgby9Gotsyo2rmXzuUW5H", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj7kyirzi8dKrjp315FmvNFskLPEzEqtaz", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj8UjJMffdDwUXFjTvv3fZZc9MdPeQY5ZV", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj91WYsRYTwMYjqUUB7twCpRP1hPtCyGnB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj96zeoho9ZPaGwPdfTiRSs58AMQzpsELo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj9UJ6s1qhxBnnq97pTyxggxowhy73uNwE", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj9YfjykKthK3SwurSiEBLLGY8uHvgkMJT", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qj9voUrsKGdcjdnAegUfoDd52vXWdHydY7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qj9yZTPWnyPpbVKjJNyNoxRPmn4ifPvVLN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjAHHScxBNWSGsLZN6iSq8u58DhBPHK5KD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjBDmCpwdxpDxciVqN24sSF7BsCQJmfaFL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjBJfCWzNvzB1om9NdxYGDv43gYWXCsvKW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjBMFFPhQryK31Uk7jzhLaC4grbq4Lv3XM", "assetId": 0, "balance": "0.00001003" }, + { "address": "QjBPnWK77YWaqsqmirKJ9584y1LTqFs4NA", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjBReRJDhD2tkd3PANxp9su6NwpEafqJ61", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjBYcppsSWw5CMvZQNq4HjDhky3gAGmuFT", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjBfQahmgB8AeQWoEHNcmqdTNVEXFz5KBe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjBoy9HrGtB131ErYjcjMUPv6k7PAh4nJ2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjBsNo9mjwBD2EvkQUoPuy6oz7feh8ip6n", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjBxeCK7GCRrnhJRpqBiRjrqd9Qgs3vV43", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjC3A4ig2MaRzznVih2dM5PRkq162JkYsa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjCHXke1wrAWLEyi6PwYNMZ69JiwVBgXKK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjCNMuU2dmApb594XSXoBPMNd1dMH1fT8i", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjCQv9zBV56ow2yjHDfbijrMg7mb6fpgSV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjCgFQj4EYfrHUEn1VRivq3D6cnuTmSJDQ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjCpHhL86og5yHzQTM69LKQzwRGVHF3zUi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjDQSGFu7vurkw7KJu2KNxyKBYaWEWB84V", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjDXb9EyJrEXtpT5pNFuHGKLKYr3bACkx8", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjEAs2or122weKppv5zALzoQzXxbsDjy3f", "assetId": 0, "balance": "0.00000988" }, + { "address": "QjEGEWkEeMtT8eDUVUG9PFjzqujem8ZXFY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjEVVUkUid3qh3orWBzdQKJmc6sbr9xs7E", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjEaMxcBKMsj91ytKe6GdTBJP8Mu1Ru3r4", "assetId": 0, "balance": "0.00000988" }, + { "address": "QjEiXJDhmK9wqpjhejViMdiDZ6Rrr67uJm", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjEjLoYMESVBcZ5d7FqJejjbfBhoroQAvd", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjEsMpi2kJ4BUAyFn5ty924QYTFQrNTE3i", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjFP7BMXqQHWSUMjUs21f8AGE8ybwm7cth", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjFVcLqAHk6UM9nGKRHeH6nvWgjCq1fQUg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjFbHpaU7vV3Luz4TksAncpj6t7S9TZJG3", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjFzvYMDDFSWNJ3rzo2d2UpaiTutY45fUp", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjGGcrbxmnvMasSS8FXh7NfwA1doZkxYUZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjGND6AG7jJCjMmMipQ52nB64D4witni1j", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjGSgYSBxhvtLXUUt4ECLLSNFAXABdBR2W", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjGcWfphGkpg6iZvPvmpwVedXejoTc8B3N", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjGkaqU71wUaAQveyLcHuPQyjdeVi38Noo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjHFYUpV9qhJTkwnnzejKYJskc2QUTnYUD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjHKPbuXxvtYZrDhEijyHKWnGCA29RUTNG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjHQakTZuwkLYVckHmoGsesfhxW8BWz37o", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjHbVhQvhVuEd8C5KSXbPVQarRrCo9zZjZ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjHbaAzkp9ocZJU9gJcS4pLoFxYYq2iKq9", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjHqSVKL7LH6CZkMimf3Jgr3jU1CfiR76z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjHr6PcaYAsoDKEWhZekvkXgHUPLLrPqfR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjHxzbsG6neZLdNuK7gdXN8HHuJ43FMMkp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjJkaT54Nt152h11waMYSs2j1HnTrxnFDC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjJpiz5C43MfMWzkBxzSG9h3h6rFxFb6jS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjJrU8zGkDcgmevnzM9ezZRrnLoDMa9gAW", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjK28t4PYtNQiso5mwhrLxo4Caed66PHBJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjK9xw2WtWtkGBirUaTeVFBg1PA6iothtN", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjKRbeTYYi53Pu4Ph3t8seavsoay3N8zpi", "assetId": 0, "balance": "-0.00000704" }, + { "address": "QjKZsWgpKzyRhuN4xNreHqvBQ89aF1iFxQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjKka1zAhCexc3ktHHcSTKqV3xbFab4akF", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjKw4QxDGoo3AYmsMZLQrVDSQLkYFcGiW2", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjKzZNNuBYWvZkGP1YtTnbPXY12DPmhWcP", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QjL43jCvEinuSNhBxHcmUjnHpfjQ9eTvDn", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjL8NK19hXnzYrKxb2PvQiTixjKeKRwDaa", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjLKWrBQ6aFmPHFabacoDGwwQg94a9TJk9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjLMLprtMqwfKEN3XoocfDRVSWkbKQwm7d", "assetId": 0, "balance": "0.00000002" }, + { "address": "QjLTbGimc5dQXJ1f7QgjEGkdQNXscZ5yAS", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjM18Pvv2UdQqYpChDKZ86421FzhML7Y4e", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjMC79UpqU1okGxWxHRkqmQVYY6RvzhM69", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjMPfZBnQ3s7efc7whmQiCfTXUfNpMns8D", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjMWr9osCo2eJVZyzRn5zNURn6azCC4Agx", "assetId": 0, "balance": "0.00000988" }, + { "address": "QjMeH2zKidECu7Q9M1jLQzfdiUutfPTENp", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjMnRJMiRASFixPn7VeVYWCRHtfbMziVvg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjN4z9cQbNzvjQy13xuNZzAi7ALL1ueYGz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjN6s2Z7tKyzAoqKGFk9PfKHnZn87hmEXb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjN9gt4oHZ2Mvi2SBksyZ3CZtDGuB9Eq5m", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjNEgw2ZYYUgVEMKbL9DJvhxEFnKrzDihf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjNTfw8W6wU76ratN6Rc6SDqD9wrwi8fBU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjNV1fj9WYZhL4iE63HT7AA3kfkNhoF1QN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjNkBpzzZD24dXi7PRBEKTHVA2rNU2TFLy", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjP9jgrPK1P7dLSpSi3omosbWez8ujg5cx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjPF3s6mju3eE19bDCKPdZtCDH7G7wU9sS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjPM7SngNKgQL5XG71EUVrn7MNmJjikE1E", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjPMtBkUZRcFkWd38tFhFJACqaXKX2QFah", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjPfZjQBadYMhQZa6KCa14vktFofwSuhLh", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjPnK6FwAkqAYEraSqWZbSAFhjBoPXTePK", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjQ3FdDzog25xApjzXnt2DfbbDKJGvS1V5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjQNYwpZCRz26TETpLydVXkWUR8DUBAE2z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjQuKRXDBsndVZcERbkkABzGnPN6xUjkAw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjR5ebMhRWa1ZSgDd2zcTCJQmRzZwsGXMa", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjRYu4Pd6KqEfH9wuE9tGqwMViWH2feaEP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjRfExsGmMA6AchrvfFJ5MsgMXxvPdJ8dt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjS5aFJ8oFzwnC1vjt6Sb7jaTeVGUpr5eQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjSCbVgkA1FJ7CKURnsgrVxJSdJ6iRkcjC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjSDaiNZuzvg27s8rVQFse1NircWrEmWaX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjSGVTriogiS3oHNmygRfvV9yLkFERoxjj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjSdPmxReQDPm42jXxr1zDbXrDNyksr62g", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjSuKefuHpBZe3sfceU7idenaBU2GAhFnW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjSzza8gQQZJD1mYD1tW824YbZLm9oCWyb", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjTPCHZrQyUoBMADtajjQ3GMQeYv77fCfi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjTPRSSgpSqHtr5d6JpszwrG9hfNuKQKnr", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjTRoy39Bfq1DJD6UPiHvcCVgrA663WkSf", "assetId": 0, "balance": "0.00000988" }, + { "address": "QjTb7j82YixgpQTdiC48NtQuGPLfpmHJK2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjThnvVJPXMF2Sqjz93Q5LBMay5SfGHF5z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjU84KgV7iUhX5J5SRGqYwz9M96PUhZsrm", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjUAtrQop1KrVqZDwDLEr8fn4Ny3BBCbbq", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjUaqMQgwJwyJUBveZrVb3kD2kZJufFuFt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjUcV3hg7iJhL16EPLfGpxrkLaWK1BRh8G", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjV2cEAPTnnpMdh3wZQoU15RqPFEf8nk7k", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjVm1ZaT62korzr9XvRxmJppyFF4sdafeT", "assetId": 0, "balance": "0.00000988" }, + { "address": "QjX2aCXUpbpXcj5reCF2mz2QhQEjFwFQxZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjXFyidrrES8UpcaNbbZ7Chsw4R53ENapC", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjXSAX6zxJJktfeiFNcT8iF7uD37pJZMkE", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjXfKHz3vVLVCr2W2JKPLubWzkZ3z513Bn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjXxa8QAqLCC7JTgkUeAo9NBqRorDbtGzw", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjYPToWQGL3J2zpSD9ZzzcCPJxdrmW1xd2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjYZ3dNRwyiTvWA78z6CJM61UTDQ9wf3zR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjYfQDSoLcW9wK4aWU4wTjezcVkXp7wJhf", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjYuxnJRUQ8XiVUeWCzrSbhJ2UfJqejXsU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjZ52sEFfAHDHJdMXdy1Jgrc1cZf5a8rtR", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjZHRnwskvrHL5ADzWZVXC2RvCKu9p8K1z", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjZh8Gji5FZKWkFJMtXPiSSv1tgvML9rhj", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjZkDEh4m8hkrf1AvkvUh3cVvYpsseF3xU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjZmNpap4doB6WxuWCp733fP5u17VjpQeo", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjZsQUwTXCjqAvN44Kqut41zhg2G6EHEQ8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qja5aszvDXnvc37BnhbqrAhLCW73DgGir1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjaD9Zc3YSzxFVHEhnCTgJWvjPfXdZfdkt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjaXizUUydq5DTtwnVvXWpPgmweicoAe6q", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjab2CEUop2RYqGFbnqx4a6YEaSTX8Z1Z8", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjanYicHuScm6QsbpjNqVRWsfw58fW3Nti", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qjb6uHaNHd3HSRwxGSPJFxjfig3E6xTAYR", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjbJVukYLAfheQ2iKUkQ3Vd812y6aj2FAK", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjbNjUC36XBvmv6AusKwTD5T69dFj2XMjt", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjbepvF7qcNsmtLb3h2mn3cfm3czCthdnH", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjbjMVjM4m3oou916udjEJAkG6mHjf4wyL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjcfdrubuehxS5W6SUWcj9WDjdY91HwrsJ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjcoLXgRYR8Bg1RwuVJ7C4KttQ7DFngicG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjdLvAPRParxDFUPvtL4EYQpgJz4aKDJXE", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjdMUSewptx5M9KXUrx8HPSVZPXqa8JDVC", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QjdRfWnJvTZ72nmqc5q1g2iAJF93MEiZu5", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjdbbLWAdC2pn2oHMoAvck7CZZVGyLQaBY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjduttetS3AFJd8rPfHQhj33V7TwateWbV", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjeAE8CuZVB76n42NsZ2i37X1GSPZXAKS6", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjeDW1nf9YzWHV1hGsFee5jy7SBf8qnG8S", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjeQpXkrdgUzvS6h6LbrsJHdxCDnPi69By", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjeaB2vWtujijBnzBi3MzfDRPFiAeso3JB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjeaksjY1u4DfaGru9oWXuDrExNf38xjSB", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjejfBcaBbjtgvdRCvf1s4uZeEY6nTkwsL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjewAsY3oQiNG8qYWtknAWhrL7iJgHGtdo", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjf1sJh7Y7e16QWjExgzQt7o2Mqxrgw977", "assetId": 0, "balance": "-0.00002752" }, + { "address": "QjfCYFnSsZa3P8x8bYGFinqZQ7ciUdPfGf", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjfGhiydPaGhmhSJu6vvgFhi4zJrKZ5xuJ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjfNd6VFarwKudFmjMd6T2TTvU1o81qpmx", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjgGeEkyiXa43pyqkXxZbvAChQpVYfUyKz", "assetId": 0, "balance": "0.00000988" }, + { "address": "QjgLUkCWbLBf4JDQw7iuq1P42XHHr5Kz74", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjgVzEBzm3KZrdhgpmYxC7NU2TFQwkqBFL", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjggniFULCiUSjNohBetH7tsizRn6wuT7R", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjgujQwfs8vuDcGuVGHTUX8CrFsyGSxZhz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjhMPxN1PcgxmUKzLjHLq1Qtu1WJvpyG9X", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjhg348jpQordUsh445q6XDYMe58jBr5G6", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qji3z1vmw29gkJeHNxGXijMJntZJ4vb1qc", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qji7FsW4zStbafZ44nrUY6bEXtbMsiX9Wn", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjiCZ9RiRUeXkfVZfcmKw4pQi9Qr3towUS", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjiFAKq7hDScB5GaGU87ybiXPoodS8zdTG", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjiLAnckX6o9Dsd3DzjgWSMW4UcD56243a", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjik8McTZvU99cyj28ZpvmPus9eMjSNGg9", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qjip2XgHvnHRmQbF7Eri5jZhLdrehwGZHX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjjoXna51Me9rKKY4RrvKfUaeYC9af5vXT", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qjjq4xNG9JqDugRTPNESdtf84YtVUPhiMU", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjjtR9KiUaFqiJKqit5WrkfPoCY5omWftP", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjjxmE9Q8Rg6FvFqH48CoydSuZAghanf4V", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjkCNgQtCTwdftxX9thuxkvUZxTmow5Vju", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjkEzT4nGH5vY5S4yfkaLQcKqcjyQdCw7K", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjkVpcFVcQWqoba5pVTizVNiLDV49cxwnj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjkeCU1Z2hiXQ98AcpSApJ4maHJYN7WmYY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjkpTkLsGofuEURvth1WMQxqG62sQ2R96X", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjkztzupv1D8DmVYsyajV5k2oEH6Ck2V5z", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjmHmDPW5uKX4KZcMKPgeegZzkvbWZNWio", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjmLWjxNNmxFTdo6hENTkFoabzKAB3baiY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjmN9sMTKEXEbXYRi65mnmj6tVehJscorP", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjmWuEEwUNDz1339SSYxo83NuMgoDBvPrz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjmXUMZ21GKTvaMufaJVTdECZ4cnqJW1mB", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjmkVwyG2Yj9BT9QKtVq19kVGgEvL1XwFz", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjmyozpnF2xSwhHJSLtdjhEHz6jj5YguNt", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjnATk2tbLBEMwCNZYhqiWMhEVT5Bbo4kU", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjnCsv4Wa9CLH2zw3ZxtXi5fmC3759NmLr", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjnQLkwUL4pZgTmrxU7avTgGgPGQN9nS9v", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjnYUWMaGsG3xHxXmqKjmRG4at2coubBNe", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjnbdyK3UbscUs6teRcuLhkbmP5fCo5qrx", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjnjxWpQ3SeXP4fSJFDwdyz4YYidwuaSLV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjoBXqi13MoQqoKHoKa8Zih88ScKZgx9nL", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjoBrfpRasxBEdTcUrKrSBaiWhb4cgcs3E", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjoFBC21w3JD9aq9LJSju87oCrT5VLdxmi", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjoNWEy1YhE59tsBfKG6DoSh6eFBuSiQvY", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjoNxXBVdYH1pPTJjk5LAuw1gyNFY1GfAP", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjoi8JJoLL7h2CPn98UcmhPGwpc3KhBgjY", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjown6CR4LNqvLmK3B9jacVNTY4U7fFDBS", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjp8FfxqzjYbGMBzA9j2oPsRcAph65yvgi", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjpUbUvHaKurJhZZTKQYwUztpYYrPAhJNX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjpkhMQuPaeEacSAcAAA6H9Fm47QqNs75R", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjptiBdCf7q1oFSpCnNkDU3Wd2Wi7qnjzN", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjqBpzxP9HhiadKW7aGSK4iD3KfBNYiLkM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjqCg9TzPt5M3ZkZJHRKYNgQxpRC6dz7cD", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjrC8NXwR8gFkEauvRwCPxqHroPFqAJbhK", "assetId": 0, "balance": "-0.00000001" }, + { "address": "QjrCFCi6dqvka4UELg2SHhM2oWnQWepd1o", "assetId": 0, "balance": "0.00000988" }, + { "address": "QjrPiyvgYizfAvDXuX3TDN3RoSE1d3xiCw", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjrcpjfF1sckgVdmA65fBuiLwBoLuJkuhY", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjsDF3Ekxy8Upyaigqt5ama94LVZmxwXfX", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjsJuK85Yh7fHAgFhcSm9z338yCnQTBSwz", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjsJx2BicKSXhrZ7fQwBTFhzoz4kShrsRW", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjsWo1wYmB5XRRLDBW4KGSE4c926hnVwvb", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjsfJc51v1riU8EQxUUeWHRf4y3NbqgsPZ", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjsiaZqNJ9vg5FoHgunwDACiGaB26UiaDV", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjstonDDkiyKikFpVT6T2m7cBzDGAas6xs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qjt5yNFGkLrnxnv21TrkQPe2kcfFeM67C1", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjtCRBEZ4Ty9JhQoGnrzXaEguE9mS48Cvj", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjtETnkvebB4d8zvjUrwsToKuUc1jVknM7", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjtLsgfzT8xFX2TSotJQCprhqwhvqwqWVs", "assetId": 0, "balance": "-0.00000012" }, + { "address": "Qju6WTt9EFQiAi8g6AaaaDoGpkGaRbVRmA", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjua7CnguPnhLX3hFKbid8NXSemdiCxZcX", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjutBLYDWosTKh1SK2jgHX755wiDWdDmMM", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjuuNmLqHU3kGkGUxJRQ2BfB3Pj2HbUrjq", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjvDwr3fsoHmF4XQhwbzeRU8UgBkgPdydg", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjvERCFXGwMroajvLVSRLzWnqUyqCVwPDC", "assetId": 0, "balance": "-0.00000012" }, + { "address": "QjvP6L5kq7Z2eB3iMZNkS8G7ruFe1hPERe", "assetId": 0, "balance": "0.00000015" }, + { "address": "Qjw7TQByYaWGEvn2zNmdXZwt4iT5rTiZH2", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjwGEUkx14a7hNkzEdTGzJCxbY87x4PLMQ", "assetId": 0, "balance": "0.00000015" }, + { "address": "QjwTMGEtJuR71AisTUB9Jqio3MoLi55KK9", "assetId": 0, "balance": "0.00000015" } +] \ No newline at end of file diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 8b8373a8..3264b670 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -29,6 +29,7 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, + "onlineAccountsModulusV3Timestamp": 1731961800000, "selfSponsorshipAlgoV1SnapshotTimestamp": 1670230000000, "selfSponsorshipAlgoV2SnapshotTimestamp": 1708360200000, "selfSponsorshipAlgoV3SnapshotTimestamp": 1708432200000, @@ -37,6 +38,9 @@ "blockRewardBatchStartHeight": 1508000, "blockRewardBatchSize": 1000, "blockRewardBatchAccountsBlockCount": 25, + "mintingGroupIds": [ + { "height": 0, "ids": [ 694 ]} + ], "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, @@ -94,6 +98,7 @@ "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 1731958200000, "onlineAccountMinterLevelValidationHeight": 1092000, "selfSponsorshipAlgoV1Height": 1092400, "selfSponsorshipAlgoV2Height": 1611200, @@ -103,7 +108,18 @@ "arbitraryOptionalFeeTimestamp": 1680278400000, "unconfirmableRewardSharesHeight": 1575500, "disableTransferPrivsTimestamp": 1706745000000, - "enableTransferPrivsTimestamp": 1709251200000 + "enableTransferPrivsTimestamp": 1709251200000, + "cancelSellNameValidationTimestamp": 1676986362069, + "disableRewardshareHeight": 1899100, + "enableRewardshareHeight": 1905100, + "onlyMintWithNameHeight": 1900300, + "removeOnlyMintWithNameHeight": 1935500, + "groupMemberCheckHeight": 1902700, + "fixBatchRewardHeight": 1945900, + "adminsReplaceFoundersHeight": 2012800, + "nullGroupMembershipHeight": 2012800, + "ignoreLevelForRewardShareHeight": 2012800, + "adminQueryFixHeight": 2012800 }, "checkpoints": [ { "height": 1136300, "signature": "3BbwawEF2uN8Ni5ofpJXkukoU8ctAPxYoFB7whq9pKfBnjfZcpfEJT4R95NvBDoTP8WDyWvsUvbfHbcr9qSZuYpSKZjUQTvdFf6eqznHGEwhZApWfvXu6zjGCxYCp65F4jsVYYJjkzbjmkCg5WAwN5voudngA23kMK6PpTNygapCzXt" } diff --git a/src/main/resources/i18n/TransactionValidity_de.properties b/src/main/resources/i18n/TransactionValidity_de.properties index a2019670..122d95b3 100644 --- a/src/main/resources/i18n/TransactionValidity_de.properties +++ b/src/main/resources/i18n/TransactionValidity_de.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = die Gruppen-ID der Transaktion stimmt nicht überein TRANSFER_PRIVS_DISABLED = Übertragungsberechtigungen deaktiviert TEMPORARY_DISABLED = Namensregistrierung vorübergehend deaktiviert + +GENERAL_TEMPORARY_DISABLED = Vorübergehend deaktiviert diff --git a/src/main/resources/i18n/TransactionValidity_en.properties b/src/main/resources/i18n/TransactionValidity_en.properties index a93db3da..aa25af9d 100644 --- a/src/main/resources/i18n/TransactionValidity_en.properties +++ b/src/main/resources/i18n/TransactionValidity_en.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = transaction's group ID does not match TRANSFER_PRIVS_DISABLED = transfer privileges disabled TEMPORARY_DISABLED = Name registration temporary disabled + +GENERAL_TEMPORARY_DISABLED = Temporary disabled diff --git a/src/main/resources/i18n/TransactionValidity_es.properties b/src/main/resources/i18n/TransactionValidity_es.properties index 8ac7ccf4..2984f229 100644 --- a/src/main/resources/i18n/TransactionValidity_es.properties +++ b/src/main/resources/i18n/TransactionValidity_es.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = el ID de grupo de la transacción no coincide TRANSFER_PRIVS_DISABLED = privilegios de transferencia deshabilitados TEMPORARY_DISABLED = Registro de nombre temporalmente deshabilitado + +GENERAL_TEMPORARY_DISABLED = Temporalmente deshabilitado diff --git a/src/main/resources/i18n/TransactionValidity_fi.properties b/src/main/resources/i18n/TransactionValidity_fi.properties index a7bc9c0a..36235a7b 100644 --- a/src/main/resources/i18n/TransactionValidity_fi.properties +++ b/src/main/resources/i18n/TransactionValidity_fi.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = transaktion ryhmä-ID:n vastaavuusvirhe TRANSFER_PRIVS_DISABLED = siirtooikeudet poistettu käytöstä TEMPORARY_DISABLED = Nimen rekisteröinti tilapäisesti poistettu käytöstä + +GENERAL_TEMPORARY_DISABLED = Tilapäisesti poistettu käytöstä diff --git a/src/main/resources/i18n/TransactionValidity_fr.properties b/src/main/resources/i18n/TransactionValidity_fr.properties index 55ae9082..22413941 100644 --- a/src/main/resources/i18n/TransactionValidity_fr.properties +++ b/src/main/resources/i18n/TransactionValidity_fr.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = l'identifiant du groupe de transaction ne correspond pas TRANSFER_PRIVS_DISABLED = privilèges de transfert désactivés TEMPORARY_DISABLED = Enregistrement du nom temporairement désactivé + +GENERAL_TEMPORARY_DISABLED = Temporairement désactivé diff --git a/src/main/resources/i18n/TransactionValidity_he.properties b/src/main/resources/i18n/TransactionValidity_he.properties index 2f9338f0..3bc149b2 100644 --- a/src/main/resources/i18n/TransactionValidity_he.properties +++ b/src/main/resources/i18n/TransactionValidity_he.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = מזהה הקבוצה של העסקה אינו תואם TRANSFER_PRIVS_DISABLED = הרשאות העברה מושבתות TEMPORARY_DISABLED = רישום שמות מושבת זמנית + +GENERAL_TEMPORARY_DISABLED = נכה זמנית diff --git a/src/main/resources/i18n/TransactionValidity_hu.properties b/src/main/resources/i18n/TransactionValidity_hu.properties index 2dbd9fd0..bfc905ca 100644 --- a/src/main/resources/i18n/TransactionValidity_hu.properties +++ b/src/main/resources/i18n/TransactionValidity_hu.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = a tranzakció csoportazonosítója nem egyezik TRANSFER_PRIVS_DISABLED = átviteli jogosultságok letiltva TEMPORARY_DISABLED = A névregisztráció ideiglenesen le van tiltva + +GENERAL_TEMPORARY_DISABLED = Ideiglenesen letiltva diff --git a/src/main/resources/i18n/TransactionValidity_it.properties b/src/main/resources/i18n/TransactionValidity_it.properties index a520d5ae..ebaa7caa 100644 --- a/src/main/resources/i18n/TransactionValidity_it.properties +++ b/src/main/resources/i18n/TransactionValidity_it.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = identificazione di gruppo della transazione non corrispon TRANSFER_PRIVS_DISABLED = privilegi di trasferimento disabilitati TEMPORARY_DISABLED = Registrazione del nome temporaneamente disabilitata + +GENERAL_TEMPORARY_DISABLED = Temporaneamente disabilitata diff --git a/src/main/resources/i18n/TransactionValidity_jp.properties b/src/main/resources/i18n/TransactionValidity_jp.properties index 3827635c..f5acb35d 100644 --- a/src/main/resources/i18n/TransactionValidity_jp.properties +++ b/src/main/resources/i18n/TransactionValidity_jp.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = トランザクションのグループIDが一致しま TRANSFER_PRIVS_DISABLED = 転送権限が無効になっています TEMPORARY_DISABLED = 名前の登録が一時的に無効になっています + +GENERAL_TEMPORARY_DISABLED = 一時的に無効になっています diff --git a/src/main/resources/i18n/TransactionValidity_ko.properties b/src/main/resources/i18n/TransactionValidity_ko.properties index 2667471c..5f1118c5 100644 --- a/src/main/resources/i18n/TransactionValidity_ko.properties +++ b/src/main/resources/i18n/TransactionValidity_ko.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = 트랜잭션의 그룹 ID가 일치하지 않습니다 TRANSFER_PRIVS_DISABLED = 권한 이전이 비활성화되었습니다. TEMPORARY_DISABLED = 이름 등록이 일시적으로 비활성화되었습니다. + +GENERAL_TEMPORARY_DISABLED = 일시적인 장애 diff --git a/src/main/resources/i18n/TransactionValidity_nl.properties b/src/main/resources/i18n/TransactionValidity_nl.properties index 4f0dd8b7..7ee466d1 100644 --- a/src/main/resources/i18n/TransactionValidity_nl.properties +++ b/src/main/resources/i18n/TransactionValidity_nl.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = groep-ID komt niet overeen TRANSFER_PRIVS_DISABLED = overdrachtsrechten uitgeschakeld TEMPORARY_DISABLED = Naamregistratie tijdelijk uitgeschakeld + +GENERAL_TEMPORARY_DISABLED = Tijdelijk uitgeschakeld diff --git a/src/main/resources/i18n/TransactionValidity_pl.properties b/src/main/resources/i18n/TransactionValidity_pl.properties index 50944674..b441c128 100644 --- a/src/main/resources/i18n/TransactionValidity_pl.properties +++ b/src/main/resources/i18n/TransactionValidity_pl.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = niezgodność ID grupy transakcji TRANSFER_PRIVS_DISABLED = uprawnienia do przenoszenia wyłączone TEMPORARY_DISABLED = Rejestracja nazwy tymczasowo wyłączona + +GENERAL_TEMPORARY_DISABLED = Tymczasowo wyłączona diff --git a/src/main/resources/i18n/TransactionValidity_ro.properties b/src/main/resources/i18n/TransactionValidity_ro.properties index 4c149a62..197f3864 100644 --- a/src/main/resources/i18n/TransactionValidity_ro.properties +++ b/src/main/resources/i18n/TransactionValidity_ro.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = ID-ul de grup al tranzactiei nu se potriveste TRANSFER_PRIVS_DISABLED = privilegii de transfer dezactivate TEMPORARY_DISABLED = Înregistrarea numelui a fost temporar dezactivată + +GENERAL_TEMPORARY_DISABLED = Temporar dezactivată diff --git a/src/main/resources/i18n/TransactionValidity_ru.properties b/src/main/resources/i18n/TransactionValidity_ru.properties index 79307d7d..4e81b82f 100644 --- a/src/main/resources/i18n/TransactionValidity_ru.properties +++ b/src/main/resources/i18n/TransactionValidity_ru.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = не соответствие идентификатор TRANSFER_PRIVS_DISABLED = права на передачу отключены TEMPORARY_DISABLED = Регистрация имени временно отключена + +GENERAL_TEMPORARY_DISABLED = Временно отключен diff --git a/src/main/resources/i18n/TransactionValidity_sv.properties b/src/main/resources/i18n/TransactionValidity_sv.properties index d4688310..e815d282 100644 --- a/src/main/resources/i18n/TransactionValidity_sv.properties +++ b/src/main/resources/i18n/TransactionValidity_sv.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = transaktionens grupp-ID matchar inte TRANSFER_PRIVS_DISABLED = överföringsprivilegier inaktiverade TEMPORARY_DISABLED = Namnregistrering tillfälligt inaktiverad + +GENERAL_TEMPORARY_DISABLED = Tillfälligt inaktiverad diff --git a/src/main/resources/i18n/TransactionValidity_zh_CN.properties b/src/main/resources/i18n/TransactionValidity_zh_CN.properties index cd16bf64..afa95f8b 100644 --- a/src/main/resources/i18n/TransactionValidity_zh_CN.properties +++ b/src/main/resources/i18n/TransactionValidity_zh_CN.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = 群组ID交易不吻合 TRANSFER_PRIVS_DISABLED = 传输权限已禁用 TEMPORARY_DISABLED = 名称注册暂时禁用 + +GENERAL_TEMPORARY_DISABLED = 暂时残障 diff --git a/src/main/resources/i18n/TransactionValidity_zh_TW.properties b/src/main/resources/i18n/TransactionValidity_zh_TW.properties index d039a8e7..3110bf25 100644 --- a/src/main/resources/i18n/TransactionValidity_zh_TW.properties +++ b/src/main/resources/i18n/TransactionValidity_zh_TW.properties @@ -197,3 +197,5 @@ TX_GROUP_ID_MISMATCH = 群組ID交易不吻合 TRANSFER_PRIVS_DISABLED = 傳輸權限已停用 TEMPORARY_DISABLED = 名稱註冊暫時停用 + +GENERAL_TEMPORARY_DISABLED = 暫時殘障 diff --git a/src/main/resources/invalid-transaction-balance-deltas.json b/src/main/resources/invalid-transaction-balance-deltas.json new file mode 100644 index 00000000..fac9db95 --- /dev/null +++ b/src/main/resources/invalid-transaction-balance-deltas.json @@ -0,0 +1,1796 @@ +[ + { + "height": 276429, + "address": "QYn2Uh4eii4SE29BpEPeRySbAeb9R6tGbf", + "assetId": 0, + "balance": "0.00000333" + }, + { + "height": 297592, + "address": "QLdw5uabviLJgRGkRiydAFmAtZzxHfNXSs", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 301032, + "address": "QYn2Uh4eii4SE29BpEPeRySbAeb9R6tGbf", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 316600, + "address": "QdyzBSPBNLfyCfdPNBn86EcXUZeDdkCYLm", + "assetId": 0, + "balance": "0.00008640" + }, + { + "height": 319195, + "address": "QjKRbeTYYi53Pu4Ph3t8seavsoay3N8zpi", + "assetId": 0, + "balance": "0.00000704" + }, + { + "height": 333480, + "address": "QTFg4go5uJ1oZidqRCXqu7miyUKiqzWuD2", + "assetId": 0, + "balance": "0.00012483" + }, + { + "height": 349299, + "address": "QhjbKpRtrTHegRr28KHVgP6XAZyJyjkapw", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 349427, + "address": "QTFg4go5uJ1oZidqRCXqu7miyUKiqzWuD2", + "assetId": 0, + "balance": "0.00018535" + }, + { + "height": 419881, + "address": "QdyzBSPBNLfyCfdPNBn86EcXUZeDdkCYLm", + "assetId": 0, + "balance": "0.00001226" + }, + { + "height": 470038, + "address": "QdyzBSPBNLfyCfdPNBn86EcXUZeDdkCYLm", + "assetId": 0, + "balance": "0.00011251" + }, + { + "height": 473261, + "address": "QdyzBSPBNLfyCfdPNBn86EcXUZeDdkCYLm", + "assetId": 0, + "balance": "0.00009462" + }, + { + "height": 488927, + "address": "QdyzBSPBNLfyCfdPNBn86EcXUZeDdkCYLm", + "assetId": 0, + "balance": "0.00008630" + }, + { + "height": 529402, + "address": "QPLoqpwAoytvpQKwvJ6GRsaRcVZ3xnYgVB", + "assetId": 0, + "balance": "0.00000763" + }, + { + "height": 599358, + "address": "QdyzBSPBNLfyCfdPNBn86EcXUZeDdkCYLm", + "assetId": 0, + "balance": "0.00016348" + }, + { + "height": 601615, + "address": "QTFg4go5uJ1oZidqRCXqu7miyUKiqzWuD2", + "assetId": 0, + "balance": "0.00019881" + }, + { + "height": 633291, + "address": "QdyzBSPBNLfyCfdPNBn86EcXUZeDdkCYLm", + "assetId": 0, + "balance": "0.00006526" + }, + { + "height": 685964, + "address": "QTFg4go5uJ1oZidqRCXqu7miyUKiqzWuD2", + "assetId": 0, + "balance": "0.00016582" + }, + { + "height": 800993, + "address": "QQrnqFh6AedkwRSAEzWWJUfLVtJPbfNurK", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 843835, + "address": "QfpUpwgV5h6SQqaywGvxvBzzV9D774993x", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 988767, + "address": "QfTxdUv4M5LWuaoybQwh1VZ8843Wqq1r1t", + "assetId": 0, + "balance": "0.00000326" + }, + { + "height": 988991, + "address": "QfTxdUv4M5LWuaoybQwh1VZ8843Wqq1r1t", + "assetId": 0, + "balance": "0.00000297" + }, + { + "height": 1002190, + "address": "QTx98gU8ErigXkViWkvRH5JfaMpk8b3bHe", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1011070, + "address": "QZCauGWyChXBgEQiXAJLmSaaz94Asgi8wU", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1011070, + "address": "QjKRbeTYYi53Pu4Ph3t8seavsoay3N8zpi", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1018209, + "address": "QjQosFc13zX2kN52Miyo3DuBYU288jNkdW", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1018210, + "address": "QNEF6iVnzXPAqhJf4x46DhXSnRrPjgqWiC", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1018212, + "address": "QSAtkxer4LwkdQqzStB82K74CNSXuamx8x", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1018212, + "address": "Qjfqk23ZTP9wNLTPLyQzewhxgmjxh8cwv7", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1019330, + "address": "QSFhD2auWxoBqBzMZggf1FqTzoUxz7cddo", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1019355, + "address": "QRnLRt2D4hkKFsyxq2UUfUH5mGwchJc25h", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1019355, + "address": "QfTxdUv4M5LWuaoybQwh1VZ8843Wqq1r1t", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1019356, + "address": "QNfmBzXcb3gLXmBteM4oToqakRrCFXjVuS", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1019357, + "address": "QTZEiy2RgGgyPpkMWE6trKYRHSGqPMufM5", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1019357, + "address": "QSApBAn8pD6jmLs6j4WwxeCa341Crb9yp4", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1035734, + "address": "QhspjBT3mpnao5EeLqEY3HJFXv42uPpCks", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1035757, + "address": "QPpr2JS24d9maQtXLpNQuLqivWe17VNth8", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1035757, + "address": "Qfd3gxberH9K6ipiV33VH3TUooNFYyV1iu", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1035757, + "address": "QUJyCt8ZMDauaH4avg84gCbLY5Es2KJVFM", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1035757, + "address": "QZNDNgaBJhkUjtb66hGvjAs3s1V2TESDxE", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1035758, + "address": "QQYp1TiGWbRChHY8fWzeNSYrBSbyczwkcK", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040180, + "address": "QdsakiJEhKaKGtG4ue2k5xJdt6kYsxwPba", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040185, + "address": "QNw21XRyVhudVTc15XcZZ7giKGWVAndSig", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040185, + "address": "QdkTGqDYde3Y9Q6EgxmBrJGAK2jm4HXspX", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040185, + "address": "QY4xWXWbyU4t2zrcpZUTAR3kcXpcXw63Qn", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040185, + "address": "QezTNFB9czsSYhbJN9YMLNrhRNtmazJrfC", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040186, + "address": "Qfxkjavp3UR7tuG988Hau1PF3Um27fU6VX", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040186, + "address": "QTSi7WDsgJtCmGpE9vJot32dmozM21bDrR", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040190, + "address": "QPVCG6EUkxcuznnDRf4aDLUNSUTnWiKeA7", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040191, + "address": "QN8RijNFo7SDDKYgF5yiWuh86UhWpcpdGL", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040191, + "address": "QiDji6mSFEF3PjGnKfZvMwJrR1GQtnf6Pd", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1040192, + "address": "QLk4souHeUSaT5jKezcmKtjUexZKyqXjqb", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1044159, + "address": "QNoxXt2xDKrM51adtcFLcW92wk615qA64H", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1044160, + "address": "QMNfNWsJuFwiSufnVwWpGU7bqcNgdTKF7o", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1044160, + "address": "QS1i9K7iJb49TA4w43VSC3fEURF6bRXvw9", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1044162, + "address": "QURLLepAaEaUQuQKT3PQ1zMMTD4w8ztuxD", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1044163, + "address": "Qb1pkXG4xufNS3ki354CWkEmC1gmz6D2H7", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1044166, + "address": "QNfi5TZ8LYdK1acZz2VKnChvhY5t7QRWe1", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1044169, + "address": "QT2X4TSA8mG7UitNFwY5DkkV7WS1RRPn7R", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1044170, + "address": "QUjm9fPRs4wbvXmwUYdMDg3HdNxGuR1DBo", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1044171, + "address": "QUhQUjdExXnbX6BYNSHNYohv8WUQgDpCYP", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1045031, + "address": "QXjqVCQ8RaaC7T6Tyiag26Ruj1Tyrx9PvP", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1045031, + "address": "QQfd4mHdR3YvUXgtq1t6s5RxbnVdagqLiY", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1045032, + "address": "QNsiHhrAQUDk5h3ecLw8bAiF2179aggSsK", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1045033, + "address": "QNXCHtt6hwppn3DjKVHEn2ybmPehgNGuV8", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1045033, + "address": "QbUPbjTu3NpEZQcJp4JcTarLRon4oTiSqi", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1045034, + "address": "QUAXAog3G9F8cJMC3KL4iGGFC3AK5hrzzP", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1045034, + "address": "QXmjUhJ7hmcQRrZ1UvnJAeFhp3aYiwL3zq", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1045035, + "address": "Qg7TPhUD5sns2pJiLjxnktRAx71WXsNp9y", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1046751, + "address": "QiJQdet8ziyDeCijhJXFE7MnWbX5XQpn2T", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1046751, + "address": "QUarYYMPRodjEEBKrGsTsufPa1pc5M9mVk", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1046752, + "address": "QMiGaZbcWXdj61Rn1UGVAPgg8s31puuX1v", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1046752, + "address": "QcgxbHijQFUaBBD1Xv6k2Fjh2hRPp2AUFg", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1053256, + "address": "QUCbBSPjDjygRJehHwjcXtM7PngUXKMiLW", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1053258, + "address": "QgZAyh4znJgzsb5tKGsYXXKhaZ2zYitqVg", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1053259, + "address": "QhCcvRt4jmyFtjeqeHGeU4Z1DKdRFGmxs3", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1053260, + "address": "QbyVFRE1zKKcprNvpCBx1VfEh9uosYZojs", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1057915, + "address": "QXZ3Uqs3KcfZDFgCURso8upzmPxxHtD9rT", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1057925, + "address": "QWRpDYNycvqQrL9RmMDraL1hjTRBbghekz", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1091923, + "address": "QMpb5Gxr9PTReeCN6r3BZgPMXozMpmmaQM", + "assetId": 0, + "balance": "0.00000988" + }, + { + "height": 1093206, + "address": "QfaFeewoSkfiSPojMdzGcW9s4gXx29b6F6", + "assetId": 0, + "balance": "0.00000001" + }, + { + "height": 1093259, + "address": "QiZJC7qBvV3EhWzmKghdgXQ746opkjcEVf", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093262, + "address": "QSsBSnwb2QrKKEmHFLk7m1XAiYBWFbQoML", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093262, + "address": "QNQPzpNL1tycGrVoBLoUXhyApKNocSqzKG", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093263, + "address": "QMunU1yCR4KYPhxVxiqeq3Z9csjoWVeadf", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093263, + "address": "QWoBLu4brvBtRUDw73ettFGAA4yEiF6t15", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093263, + "address": "QiDbcs8fseyxkqG1MYWtuEtafMAFkYxpdp", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093264, + "address": "QZ5YkDUgL1EtgrGzo6b3zJFVLyNW2maUTQ", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093265, + "address": "QNR9kvEKkibMJi4Rw4DY9rSL4NXa2FqMgL", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093273, + "address": "QbHmVyTvZBaCCwTkQXs1br8ShvVJcWKdWY", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093273, + "address": "QQjMo2twsrpPThcM15Hb4B6cdi8Y9bg8VJ", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093274, + "address": "QbT3RWDZ6fkeQapiUm5poQBfccCKPVWoWX", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093274, + "address": "QfN5pg6MnY6KnmYf9bbWDXnVxzPUxRXinU", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093274, + "address": "QiRCBBGZPW2Ttd3UijCYGDEL4gP1FPfz4E", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093274, + "address": "QVfy1qzcpMkVhHjvWgNRELesQezTViq3Yi", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093275, + "address": "QTNg8je6rLgjDxBmp6WcuSe6y5X8QQHweM", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093275, + "address": "QiivikAitFeweVZ1dhHTzT32RSxLewoZ87", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093276, + "address": "QUunK5bfcMowvRM4T5XyDP2q91w25QwLUA", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093277, + "address": "QY5BMg5RMPbQLAfd84iiUyVHoaVBVaFfW8", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093277, + "address": "Qezj9taJdhPWM5tggdMneWrCtPM1ViUhoE", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093278, + "address": "QXGBPZgyPyPbJ37PEcirUoFUZtZf8SRD4j", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093279, + "address": "QhVMAHfU63EdfYKsfowEfX6rdj1ogSXoVM", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093279, + "address": "QVEwbC3BTssdgPnVhAsPQoLWZAJhaJWQuG", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093280, + "address": "QadMTAZCy9qkYa67JgaTMgUK3VNyU4gnNp", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093280, + "address": "QjMwBpPJ9YRrSA6Y5T7N3dHSpaZRyb8dPY", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093280, + "address": "QUfWphoopnpjqmbK7RE5A7ogqGkbMGpdtB", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093281, + "address": "QPtiByppfhcEH9g6SoRQpstcNhc85p6JpQ", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093281, + "address": "Qe4xd7M8w7gf2vhqqzAsHNymsKAKegH8pY", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093287, + "address": "QZ5kTQoGA68UDcuSPrdD5YkuoTfaesXSpF", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093289, + "address": "QViewbNxecpxwTpSjZbNMpoFvMLtphB6x4", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093290, + "address": "QMxXDt58dqqTXN9oJzknp6EWzrzVrAvoWx", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093290, + "address": "QWQBvQcBGLiwwycJWGSRLB1BtYMs6gLZFu", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093290, + "address": "QW2u9CxRBhZvVGNk93NtiFcR4z7cQBdrZg", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093290, + "address": "Qgry9DDwQhfVLFEji7tgcCAK2UQFS3nBnE", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093291, + "address": "QRH2afxR7EqqxMhSgjy7XmzAbFCTywMJTp", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093291, + "address": "QhCxKRdXezgs3jWe3VPwFe4JLXSHgGXnVB", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093291, + "address": "QiBUNmGsFszE3YTgcsTvCQCMGvMK9JqN5q", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093292, + "address": "QMNWmrmv67GGMD78V74hfwhNAo82LAf6mg", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093292, + "address": "QiVndNZgDH2WjFxxGcu59NTb9KYzU24Tv8", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093293, + "address": "QbLxFMPabbbJHqD1pN5AsFjotSxZjRZnpU", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093293, + "address": "QTg8Ee1Ph7da1X3Vcgcx5SKjv1zNVbsAfs", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093294, + "address": "Qbdh8StCUJcQu63qmc2WpSjXMyGScUHiYn", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093294, + "address": "QRmEsku8SM9WnF1QsoRiRQfJFEDZBe3oZL", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093294, + "address": "QPWxc25kgMu2ZsFnZwGz8yXdSbNnmgig6s", + "assetId": 0, + "balance": "0.00000980" + }, + { + "height": 1093298, + "address": "QYAKNhddVH7CrJn8GisLS8xE3nUJvkSCn7", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093299, + "address": "QLxSajwTyTtX5suTwREBxUdvHKzApTR5UG", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093299, + "address": "QhdnxnkoHXCmMVqrgRw2R5sBZtgFWftVfQ", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093299, + "address": "QW8nyaqCvKPUjAV6bHhLvMGFW2jrpWA2pv", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093300, + "address": "QSxbULEeis5KfnCRywrEfma7MHxgZHddee", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093301, + "address": "Qc3FPjw7UpNpxD9YTTAXkD4Nzowbfu41aL", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093301, + "address": "QTRJFPMXBuX68LpoW5HcVFMvtiypuPSvvu", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093302, + "address": "Qeos2fCtFeHtSpUd1y4M6BuiejQcQFmwfM", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093302, + "address": "QX8Yg85ooCV23euSURWTbXd8fh4K19Lx4v", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093303, + "address": "QX5Q1BR3WBhLcyGpgYstzunK2pRDFDWc4m", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093304, + "address": "QiHQcUjPFVJgy1fQP63w5WbijbuMA5E1e7", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093306, + "address": "QgLfiKfBpRe9t1BjqXZVeqivyXu674qFQi", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093306, + "address": "QaEERsupuffE1Q2DKk955Jz7eyg1SEmJ4U", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093306, + "address": "QaZjFD4zrkcpuWvxMtnp2L2J5bQF8LBncW", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093307, + "address": "QiAvMrDTckgapoDR1cfVS14Madxac6EjZ6", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093307, + "address": "QbFYJjPtUk1yWv62pwvNoLLPCsKFKowM7w", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093308, + "address": "QdN83MY9uTQkNjX4Dt7G2oXKvFXdQyZHf4", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093310, + "address": "QYC49KA152NqduVDidmbig9DZbsBVPyQHR", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093310, + "address": "QUqBQa3h9dbyrryLn9xgNhkwgxKHzpGDp9", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093310, + "address": "QRueeQvSE7oMcD97CXi3YaHnssMBMhc7KY", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093310, + "address": "QdUoKndqbWmKsTNrm7isT1WYLKeuy7s32Q", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093311, + "address": "QhADNEdXHTmLuiGMCdBN8FdCmApC5HLacb", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093311, + "address": "QbkYbT7aX7QHni4wPadUMYAKsn1JYfwWaQ", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093313, + "address": "QjBgyPmoPowCQYy6Wa67d1VbJew8NmGcYw", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093313, + "address": "QhpiRWHexba9XN4RsRAooaFWbmqsg38HzZ", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093315, + "address": "QPxY1yJjFeBGefcyj2nFaRadnqG2JuQwwk", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093315, + "address": "QYeK5dHLBG5pCzeXFiXvP1yBsUYyjWzC6f", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093316, + "address": "QVyXteRh2WufVUczKiqkbMWfKRzbx7uXit", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093317, + "address": "QQVS8Dg1HtuEsu9WK4ycho9M8qdXRQDab1", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093317, + "address": "QSDGwcCy9DCBuxtvs12gxjb3EkabSZZ7GB", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093318, + "address": "QhasYvtKLNF46T22z5dfZmiPS1n6XiGBhH", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093318, + "address": "QMhZWvvaabfLRBPu7viBPyMKVD5jTxwFVQ", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093318, + "address": "QfrUsEhegQtvGN3eEpm1QJxDFTBXn7NFS4", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093318, + "address": "QZeWDxZmitSX1MCw8cDcJsbGvM5pXeocxX", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093319, + "address": "QaWFThsqXGGT9eJA8wbdNb3ZzPgLGg3gvN", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093320, + "address": "Qfv3GnnbX2ea2qjgiirkxJ5r723uPEM2zk", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093320, + "address": "QaxFmHkGKWKgpicngJTPGA2qDN1Lj6Zdg6", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093320, + "address": "QYGbDdyk3VrdwyZ22RMwt9XKbGCeDARV8f", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093320, + "address": "QcKJQPZArsJ2cDiq63YxZqD7DtXUhK5AQu", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093321, + "address": "QfTeUx7fU8ed9U2bpcmJXVYfDxe8GVGTh7", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093321, + "address": "QMik4F9yLcPdc7ZWQyTabc8RPwdfzgKTo1", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093322, + "address": "QVwScCQx45EyvdDcG5ivjANfMRZV6navUF", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093322, + "address": "QfB7p6WtT1B3vZzXofabGigW7TCBftex8t", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093322, + "address": "Qd28tETNJfeSWcWTa6SQAWcaHN2DieHAhK", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093323, + "address": "QTom8nzAsLXTANRbKxv1nXUQAcpuLubCvy", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093323, + "address": "QYeJfTM7eNwX5S1RQxGqThPKaLUoCd4qMi", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093324, + "address": "QfeFQkv5GrqpHSEg6ZmKqQ2jVp98diQNuy", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093331, + "address": "QeNptFheMPcxowUyaqWfFPA1Zh8czwhbY6", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093332, + "address": "QfHpfS2h9wMj3WjncLN3f3WBztqJ4oNRCX", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093332, + "address": "QWzhPZvGWVXAvy7PkQA5cg3wcfWFdpQqSH", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093333, + "address": "QYWyxR2aLdWCnNhnSrfCanrjHwMYytGb6j", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093333, + "address": "QXzuHzxqWERkDt99cokk5iEnR9P5R9ayta", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093335, + "address": "QPqT2E3AUmCn57P19ZS9fHtZKZEhoyC75w", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093335, + "address": "QetJVQ7sJM4qnNPnLynjtWF9vpoxmwu89q", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093335, + "address": "QX9g3P5oewATU3bEH62C6V2UTAhA4xFsbf", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093336, + "address": "QWyqFA9TpJz8228k7vaVQFgTdnaGS7gXZx", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093338, + "address": "QbAYPBjegZeL3jWy6dMLbtYrqvr2iaTgFL", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093339, + "address": "QVZpibGGqLJsceJs72y1ShTGMo7SDpdPg2", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093340, + "address": "QMb9MA88KLPqB1MCANF1Q5iaexeMYJeApS", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093355, + "address": "QfTMPGVNEwusQSbFMdWCbh2tBkXWnynoDj", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093356, + "address": "QZUwRDkBRtDZuXE6Ct4Xnv4tfF1cL17Jak", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093357, + "address": "QLem7hMo5qPgogwakniBk47rLDNjypJR2r", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093360, + "address": "QbreLkZCwSh349jWS9Dy7S7gVRADhWt9wW", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093361, + "address": "QdVfBG6M3V9kDULAAWuGgWFp9L7e9GqLdt", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093375, + "address": "QWa3hkkgSbYVjJVdzQV9fXRyWY5LQKiVsA", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093457, + "address": "QgA9ARtFdxei4wuMQtLfPzrb4YSKNTARGv", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093457, + "address": "QgVuFn7hLHvSpuUG9Q9WC7WSBm73vmWvRb", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093458, + "address": "QLgsPXLAZoKEyK3vq23GkDA9EDvvArfMg3", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093459, + "address": "QPqGjAbhnni1PF1f66YXvYo6DjDSGpT4Hh", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093460, + "address": "QXbePZN4uwmzu1C7ZNuqU9Pezn8DePTNuz", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093460, + "address": "QcNtdaMWTrLV4d8t6iHVd5UfsvuTcwvZxh", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093461, + "address": "QeG2Uaxci9dchEzh4KMGfmAekxMSmQ89PF", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093461, + "address": "QPjLUUP5iwmVJEqyY9ERNnj5Tz4oRXYgfo", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093466, + "address": "QbT7WGTSdD7m7PHay9KEChusek5bYNTKgH", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093467, + "address": "QVvvu5quCq5N92RWgvvx9K7dTcgSXRfQxD", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093468, + "address": "QjUCsr9y1LF7g9YjxsB2ooffdueBi4yx8k", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093469, + "address": "QVHJc7BiDq1edXYVLPZShFkH2TbSkUtagC", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093470, + "address": "QZymEFyQwkJchgzWA16XA3eNXtY2PSxLZ7", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093470, + "address": "QVEg2PHKMdKcCgweHJ5QTGNiosytGajyah", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093471, + "address": "QeehxVEFj5BfKLqyTH9sCi1zWE2NnvYYdk", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093471, + "address": "QghRQzFcTv2qDtVQkZjobDYToXRNohuFEM", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093573, + "address": "QgcLn3mZMooeLkrXhccMGk6fpergsyMMDD", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093574, + "address": "QWdRKgxTENXXWxHpxxemLQFYARRLktGhs4", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093575, + "address": "QUPLytwEkGEdgNocHj2CdQrjkkNcdMqrVt", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093575, + "address": "QPS4CFSD8vXXzK4aF7KJB2goQtsjxei9TF", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093576, + "address": "QRWH7ttsqDAZRsYvEydmBtRNiaMUZTU8vA", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093577, + "address": "QZJoK2pM478zaWHtMEWLmxkkE6Fn5jWPW1", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093578, + "address": "QTDJmUpPJMNo5rzPAZKLfxVrDZaMwnhzmu", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093579, + "address": "QMaMpnVYiwPUaCQj29E4EFmfE5hZTFPBc2", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093579, + "address": "QggmMMsr5yTM5Z77sJTu6n4MfK8KVfYs8n", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093580, + "address": "QeXGavC5cGKdF55Tqut8Mr2d8fhdyy6jFv", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093580, + "address": "QaSMZ2bJMMeDrodMR26QRbBLwMdumSxNqw", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093581, + "address": "QYzRhdyTt4UM9i9gp5Sd6w998qE4R1UHjH", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093583, + "address": "QXgo881c4L1zSuvBPoH589hy6JtTRC8h9L", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093583, + "address": "QNiLMNuivo7izfQ7iRPh8nghfu5akBcZeA", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093585, + "address": "QSo8VnBYYS5RwvqRVGuqhJ1Jt8sBRdTGK7", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093586, + "address": "QNRhHMGDSxhnEaGEHm5iHBCQzonqcMWoDt", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093586, + "address": "QYqxZNCVwGY5e7bvgrN2BQu6bkAnS2JGT8", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093587, + "address": "QeCREZ6nDWdaSJoyXSZDQ1Ha1QLLwKoRUr", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093588, + "address": "QRxzEi6kCUyQt6gfnnXiB2U8CgaqeyyRET", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093589, + "address": "QZmBQE1XQrRD7tdEsE2mg6cr7jAo5kVUwL", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093590, + "address": "QgiZUoRiVyh6YZL45c3zB9siVadosmKj29", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093590, + "address": "QeWUMbSTjhcN7tzx7vkEeXFw3o5PMV8Pr6", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093591, + "address": "Qc2PSAG1w3dEMc43YYH23drHRoaRNujbdp", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1093591, + "address": "QYGYkNyjQyhoPJFLnentDNGMXTxJ5G7xC7", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1097184, + "address": "QTtFFjFZgvwZV21wwA9EKscmrxx1cpJV2c", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1098337, + "address": "Qaes2mfwR6fPaW3wWFMjvauekPLCrPZnbP", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1098530, + "address": "QTqtjUqq6TfpEt4QHtzsiCVcDxCUbHCToB", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1098532, + "address": "QNnmknD744z9PwGTgCAJfnSUdshkbtAJLq", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1101800, + "address": "QRhZ72WGTt422ija1S9KjNJoFmFo7TLWCp", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1110848, + "address": "QcV6Wj5MkdfLsbuMCaGMqBk8po5BBbxyDE", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1110850, + "address": "QLwHp9ifg97AMKitkQLHA2KiC7PhJSzpdQ", + "assetId": 0, + "balance": "0.00000014" + }, + { + "height": 1110852, + "address": "Qf2k5ZEjjYUito22jjehetpi3Z2HNGz81a", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1110856, + "address": "QTSGVqL5TVBHkHdKSfroW4VR9V8cxGcVFh", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1110860, + "address": "QMq9U6wRSzTzy9LN6v37kQXpKQ9wxvAbqu", + "assetId": 0, + "balance": "0.00000006" + }, + { + "height": 1110861, + "address": "QXnx4fYcMcTjsnjUwJNQqwJhUQ32LRbn8d", + "assetId": 0, + "balance": "0.00000013" + }, + { + "height": 1110863, + "address": "QYH4R1XK3da1dNukR48thfrxitf9V6624c", + "assetId": 0, + "balance": "0.00000014" + }, + { + "height": 1110864, + "address": "QMJMPKMWHRcb9AQU5YcA8o37jWb9JNwm6b", + "assetId": 0, + "balance": "0.00000014" + }, + { + "height": 1110865, + "address": "QgaMWsHXbvh5MgP6gd3X6LhWShEKBynZZ6", + "assetId": 0, + "balance": "0.00000009" + }, + { + "height": 1110865, + "address": "QWUxkfcwAbnCRHSvaS7hMTzxZPhZhg5H5H", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1110866, + "address": "QQDDKRZTquVFpy3ru5Qr8DQu16HoqqBmq3", + "assetId": 0, + "balance": "0.00000006" + }, + { + "height": 1110869, + "address": "QWZCZ11TsRLakgTiHJXtZ5D9R1ZRYGSpzL", + "assetId": 0, + "balance": "0.00000005" + }, + { + "height": 1110873, + "address": "QcJWgS26gkD8Vsu54L71qXxw5RciQUMKR6", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1111325, + "address": "QNqdw3qDXarffzmat1ExknbUGnsrTZhyZT", + "assetId": 0, + "balance": "0.00000014" + }, + { + "height": 1114548, + "address": "QMRNYrFuZbngAtEpUBz6bNdrPXgvCPt7u1", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1114550, + "address": "QWaqayWDuRZggfeYSWGVhyKN3VBAeJKn8Z", + "assetId": 0, + "balance": "0.00000006" + }, + { + "height": 1114572, + "address": "QTyQ68x5nAFNRCCpQkbzu74icUbv62pmQ2", + "assetId": 0, + "balance": "0.00000012" + }, + { + "height": 1114574, + "address": "QQc51KWkb7MAfVLuaycngu3thC9cKPpNHq", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1114576, + "address": "QcUoTy8Y2Ts84nGzCx9GBBT5wRKFRhVejg", + "assetId": 0, + "balance": "0.00000011" + }, + { + "height": 1114579, + "address": "QgUdhDRrizos8c6smL2F9wBRYTyDP2wZ4j", + "assetId": 0, + "balance": "0.00000006" + }, + { + "height": 1114583, + "address": "QWE7PSKwwjNPPBhYqTFV1ks6XJvN2SVKNX", + "assetId": 0, + "balance": "0.00000005" + }, + { + "height": 1114616, + "address": "QQ9qZ8rVkk3tuhmfZLq1EFJF2YGsa6GwXS", + "assetId": 0, + "balance": "0.00000011" + }, + { + "height": 1114621, + "address": "QhHcCGoUj4ES9b87A1D3XHKXS45XFhSv2c", + "assetId": 0, + "balance": "0.00000008" + }, + { + "height": 1114637, + "address": "QUa7jcr4NVgEa6MwDTAyFYsnzagkzDRekT", + "assetId": 0, + "balance": "0.00000008" + }, + { + "height": 1114644, + "address": "QRnUKpupXMpqZTUUnj3Rc2E9ZvE4Vok7rv", + "assetId": 0, + "balance": "0.00000004" + }, + { + "height": 1114655, + "address": "QQnbvYnzcWNayDzKLDKkcTip38GL5rcCJP", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1114667, + "address": "QdsaaMF9WGxv5Cz2wbAbsmAZEwT62aUbRy", + "assetId": 0, + "balance": "0.00000010" + }, + { + "height": 1114671, + "address": "QStx2CfRpNqT1z7zaYVeCKhXV2DD7ayBi3", + "assetId": 0, + "balance": "0.00000002" + }, + { + "height": 1114672, + "address": "QWgFkdW9WhhoY5Jz5BVX2iCnCuh4uNy1gJ", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1114679, + "address": "QUUNskQ7q1bShGwFEk5FRLWkj6ma2HJonh", + "assetId": 0, + "balance": "0.00000003" + }, + { + "height": 1114710, + "address": "QitGikYSxEZWgt4kA4YJ1rjhbdMjSxjFPJ", + "assetId": 0, + "balance": "0.00000013" + }, + { + "height": 1114715, + "address": "QTQcpbByyrunj7Vuqw9T387A7axUKfqFwQ", + "assetId": 0, + "balance": "0.00000011" + }, + { + "height": 1114737, + "address": "QVxqLTGTc5nXzScmm3z1X9u1MEG8GLv8He", + "assetId": 0, + "balance": "0.00000014" + }, + { + "height": 1114761, + "address": "Qc8rr1NyusWtxL85xmFkPDZ9PSA42bT4e9", + "assetId": 0, + "balance": "0.00000005" + }, + { + "height": 1114776, + "address": "QNqE8mmjqAa9PPWhvdKZzHJcTL1TFkYyLw", + "assetId": 0, + "balance": "0.00000010" + }, + { + "height": 1125772, + "address": "Qfh2kibCYZNhboShiPWbfKhFzXvGDTU6uW", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1125837, + "address": "QgNT5SQYY3q9s616L1h1zvTs8Zuxs8jr5r", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1126550, + "address": "QhMfDd6Gp3LyLsWorWtBux8haKkAmUvY7C", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1126569, + "address": "QVLFkcnkWp9hQuavCePEPkwdhoTcbYLsed", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1126743, + "address": "Qb3iWozB7yD9Sk8ysWuVPqcKy1zifDMC2P", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1126743, + "address": "QfSnrNrje3pCzEnkXX4ZJryYL4L8xVkTVN", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1127953, + "address": "QhcgFCF29GpYUSuhnTgs7x4JKQLaN2BvaJ", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1127954, + "address": "QQGqqS9pUCGp34UFSi36HdvixVwT9Q3MnX", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129197, + "address": "QiPYjnivrM6fozrXZe5LzTeZoyRFZrSMEp", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129198, + "address": "Qj1AXCpi64Ub81JS5gAwnnmPJBQMSrnr8B", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129256, + "address": "QWzDHGW5Bc1Fodsvkm2cDeYU8skNUySStG", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129805, + "address": "QMhsHPG5Vxre3yhVvN4ueXdvwn1VvLQb1S", + "assetId": 0, + "balance": "0.00000014" + }, + { + "height": 1129805, + "address": "QTqtW3aaAq1XgVxYAbGRJ7ZXLQrNvTcTVj", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129830, + "address": "QWjCsHTAN7L4Wssd6FwewWCZiQynsvk2vg", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129831, + "address": "QPychdwPnu13tLFgyGWX5s7WJuPMsttDds", + "assetId": 0, + "balance": "0.00000014" + }, + { + "height": 1129853, + "address": "QQ9iKeSM5qV9EAGWuUzVoGAxpoWiXAVhX5", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129853, + "address": "QbQprKpPbXLbPMyB8G4RFsRYaAEfcND3Kk", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129895, + "address": "QUMHsX7aDa5qhptZ9Hgkngdq7Saw9Yz7d1", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129895, + "address": "QXcp3TAj46n9RWQte4grdKDqfShQAhGw99", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1129949, + "address": "QWf88E1QDq28gemLwuD9S2Cq58nVbNjy2n", + "assetId": 0, + "balance": "0.00000014" + }, + { + "height": 1130069, + "address": "QZUWwTMs1Gf4CgZ6wxXT4vx85eyb8pgRVN", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1130157, + "address": "Qdk9ewE2hUK9kvAonjGY5eTrVu4ngTqHS3", + "assetId": 0, + "balance": "0.00000014" + }, + { + "height": 1130290, + "address": "QVH7cLhAKZXWWri3WQyfnYJQVXF2hZmCXL", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1130443, + "address": "QjmX8Xv4EYA49ZS8cFMzSUgt9rn5aE2VA3", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1142220, + "address": "QXYfzEQHZ2aKujLWQfvnWG8akwrho7uXah", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1142226, + "address": "QZ9K4tDJk6uR8DNvfu5CUKydWeRTWAmksW", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1142226, + "address": "QZfPW8XacLXTtb1Gtms2bLG2AkVfnjxwwh", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1142231, + "address": "Qe8UatrFZXsrW3EBrgKsk8c7pggAJF3DN2", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1142231, + "address": "QfYQRt9jg3kvLvL8EJnar3U6ZHXtZoreiu", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1152382, + "address": "QW4no1vcyb4rSjXVj78ZjkSUtPiZkU65cB", + "assetId": 0, + "balance": "0.00000015" + }, + { + "height": 1186576, + "address": "QSPYfB4tpxoeiBc2L86Tmb5c3byKF5W5V9", + "assetId": 0, + "balance": "0.00000015" + } +] diff --git a/src/main/resources/loading/index.html b/src/main/resources/loading/index.html index 574645cc..24ff2ae3 100644 --- a/src/main/resources/loading/index.html +++ b/src/main/resources/loading/index.html @@ -20,17 +20,21 @@ width: 100%; text-align: center; z-index: 1000; - top: 45%; + top: 50%; -ms-transform: translateY(-50%); - transform: translateY(-50%); + transform: translate(-50% , -50%); + left: 50%; } #panel { text-align: center; background: white; + word-wrap: break-word; width: 350px; + max-width: 100%; margin: auto; padding: 25px; border-radius: 30px; + box-sizing: border-box; } #status { color: #03a9f4; diff --git a/src/main/resources/q-apps/q-apps-gateway.js b/src/main/resources/q-apps/q-apps-gateway.js index d5028dca..f54c320e 100644 --- a/src/main/resources/q-apps/q-apps-gateway.js +++ b/src/main/resources/q-apps/q-apps-gateway.js @@ -1,5 +1,57 @@ console.log("Gateway mode"); +function sendRequestToExtension( + requestType, + payload, + timeout = 750 +) { + return new Promise((resolve, reject) => { + const requestId = Math.random().toString(36).substring(2, 15); // Generate a unique ID for the request + const detail = { + type: requestType, + payload, + requestId, + timeout: timeout / 1000, + }; + + // Store the timeout ID so it can be cleared later + const timeoutId = setTimeout(() => { + document.removeEventListener("qortalExtensionResponses", handleResponse); + reject(new Error("Request timed out")); + }, timeout); // Adjust timeout as necessary + + function handleResponse(event) { + const { requestId: responseId, data } = event.detail; + if (requestId === responseId) { + // Match the response with the request + document.removeEventListener("qortalExtensionResponses", handleResponse); + clearTimeout(timeoutId); // Clear the timeout upon successful response + resolve(data); + } + } + + document.addEventListener("qortalExtensionResponses", handleResponse); + document.dispatchEvent( + new CustomEvent("qortalExtensionRequests", { detail }) + ); + }); +} + + const isExtensionInstalledFunc = async () => { + try { + const response = await sendRequestToExtension( + "REQUEST_IS_INSTALLED", + {}, + 750 + ); + return response; + } catch (error) { + // not installed + } +}; + + + function qdnGatewayShowModal(message) { const modalElementId = "qdnGatewayModal"; @@ -32,7 +84,7 @@ function qdnGatewayShowModal(message) { document.body.appendChild(modalElement); } -window.addEventListener("message", (event) => { +window.addEventListener("message", async (event) => { if (event == null || event.data == null || event.data.length == 0) { return; } @@ -43,7 +95,7 @@ window.addEventListener("message", (event) => { // Gateway mode only cares about requests that were intended for the UI return; } - + let response; let data = event.data; @@ -59,6 +111,8 @@ window.addEventListener("message", (event) => { case "GET_LIST_ITEMS": case "ADD_LIST_ITEMS": case "DELETE_LIST_ITEM": + const isExtInstalledRes = await isExtensionInstalledFunc() + if(isExtInstalledRes?.version) return; 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 + "\"}" diff --git a/src/main/resources/q-apps/q-apps.js b/src/main/resources/q-apps/q-apps.js index d586e2e2..d7222750 100644 --- a/src/main/resources/q-apps/q-apps.js +++ b/src/main/resources/q-apps/q-apps.js @@ -1,3 +1,119 @@ +let customQDNHistoryPaths = []; // Array to track visited paths +let currentIndex = -1; // Index to track the current position in the history +let isManualNavigation = true; // Flag to control when to add new paths. set to false when navigating through a back/forward call + + +function resetVariables(){ +let customQDNHistoryPaths = []; +let currentIndex = -1; +let isManualNavigation = true; +} + +function getNameAfterService(url) { + try { + const parsedUrl = new URL(url); + const pathParts = parsedUrl.pathname.split('/'); + + // Find the index of "WEBSITE" or "APP" and get the next part + const serviceIndex = pathParts.findIndex(part => part === 'WEBSITE' || part === 'APP'); + + if (serviceIndex !== -1 && pathParts[serviceIndex + 1]) { + return pathParts[serviceIndex + 1]; + } else { + return null; // Return null if "WEBSITE" or "APP" is not found or has no following part + } + } catch (error) { + console.error("Invalid URL provided:", error); + return null; + } +} + + + +function parseUrl(url) { + try { + const parsedUrl = new URL(url); + + // Check if isManualNavigation query exists and is set to "false" + const isManual = parsedUrl.searchParams.get("isManualNavigation"); + + if (isManual !== null && isManual == "false") { + isManualNavigation = false + // Optional: handle this condition if needed (e.g., return or adjust the response) + } + + + // Remove theme, identifier, and time queries if they exist + parsedUrl.searchParams.delete("theme"); + parsedUrl.searchParams.delete("identifier"); + parsedUrl.searchParams.delete("time"); + parsedUrl.searchParams.delete("isManualNavigation"); + // Extract the pathname and remove the prefix if it matches "render/APP" or "render/WEBSITE" + const path = parsedUrl.pathname.replace(/^\/render\/(APP|WEBSITE)\/[^/]+/, ""); + + // Combine the path with remaining query params (if any) + return path + parsedUrl.search; + } catch (error) { + console.error("Invalid URL provided:", error); + return null; + } +} + +// Tell the client to open a new tab. Done when an app is linking to another app +function openNewTab(data){ +window.parent.postMessage({ +action: 'SET_TAB', +requestedHandler:'UI', +payload: data +}, '*'); +} +// sends navigation information to the client in order to manage back/forward navigation +function sendNavigationInfoToParent(isDOMContentLoaded){ +window.parent.postMessage({ +action: 'NAVIGATION_HISTORY', +requestedHandler:'UI', +payload: { +customQDNHistoryPaths, +currentIndex, +isDOMContentLoaded: isDOMContentLoaded ? true : false +} +}, '*'); + +} + + +function handleQDNResourceDisplayed(pathurl, isDOMContentLoaded) { +// make sure that an empty string the root path +if(pathurl?.startsWith('/render/hash/')) return; +const path = pathurl || '/' + if (!isManualNavigation) { + isManualNavigation = true + // If the navigation is automatic (back/forward), do not add new entries + return; + } + + + // If it's a new path, add it to the history array and adjust the index + if (customQDNHistoryPaths[currentIndex] !== path) { + + customQDNHistoryPaths = customQDNHistoryPaths.slice(0, currentIndex + 1); + + + + // Add the new path and move the index to the new position + customQDNHistoryPaths.push(path); + currentIndex = customQDNHistoryPaths.length - 1; + sendNavigationInfoToParent(isDOMContentLoaded) + } else { + currentIndex = customQDNHistoryPaths.length - 1 + sendNavigationInfoToParent(isDOMContentLoaded) + } + + + // Reset isManualNavigation after handling + isManualNavigation = true; +} + function httpGet(url) { var request = new XMLHttpRequest(); request.open("GET", url, false); @@ -156,7 +272,7 @@ function convertToResourceUrl(url, isLink) { return buildResourceUrl(c.service, c.name, c.identifier, c.path, isLink); } -window.addEventListener("message", (event) => { +window.addEventListener("message", async (event) => { if (event == null || event.data == null || event.data.length == 0) { return; } @@ -169,11 +285,9 @@ window.addEventListener("message", (event) => { return; } - console.log("Core received action: " + JSON.stringify(event.data.action)); - let url; let data = event.data; - + let identifier; switch (data.action) { case "GET_ACCOUNT_DATA": return httpGetAsyncWithEvent(event, "/addresses/" + data.address); @@ -199,10 +313,51 @@ window.addEventListener("message", (event) => { 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 - window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path, true); - return; + case "LINK_TO_QDN_RESOURCE": + if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE + + const nameOfCurrentApp = getNameAfterService(window.location.href); + // Check to see if the link is an external app. If it is, request that the client opens a new tab instead of manipulating the window's history stack. + if (nameOfCurrentApp !== data.name) { + // Attempt to open a new tab and wait for a response + const navigationPromise = new Promise((resolve, reject) => { + function handleMessage(event) { + if (event.data?.action === 'SET_TAB_SUCCESS' && event.data.payload?.name === data.name) { + window.removeEventListener('message', handleMessage); + resolve(); + } + } + + window.addEventListener('message', handleMessage); + + // Send the message to the parent window + openNewTab({ + name: data.name, + service: data.service, + identifier: data.identifier, + path: data.path + }); + + // Set a timeout to reject the promise if no response is received within 200ms + setTimeout(() => { + window.removeEventListener('message', handleMessage); + reject(new Error("No response within 200ms")); + }, 200); + }); + + // Handle the promise, and if it times out, fall back to the else block + navigationPromise + .then(() => { + console.log('Tab opened successfully'); + }) + .catch(() => { + console.warn('No response, proceeding with window.location'); + window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path, true); + }); + } else { + window.location = buildResourceUrl(data.service, data.name, data.identifier, data.path, true); + } + return; case "LIST_QDN_RESOURCES": url = "/arbitrary/resources?"; @@ -227,6 +382,7 @@ window.addEventListener("message", (event) => { 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.keywords != null) data.keywords.forEach((x, i) => url = url.concat("&keywords=" + x)); if (data.title != null) url = url.concat("&title=" + data.title); if (data.description != null) url = url.concat("&description=" + data.description); if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString()); @@ -263,7 +419,7 @@ window.addEventListener("message", (event) => { return httpGetAsyncWithEvent(event, url); case "GET_QDN_RESOURCE_PROPERTIES": - let identifier = (data.identifier != null) ? data.identifier : "default"; + identifier = (data.identifier != null) ? data.identifier : "default"; url = "/arbitrary/resource/properties/" + data.service + "/" + data.name + "/" + identifier; return httpGetAsyncWithEvent(event, url); @@ -300,7 +456,7 @@ window.addEventListener("message", (event) => { return httpGetAsyncWithEvent(event, url); case "GET_AT": - url = "/at" + data.atAddress; + url = "/at/" + data.atAddress; return httpGetAsyncWithEvent(event, url); case "GET_AT_DATA": @@ -317,7 +473,7 @@ window.addEventListener("message", (event) => { case "FETCH_BLOCK": if (data.signature != null) { - url = "/blocks/" + data.signature; + url = "/blocks/signature/" + data.signature; } else if (data.height != null) { url = "/blocks/byheight/" + data.height; } @@ -351,10 +507,18 @@ window.addEventListener("message", (event) => { if (data.inverse != null) url = url.concat("&inverse=" + data.inverse); return httpGetAsyncWithEvent(event, url); + + case "PERFORMING_NON_MANUAL": + isManualNavigation = false + currentIndex = data.currentIndex + return; + default: // Pass to parent (UI), in case they can fulfil this request event.data.requestedHandler = "UI"; parent.postMessage(event.data, '*', [event.ports[0]]); + + return; } @@ -450,6 +614,7 @@ function getDefaultTimeout(action) { switch (action) { case "GET_USER_ACCOUNT": case "SAVE_FILE": + case "SIGN_TRANSACTION": case "DECRYPT_DATA": // User may take a long time to accept/deny the popup return 60 * 60 * 1000; @@ -471,6 +636,11 @@ function getDefaultTimeout(action) { // Chat messages rely on PoW computations, so allow extra time return 60 * 1000; + case "CREATE_TRADE_BUY_ORDER": + case "CREATE_TRADE_SELL_ORDER": + case "CANCEL_TRADE_SELL_ORDER": + case "VOTE_ON_POLL": + case "CREATE_POLL": case "JOIN_GROUP": case "DEPLOY_AT": case "SEND_COIN": @@ -485,7 +655,7 @@ function getDefaultTimeout(action) { break; } } - return 10 * 1000; + return 30 * 1000; } /** @@ -523,7 +693,9 @@ const qortalRequestWithTimeout = (request, timeout) => /** * Send current page details to UI */ -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', (event) => { + +resetVariables() qortalRequest({ action: "QDN_RESOURCE_DISPLAYED", service: _qdnService, @@ -531,19 +703,32 @@ document.addEventListener('DOMContentLoaded', () => { identifier: _qdnIdentifier, path: _qdnPath }); + // send to the client the first path when the app loads. + const firstPath = parseUrl(window?.location?.href || "") + handleQDNResourceDisplayed(firstPath, true); + // Increment counter when page fully loads }); /** * Handle app navigation */ navigation.addEventListener('navigate', (event) => { + const url = new URL(event.destination.url); + let fullpath = url.pathname + url.hash; + const processedPath = (fullpath.startsWith(_qdnBase)) ? fullpath.slice(_qdnBase.length) : fullpath; qortalRequest({ action: "QDN_RESOURCE_DISPLAYED", service: _qdnService, name: _qdnName, identifier: _qdnIdentifier, - path: (fullpath.startsWith(_qdnBase)) ? fullpath.slice(_qdnBase.length) : fullpath + path: processedPath }); + + // Put a timeout so that the DOMContentLoaded listener's logic executes before the navigate listener + setTimeout(()=> { + handleQDNResourceDisplayed(processedPath); + }, 100) }); + diff --git a/src/main/resources/reticulum_default_testnet_config.yml b/src/main/resources/reticulum_default_testnet_config.yml new file mode 100644 index 00000000..18c7b3fb --- /dev/null +++ b/src/main/resources/reticulum_default_testnet_config.yml @@ -0,0 +1,93 @@ +--- +# You should probably edit it to include any additional, +# interfaces and settings you might need. + +# Only the most basic options are included in this default +# configuration. To see a more verbose, and much longer, +# configuration example, you can run the command: +# rnsd --exampleconfig + +reticulum: + + # If you enable Transport, your system will route traffic + # for other peers, pass announces and serve path requests. + # This should only be done for systems that are suited to + # act as transport nodes, ie. if they are stationary and + # always-on. This directive is optional and can be removed + # for brevity. + + enable_transport: false + + # By default, the first program to launch the Reticulum + # Network Stack will create a shared instance, that other + # programs can communicate with. Only the shared instance + # opens all the configured interfaces directly, and other + # local programs communicate with the shared instance over + # a local socket. This is completely transparent to the + # user, and should generally be turned on. This directive + # is optional and can be removed for brevity. + + share_instance: false + + # If you want to run multiple *different* shared instances + # on the same system, you will need to specify different + # shared instance ports for each. The defaults are given + # below, and again, these options can be left out if you + # don't need them. + + #shared_instance_port: 37428 + #instance_control_port: 37429 + shared_instance_port: 37438 + instance_control_port: 37439 + + # You can configure Reticulum to panic and forcibly close + # if an unrecoverable interface error occurs, such as the + # hardware device for an interface disappearing. This is + # an optional directive, and can be left out for brevity. + # This behaviour is disabled by default. + + panic_on_interface_error: false + + +# The interfaces section defines the physical and virtual +# interfaces Reticulum will use to communicate on. This +# section will contain examples for a variety of interface +# types. You can modify these or use them as a basis for +# your own config, or simply remove the unused ones. + +interfaces: + + # This interface enables communication with other + # link-local Reticulum nodes over UDP. It does not + # need any functional IP infrastructure like routers + # or DHCP servers, but will require that at least link- + # local IPv6 is enabled in your operating system, which + # should be enabled by default in almost any OS. See + # the Reticulum Manual for more configuration options. + #"Default Interface": + # type: AutoInterface + # enabled: true + + # This interface enables communication with a "backbone" + # server over TCP. + # Note: others may be added for redundancy + "TCP Client Interface mobilefabrik": + type: TCPClientInterface + enabled: true + target_host: phantom.mobilefabrik.com + target_port: 3434 + #network_name: qortal + + # This interface turns this Reticulum instance into a + # server other clients can connect to over TCP. + # To enable this instance to route traffic the above + # setting "enable_transport" needs to be set (to true). + # Note: this interface type is not yet supported by + # reticulum-network-stack. + #"TCP Server Interface": + # type: TCPServerInterface + # enabled: true + # listen_ip: 0.0.0.0 + # listen_port: 3434 + # #network_name: qortal + diff --git a/src/test/java/org/qortal/test/BlockArchiveV1Tests.java b/src/test/java/org/qortal/test/BlockArchiveV1Tests.java index a28bd28d..2cf8ef79 100644 --- a/src/test/java/org/qortal/test/BlockArchiveV1Tests.java +++ b/src/test/java/org/qortal/test/BlockArchiveV1Tests.java @@ -54,26 +54,39 @@ public class BlockArchiveV1Tests extends Common { public void testWriter() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testWriter"); + // Mint some blocks so that we are able to archive them later + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); repository.getATRepository().setAtTrimHeight(901); + System.out.println("Set trim heights to 901."); // Check the max archive height - this should be one less than the first untrimmed height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height (Expected 900): " + maximumArchiveHeight); assertEquals(900, maximumArchiveHeight); // Write blocks 2-900 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Archive contains " + writer.getWrittenCount() + " blocks. (Expected 899)"); assertEquals(900 - 1, writer.getWrittenCount()); // Increment block archive height @@ -84,6 +97,9 @@ public class BlockArchiveV1Tests extends Common { // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); + + System.out.println("testWriter completed successfully."); } } @@ -91,26 +107,39 @@ public class BlockArchiveV1Tests extends Common { public void testWriterAndReader() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testWriterAndReader"); + // Mint some blocks so that we are able to archive them later + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); repository.getATRepository().setAtTrimHeight(901); + System.out.println("Set trim heights to 901."); // Check the max archive height - this should be one less than the first untrimmed height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height (Expected 900): " + maximumArchiveHeight); assertEquals(900, maximumArchiveHeight); // Write blocks 2-900 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Archive contains " + writer.getWrittenCount() + " blocks. (Expected 899)"); assertEquals(900 - 1, writer.getWrittenCount()); // Increment block archive height @@ -121,8 +150,10 @@ public class BlockArchiveV1Tests extends Common { // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); // Read block 2 from the archive + System.out.println("Reading block 2 from the archive..."); BlockArchiveReader reader = BlockArchiveReader.getInstance(); BlockTransformation block2Info = reader.fetchBlockAtHeight(2); BlockData block2ArchiveData = block2Info.getBlockData(); @@ -131,6 +162,7 @@ public class BlockArchiveV1Tests extends Common { BlockData block2RepositoryData = repository.getBlockRepository().fromHeight(2); // Ensure the values match + System.out.println("Comparing block 2 data..."); assertEquals(block2ArchiveData.getHeight(), block2RepositoryData.getHeight()); assertArrayEquals(block2ArchiveData.getSignature(), block2RepositoryData.getSignature()); @@ -138,6 +170,7 @@ public class BlockArchiveV1Tests extends Common { assertEquals(1, block2ArchiveData.getOnlineAccountsCount()); // Read block 900 from the archive + System.out.println("Reading block 900 from the archive..."); BlockTransformation block900Info = reader.fetchBlockAtHeight(900); BlockData block900ArchiveData = block900Info.getBlockData(); @@ -145,12 +178,14 @@ public class BlockArchiveV1Tests extends Common { BlockData block900RepositoryData = repository.getBlockRepository().fromHeight(900); // Ensure the values match + System.out.println("Comparing block 900 data..."); assertEquals(block900ArchiveData.getHeight(), block900RepositoryData.getHeight()); assertArrayEquals(block900ArchiveData.getSignature(), block900RepositoryData.getSignature()); // Test some values in the archive assertEquals(1, block900ArchiveData.getOnlineAccountsCount()); + System.out.println("testWriterAndReader completed successfully."); } } @@ -158,33 +193,48 @@ public class BlockArchiveV1Tests extends Common { public void testArchivedAtStates() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testArchivedAtStates"); + // Deploy an AT so that we have AT state data + System.out.println("Deploying AT..."); 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(); + System.out.println("AT deployed at address: " + atAddress); // Mint some blocks so that we are able to archive them later + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // 9 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(10); repository.getATRepository().setAtTrimHeight(10); + System.out.println("Set trim heights to 10."); // Check the max archive height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height (Expected 9): " + maximumArchiveHeight); assertEquals(9, maximumArchiveHeight); // Write blocks 2-9 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Archive contains " + writer.getWrittenCount() + " blocks. (Expected 8)"); assertEquals(9 - 1, writer.getWrittenCount()); // Increment block archive height @@ -195,10 +245,13 @@ public class BlockArchiveV1Tests extends Common { // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); // Check blocks 3-9 + System.out.println("Checking blocks 3 to 9..."); for (Integer testHeight = 2; testHeight <= 9; testHeight++) { + System.out.println("Reading block " + testHeight + " from the archive..."); // Read a block from the archive BlockArchiveReader reader = BlockArchiveReader.getInstance(); BlockTransformation blockInfo = reader.fetchBlockAtHeight(testHeight); @@ -216,6 +269,7 @@ public class BlockArchiveV1Tests extends Common { // Check the archived AT state if (testHeight == 2) { + System.out.println("Checking block " + testHeight + " AT state data (expected null)..."); // Block 2 won't have an AT state hash because it's initial (and has the DEPLOY_AT in the same block) assertNull(archivedAtStateData); @@ -223,6 +277,7 @@ public class BlockArchiveV1Tests extends Common { assertEquals(Transaction.TransactionType.DEPLOY_AT, archivedTransactions.get(0).getType()); } else { + System.out.println("Checking block " + testHeight + " AT state data..."); // For blocks 3+, ensure the archive has the AT state data, but not the hashes assertNotNull(archivedAtStateData.getStateHash()); assertNull(archivedAtStateData.getStateData()); @@ -255,10 +310,12 @@ public class BlockArchiveV1Tests extends Common { } // Check block 10 (unarchived) + System.out.println("Checking block 10 (should not be in archive)..."); BlockArchiveReader reader = BlockArchiveReader.getInstance(); BlockTransformation blockInfo = reader.fetchBlockAtHeight(10); assertNull(blockInfo); + System.out.println("testArchivedAtStates completed successfully."); } } @@ -267,32 +324,46 @@ public class BlockArchiveV1Tests extends Common { public void testArchiveAndPrune() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testArchiveAndPrune"); + // Deploy an AT so that we have AT state data + System.out.println("Deploying AT..."); 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 + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // Assume 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); repository.getATRepository().setAtTrimHeight(901); + System.out.println("Set trim heights to 901."); // Check the max archive height - this should be one less than the first untrimmed height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height (Expected 900): " + maximumArchiveHeight); assertEquals(900, maximumArchiveHeight); // Write blocks 2-900 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Archive contains " + writer.getWrittenCount() + " blocks. (Expected 899)"); assertEquals(900 - 1, writer.getWrittenCount()); // Increment block archive height @@ -303,17 +374,21 @@ public class BlockArchiveV1Tests extends Common { // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); // Ensure the SQL repository contains blocks 2 and 900... assertNotNull(repository.getBlockRepository().fromHeight(2)); assertNotNull(repository.getBlockRepository().fromHeight(900)); + System.out.println("Blocks 2 and 900 exist in the repository."); // Prune all the archived blocks + System.out.println("Pruning blocks 2 to 900..."); int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 900); assertEquals(900-1, numBlocksPruned); repository.getBlockRepository().setBlockPruneHeight(901); // Prune the AT states for the archived blocks + System.out.println("Pruning AT states up to height 900..."); repository.getATRepository().rebuildLatestAtStates(900); repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 900); @@ -323,14 +398,19 @@ public class BlockArchiveV1Tests extends Common { // Now ensure the SQL repository is missing blocks 2 and 900... assertNull(repository.getBlockRepository().fromHeight(2)); assertNull(repository.getBlockRepository().fromHeight(900)); + System.out.println("Blocks 2 and 900 have been pruned from the repository."); // ... 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)); + System.out.println("Blocks 1 and 901 still exist in the repository."); // Validate the latest block height in the repository - assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + int lastBlockHeight = repository.getBlockRepository().getLastBlock().getHeight(); + System.out.println("Latest block height in repository (Expected 1002): " + lastBlockHeight); + assertEquals(1002, lastBlockHeight); + System.out.println("testArchiveAndPrune completed successfully."); } } @@ -338,137 +418,190 @@ public class BlockArchiveV1Tests extends Common { public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testTrimArchivePruneAndOrphan"); + // Deploy an AT so that we have AT state data + System.out.println("Deploying AT..."); PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); byte[] creationBytes = AtUtils.buildSimpleAT(); long fundingAmount = 1_00000000L; AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + System.out.println("AT deployed successfully."); // Mint some blocks so that we are able to archive them later + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // Make sure that block 500 has full AT state data and data hash + System.out.println("Verifying block 500 AT state data..."); List block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); assertNotNull(atStatesData.getStateHash()); assertNotNull(atStatesData.getStateData()); + System.out.println("Block 500 AT state data verified."); // Trim the first 500 blocks + System.out.println("Trimming 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); + System.out.println("Trimming completed."); // Now block 499 should only have the AT state data hash + System.out.println("Checking block 499 AT state data..."); List block499AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(499); atStatesData = repository.getATRepository().getATStateAtHeight(block499AtStatesData.get(0).getATAddress(), 499); assertNotNull(atStatesData.getStateHash()); assertNull(atStatesData.getStateData()); + System.out.println("Block 499 AT state data contains only state hash as expected."); // ... but block 500 should have the full data (due to being retained as the "latest" AT state in the trimmed range + System.out.println("Verifying block 500 AT state data again..."); block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); assertNotNull(atStatesData.getStateHash()); assertNotNull(atStatesData.getStateData()); + System.out.println("Block 500 AT state data contains full data."); // ... and block 501 should also have the full data + System.out.println("Verifying block 501 AT state data..."); List block501AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(501); atStatesData = repository.getATRepository().getATStateAtHeight(block501AtStatesData.get(0).getATAddress(), 501); assertNotNull(atStatesData.getStateHash()); assertNotNull(atStatesData.getStateData()); + System.out.println("Block 501 AT state data contains full data."); // Check the max archive height - this should be one less than the first untrimmed height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height determined (Expected 500): " + maximumArchiveHeight); assertEquals(500, maximumArchiveHeight); BlockData block3DataPreArchive = repository.getBlockRepository().fromHeight(3); // Write blocks 2-500 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Number of blocks written to archive (Expected 499): " + writer.getWrittenCount()); 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()); + System.out.println("Block archive height updated to: " + (500 - 1)); // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); // Ensure the SQL repository contains blocks 2 and 500... + System.out.println("Verifying that blocks 2 and 500 exist in the repository..."); assertNotNull(repository.getBlockRepository().fromHeight(2)); assertNotNull(repository.getBlockRepository().fromHeight(500)); + System.out.println("Blocks 2 and 500 are present in the repository."); // Prune all the archived blocks + System.out.println("Pruning blocks 2 to 500..."); int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 500); + System.out.println("Number of blocks pruned (Expected 499): " + numBlocksPruned); assertEquals(500-1, numBlocksPruned); repository.getBlockRepository().setBlockPruneHeight(501); // Prune the AT states for the archived blocks + System.out.println("Pruning AT states up to height 500..."); repository.getATRepository().rebuildLatestAtStates(500); repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(2, 500); + System.out.println("Number of AT states pruned (Expected 498): " + 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... + System.out.println("Verifying that blocks 2 and 500 have been pruned..."); assertNull(repository.getBlockRepository().fromHeight(2)); assertNull(repository.getBlockRepository().fromHeight(500)); + System.out.println("Blocks 2 and 500 have been successfully pruned."); // ... but it's not missing blocks 1 and 501 (we don't prune the genesis block) + System.out.println("Verifying that blocks 1 and 501 still exist..."); assertNotNull(repository.getBlockRepository().fromHeight(1)); assertNotNull(repository.getBlockRepository().fromHeight(501)); + System.out.println("Blocks 1 and 501 are present in the repository."); // Validate the latest block height in the repository - assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + int lastBlockHeight = repository.getBlockRepository().getLastBlock().getHeight(); + System.out.println("Latest block height in repository (Expected 1002): " + lastBlockHeight); + assertEquals(1002, lastBlockHeight); // Now orphan some unarchived blocks. + System.out.println("Orphaning 500 blocks..."); BlockUtils.orphanBlocks(repository, 500); - assertEquals(502, (int) repository.getBlockRepository().getLastBlock().getHeight()); + int currentLastBlockHeight = repository.getBlockRepository().getLastBlock().getHeight(); + System.out.println("New last block height after orphaning (Expected 502): " + currentLastBlockHeight); + assertEquals(502, currentLastBlockHeight); // We're close to the lower limit of the SQL database now, so // we need to import some blocks from the archive + System.out.println("Importing blocks 401 to 500 from the archive..."); BlockArchiveUtils.importFromArchive(401, 500, repository); // Ensure the SQL repository now contains block 401 but not 400... + System.out.println("Verifying that block 401 exists and block 400 does not..."); assertNotNull(repository.getBlockRepository().fromHeight(401)); assertNull(repository.getBlockRepository().fromHeight(400)); + System.out.println("Block 401 exists, block 400 does not."); // Import the remaining 399 blocks + System.out.println("Importing blocks 2 to 400 from the archive..."); BlockArchiveUtils.importFromArchive(2, 400, repository); // Verify that block 3 matches the original + System.out.println("Verifying that block 3 matches the original data..."); BlockData block3DataPostArchive = repository.getBlockRepository().fromHeight(3); assertArrayEquals(block3DataPreArchive.getSignature(), block3DataPostArchive.getSignature()); assertEquals(block3DataPreArchive.getHeight(), block3DataPostArchive.getHeight()); + System.out.println("Block 3 data matches the original."); // Orphan 1 more block, which should be the last one that is possible to be orphaned + System.out.println("Orphaning 1 more block..."); BlockUtils.orphanBlocks(repository, 1); + System.out.println("Orphaned 1 block successfully."); // Orphan another block, which should fail + System.out.println("Attempting to orphan another block, which should fail..."); Exception exception = null; try { BlockUtils.orphanBlocks(repository, 1); } catch (DataException e) { exception = e; + System.out.println("Caught expected DataException: " + e.getMessage()); } // Ensure that a DataException is thrown because there is no more AT states data available assertNotNull(exception); assertEquals(DataException.class, exception.getClass()); + System.out.println("DataException confirmed due to lack of AT states data."); // 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. + System.out.println("testTrimArchivePruneAndOrphan completed successfully."); } } @@ -482,16 +615,26 @@ public class BlockArchiveV1Tests extends Common { public void testMissingAtStatesHeightIndex() throws DataException, SQLException { try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + System.out.println("Starting testMissingAtStatesHeightIndex"); + // Firstly check that we're able to prune or archive when the index exists + System.out.println("Checking existence of ATStatesHeightIndex..."); assertTrue(repository.getATRepository().hasAtStatesHeightIndex()); assertTrue(RepositoryManager.canArchiveOrPrune()); + System.out.println("ATStatesHeightIndex exists. Archiving and pruning are possible."); // Delete the index + System.out.println("Dropping ATStatesHeightIndex..."); repository.prepareStatement("DROP INDEX ATSTATESHEIGHTINDEX").execute(); + System.out.println("ATStatesHeightIndex dropped."); // Ensure check that we're unable to prune or archive when the index doesn't exist + System.out.println("Verifying that ATStatesHeightIndex no longer exists..."); assertFalse(repository.getATRepository().hasAtStatesHeightIndex()); assertFalse(RepositoryManager.canArchiveOrPrune()); + System.out.println("ATStatesHeightIndex does not exist. Archiving and pruning are disabled."); + + System.out.println("testMissingAtStatesHeightIndex completed successfully."); } } @@ -501,8 +644,10 @@ public class BlockArchiveV1Tests extends Common { Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); try { FileUtils.deleteDirectory(archivePath.toFile()); + System.out.println("Deleted archive directory at: " + archivePath); } catch (IOException e) { - + + System.out.println("Failed to delete archive directory: " + e.getMessage()); } } diff --git a/src/test/java/org/qortal/test/BlockArchiveV2Tests.java b/src/test/java/org/qortal/test/BlockArchiveV2Tests.java index 3b1d12d3..8ab02b40 100644 --- a/src/test/java/org/qortal/test/BlockArchiveV2Tests.java +++ b/src/test/java/org/qortal/test/BlockArchiveV2Tests.java @@ -54,26 +54,39 @@ public class BlockArchiveV2Tests extends Common { public void testWriter() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testWriter"); + // Mint some blocks so that we are able to archive them later + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); repository.getATRepository().setAtTrimHeight(901); + System.out.println("Set trim heights to 901."); // Check the max archive height - this should be one less than the first untrimmed height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height (Expected 900): " + maximumArchiveHeight); assertEquals(900, maximumArchiveHeight); // Write blocks 2-900 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Archive contains " + writer.getWrittenCount() + " blocks. (Expected 899)"); assertEquals(900 - 1, writer.getWrittenCount()); // Increment block archive height @@ -84,6 +97,9 @@ public class BlockArchiveV2Tests extends Common { // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); + + System.out.println("testWriter completed successfully."); } } @@ -91,26 +107,39 @@ public class BlockArchiveV2Tests extends Common { public void testWriterAndReader() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testWriterAndReader"); + // Mint some blocks so that we are able to archive them later + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); repository.getATRepository().setAtTrimHeight(901); + System.out.println("Set trim heights to 901."); // Check the max archive height - this should be one less than the first untrimmed height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height (Expected 900): " + maximumArchiveHeight); assertEquals(900, maximumArchiveHeight); // Write blocks 2-900 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Archive contains " + writer.getWrittenCount() + " blocks. (Expected 899)"); assertEquals(900 - 1, writer.getWrittenCount()); // Increment block archive height @@ -121,8 +150,10 @@ public class BlockArchiveV2Tests extends Common { // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); // Read block 2 from the archive + System.out.println("Reading block 2 from the archive..."); BlockArchiveReader reader = BlockArchiveReader.getInstance(); BlockTransformation block2Info = reader.fetchBlockAtHeight(2); BlockData block2ArchiveData = block2Info.getBlockData(); @@ -131,6 +162,7 @@ public class BlockArchiveV2Tests extends Common { BlockData block2RepositoryData = repository.getBlockRepository().fromHeight(2); // Ensure the values match + System.out.println("Comparing block 2 data..."); assertEquals(block2ArchiveData.getHeight(), block2RepositoryData.getHeight()); assertArrayEquals(block2ArchiveData.getSignature(), block2RepositoryData.getSignature()); @@ -138,6 +170,7 @@ public class BlockArchiveV2Tests extends Common { assertEquals(1, block2ArchiveData.getOnlineAccountsCount()); // Read block 900 from the archive + System.out.println("Reading block 900 from the archive..."); BlockTransformation block900Info = reader.fetchBlockAtHeight(900); BlockData block900ArchiveData = block900Info.getBlockData(); @@ -145,12 +178,14 @@ public class BlockArchiveV2Tests extends Common { BlockData block900RepositoryData = repository.getBlockRepository().fromHeight(900); // Ensure the values match + System.out.println("Comparing block 900 data..."); assertEquals(block900ArchiveData.getHeight(), block900RepositoryData.getHeight()); assertArrayEquals(block900ArchiveData.getSignature(), block900RepositoryData.getSignature()); // Test some values in the archive assertEquals(1, block900ArchiveData.getOnlineAccountsCount()); + System.out.println("testWriterAndReader completed successfully."); } } @@ -158,47 +193,66 @@ public class BlockArchiveV2Tests extends Common { public void testArchivedAtStates() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testArchivedAtStates"); + // Deploy an AT so that we have AT state data + System.out.println("Deploying AT..."); 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(); + System.out.println("AT deployed at address: " + atAddress); // Mint some blocks so that we are able to archive them later + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // 9 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(10); repository.getATRepository().setAtTrimHeight(10); + System.out.println("Set trim heights to 10."); // Check the max archive height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height (Expected 9): " + maximumArchiveHeight); assertEquals(9, maximumArchiveHeight); // Write blocks 2-9 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Archive contains " + writer.getWrittenCount() + " blocks. (Expected 8)"); assertEquals(9 - 1, writer.getWrittenCount()); // Increment block archive height repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); repository.saveChanges(); assertEquals(9 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + System.out.println("Block archive height updated to: " + (9 - 1)); // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); // Check blocks 3-9 + System.out.println("Checking blocks 2 to 9..."); for (Integer testHeight = 2; testHeight <= 9; testHeight++) { + System.out.println("Reading block " + testHeight + " from the archive..."); // Read a block from the archive BlockArchiveReader reader = BlockArchiveReader.getInstance(); BlockTransformation blockInfo = reader.fetchBlockAtHeight(testHeight); @@ -216,15 +270,18 @@ public class BlockArchiveV2Tests extends Common { // Check the archived AT state if (testHeight == 2) { + System.out.println("Checking block " + testHeight + " AT state data (expected transactions)..."); assertEquals(1, archivedTransactions.size()); assertEquals(Transaction.TransactionType.DEPLOY_AT, archivedTransactions.get(0).getType()); } else { + System.out.println("Checking block " + testHeight + " AT state data (no transactions expected)..."); // Blocks 3+ shouldn't have any transactions assertTrue(archivedTransactions.isEmpty()); } // Ensure the archive has the AT states hash + System.out.println("Checking block " + testHeight + " AT states hash..."); assertNotNull(archivedAtStateHash); // Also check the online accounts count and height @@ -232,6 +289,7 @@ public class BlockArchiveV2Tests extends Common { assertEquals(testHeight, archivedBlockData.getHeight()); // Ensure the values match + System.out.println("Comparing block " + testHeight + " data..."); assertEquals(archivedBlockData.getHeight(), repositoryBlockData.getHeight()); assertArrayEquals(archivedBlockData.getSignature(), repositoryBlockData.getSignature()); assertEquals(archivedBlockData.getOnlineAccountsCount(), repositoryBlockData.getOnlineAccountsCount()); @@ -249,10 +307,12 @@ public class BlockArchiveV2Tests extends Common { } // Check block 10 (unarchived) + System.out.println("Checking block 10 (should not be in archive)..."); BlockArchiveReader reader = BlockArchiveReader.getInstance(); BlockTransformation blockInfo = reader.fetchBlockAtHeight(10); assertNull(blockInfo); + System.out.println("testArchivedAtStates completed successfully."); } } @@ -261,32 +321,47 @@ public class BlockArchiveV2Tests extends Common { public void testArchiveAndPrune() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testArchiveAndPrune"); + // Deploy an AT so that we have AT state data + System.out.println("Deploying AT..."); PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); byte[] creationBytes = AtUtils.buildSimpleAT(); long fundingAmount = 1_00000000L; AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + System.out.println("AT deployed successfully."); // Mint some blocks so that we are able to archive them later + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // Assume 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); repository.getATRepository().setAtTrimHeight(901); + System.out.println("Set trim heights to 901."); // Check the max archive height - this should be one less than the first untrimmed height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height (Expected 900): " + maximumArchiveHeight); assertEquals(900, maximumArchiveHeight); // Write blocks 2-900 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Archive contains " + writer.getWrittenCount() + " blocks. (Expected 899)"); assertEquals(900 - 1, writer.getWrittenCount()); // Increment block archive height @@ -297,34 +372,48 @@ public class BlockArchiveV2Tests extends Common { // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); // Ensure the SQL repository contains blocks 2 and 900... + System.out.println("Verifying that blocks 2 and 900 exist in the repository..."); assertNotNull(repository.getBlockRepository().fromHeight(2)); assertNotNull(repository.getBlockRepository().fromHeight(900)); + System.out.println("Blocks 2 and 900 are present in the repository."); // Prune all the archived blocks + System.out.println("Pruning blocks 2 to 900..."); int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 900); + System.out.println("Number of blocks pruned (Expected 899): " + numBlocksPruned); assertEquals(900-1, numBlocksPruned); repository.getBlockRepository().setBlockPruneHeight(901); // Prune the AT states for the archived blocks + System.out.println("Pruning AT states up to height 900..."); repository.getATRepository().rebuildLatestAtStates(900); repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 900); + System.out.println("Number of AT states pruned (Expected 898): " + 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... + System.out.println("Verifying that blocks 2 and 900 have been pruned..."); assertNull(repository.getBlockRepository().fromHeight(2)); assertNull(repository.getBlockRepository().fromHeight(900)); + System.out.println("Blocks 2 and 900 have been successfully pruned."); // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) + System.out.println("Verifying that blocks 1 and 901 still exist..."); assertNotNull(repository.getBlockRepository().fromHeight(1)); assertNotNull(repository.getBlockRepository().fromHeight(901)); + System.out.println("Blocks 1 and 901 are present in the repository."); // Validate the latest block height in the repository - assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + int lastBlockHeight = repository.getBlockRepository().getLastBlock().getHeight(); + System.out.println("Latest block height in repository (Expected 1002): " + lastBlockHeight); + assertEquals(1002, lastBlockHeight); + System.out.println("testArchiveAndPrune completed successfully."); } } @@ -332,138 +421,191 @@ public class BlockArchiveV2Tests extends Common { public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Starting testTrimArchivePruneAndOrphan"); + // Deploy an AT so that we have AT state data + System.out.println("Deploying AT..."); PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); byte[] creationBytes = AtUtils.buildSimpleAT(); long fundingAmount = 1_00000000L; AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + System.out.println("AT deployed successfully."); // Mint some blocks so that we are able to archive them later + System.out.println("Minting 1000 blocks..."); for (int i = 0; i < 1000; i++) { BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + // Log every 100 blocks + if ((i + 1) % 100 == 0) { + System.out.println("Minted block " + (i + 1)); + } } + System.out.println("Finished minting blocks."); // Make sure that block 500 has full AT state data and data hash + System.out.println("Verifying block 500 AT state data..."); List block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); assertNotNull(atStatesData.getStateHash()); assertNotNull(atStatesData.getStateData()); + System.out.println("Block 500 AT state data verified."); // Trim the first 500 blocks + System.out.println("Trimming 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); + System.out.println("Trimming completed."); // Now block 499 should only have the AT state data hash + System.out.println("Checking block 499 AT state data..."); List block499AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(499); atStatesData = repository.getATRepository().getATStateAtHeight(block499AtStatesData.get(0).getATAddress(), 499); assertNotNull(atStatesData.getStateHash()); assertNull(atStatesData.getStateData()); + System.out.println("Block 499 AT state data contains only state hash as expected."); // ... but block 500 should have the full data (due to being retained as the "latest" AT state in the trimmed range + System.out.println("Verifying block 500 AT state data again..."); block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); assertNotNull(atStatesData.getStateHash()); assertNotNull(atStatesData.getStateData()); + System.out.println("Block 500 AT state data contains full data."); // ... and block 501 should also have the full data + System.out.println("Verifying block 501 AT state data..."); List block501AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(501); atStatesData = repository.getATRepository().getATStateAtHeight(block501AtStatesData.get(0).getATAddress(), 501); assertNotNull(atStatesData.getStateHash()); assertNotNull(atStatesData.getStateData()); + System.out.println("Block 501 AT state data contains full data."); // Check the max archive height - this should be one less than the first untrimmed height final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + System.out.println("Maximum archive height determined (Expected 500): " + maximumArchiveHeight); assertEquals(500, maximumArchiveHeight); BlockData block3DataPreArchive = repository.getBlockRepository().fromHeight(3); // Write blocks 2-500 to the archive + System.out.println("Writing blocks 2 to " + maximumArchiveHeight + " 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(); + System.out.println("Finished writing blocks to archive. Result: " + result); assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); // Make sure that the archive contains the correct number of blocks + System.out.println("Number of blocks written to archive (Expected 499): " + writer.getWrittenCount()); 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()); + System.out.println("Block archive height updated to: " + (500 - 1)); // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); assertTrue(outputFile.exists()); + System.out.println("Archive file exists at: " + outputFile.getAbsolutePath()); // Ensure the SQL repository contains blocks 2 and 500... + System.out.println("Verifying that blocks 2 and 500 exist in the repository..."); assertNotNull(repository.getBlockRepository().fromHeight(2)); assertNotNull(repository.getBlockRepository().fromHeight(500)); + System.out.println("Blocks 2 and 500 are present in the repository."); // Prune all the archived blocks + System.out.println("Pruning blocks 2 to 500..."); int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 500); + System.out.println("Number of blocks pruned (Expected 499): " + numBlocksPruned); assertEquals(500-1, numBlocksPruned); repository.getBlockRepository().setBlockPruneHeight(501); // Prune the AT states for the archived blocks + System.out.println("Pruning AT states up to height 500..."); repository.getATRepository().rebuildLatestAtStates(500); repository.saveChanges(); int numATStatesPruned = repository.getATRepository().pruneAtStates(2, 500); + System.out.println("Number of AT states pruned (Expected 498): " + 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... + System.out.println("Verifying that blocks 2 and 500 have been pruned..."); assertNull(repository.getBlockRepository().fromHeight(2)); assertNull(repository.getBlockRepository().fromHeight(500)); + System.out.println("Blocks 2 and 500 have been successfully pruned."); // ... but it's not missing blocks 1 and 501 (we don't prune the genesis block) + System.out.println("Verifying that blocks 1 and 501 still exist..."); assertNotNull(repository.getBlockRepository().fromHeight(1)); assertNotNull(repository.getBlockRepository().fromHeight(501)); + System.out.println("Blocks 1 and 501 are present in the repository."); // Validate the latest block height in the repository - assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + int lastBlockHeight = repository.getBlockRepository().getLastBlock().getHeight(); + System.out.println("Latest block height in repository (Expected 1002): " + lastBlockHeight); + assertEquals(1002, lastBlockHeight); // Now orphan some unarchived blocks. + System.out.println("Orphaning 500 blocks..."); BlockUtils.orphanBlocks(repository, 500); - assertEquals(502, (int) repository.getBlockRepository().getLastBlock().getHeight()); + int currentLastBlockHeight = repository.getBlockRepository().getLastBlock().getHeight(); + System.out.println("New last block height after orphaning (Expected 502): " + currentLastBlockHeight); + assertEquals(502, currentLastBlockHeight); // We're close to the lower limit of the SQL database now, so // we need to import some blocks from the archive + System.out.println("Importing blocks 401 to 500 from the archive..."); BlockArchiveUtils.importFromArchive(401, 500, repository); // Ensure the SQL repository now contains block 401 but not 400... + System.out.println("Verifying that block 401 exists and block 400 does not..."); assertNotNull(repository.getBlockRepository().fromHeight(401)); assertNull(repository.getBlockRepository().fromHeight(400)); + System.out.println("Block 401 exists, block 400 does not."); // Import the remaining 399 blocks + System.out.println("Importing blocks 2 to 400 from the archive..."); BlockArchiveUtils.importFromArchive(2, 400, repository); // Verify that block 3 matches the original + System.out.println("Verifying that block 3 matches the original data..."); BlockData block3DataPostArchive = repository.getBlockRepository().fromHeight(3); assertArrayEquals(block3DataPreArchive.getSignature(), block3DataPostArchive.getSignature()); assertEquals(block3DataPreArchive.getHeight(), block3DataPostArchive.getHeight()); + System.out.println("Block 3 data matches the original."); // 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 + System.out.println("Orphaning 2 more blocks..."); BlockUtils.orphanBlocks(repository, 2); + System.out.println("Orphaned 2 blocks successfully."); // Orphan another block, which should fail + System.out.println("Attempting to orphan another block, which should fail..."); Exception exception = null; try { BlockUtils.orphanBlocks(repository, 1); } catch (DataException e) { exception = e; + System.out.println("Caught expected DataException: " + e.getMessage()); } // Ensure that a DataException is thrown because there is no more AT states data available assertNotNull(exception); assertEquals(DataException.class, exception.getClass()); + System.out.println("DataException confirmed due to lack of AT states data."); // 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. + System.out.println("testTrimArchivePruneAndOrphan completed successfully."); } } @@ -477,16 +619,26 @@ public class BlockArchiveV2Tests extends Common { public void testMissingAtStatesHeightIndex() throws DataException, SQLException { try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + System.out.println("Starting testMissingAtStatesHeightIndex"); + // Firstly check that we're able to prune or archive when the index exists + System.out.println("Checking existence of ATStatesHeightIndex..."); assertTrue(repository.getATRepository().hasAtStatesHeightIndex()); assertTrue(RepositoryManager.canArchiveOrPrune()); + System.out.println("ATStatesHeightIndex exists. Archiving and pruning are possible."); // Delete the index + System.out.println("Dropping ATStatesHeightIndex..."); repository.prepareStatement("DROP INDEX ATSTATESHEIGHTINDEX").execute(); + System.out.println("ATStatesHeightIndex dropped."); // Ensure check that we're unable to prune or archive when the index doesn't exist + System.out.println("Verifying that ATStatesHeightIndex no longer exists..."); assertFalse(repository.getATRepository().hasAtStatesHeightIndex()); assertFalse(RepositoryManager.canArchiveOrPrune()); + System.out.println("ATStatesHeightIndex does not exist. Archiving and pruning are disabled."); + + System.out.println("testMissingAtStatesHeightIndex completed successfully."); } } @@ -496,8 +648,10 @@ public class BlockArchiveV2Tests extends Common { Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); try { FileUtils.deleteDirectory(archivePath.toFile()); + System.out.println("Deleted archive directory at: " + archivePath); } catch (IOException e) { + System.out.println("Failed to delete archive directory: " + e.getMessage()); } } diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index 0d07be4b..51d2535e 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -405,19 +405,26 @@ public class RepositoryTests extends Common { Integer offset = null; Boolean reverse = null; - hsqldb.getATRepository().getMatchingFinalATStates(codeHash, isFinished, dataByteOffset, expectedValue, minimumFinalHeight, limit, offset, reverse); + hsqldb.getATRepository().getMatchingFinalATStates(codeHash,null, null, isFinished, dataByteOffset, expectedValue, minimumFinalHeight, limit, offset, reverse); } catch (DataException e) { fail("HSQLDB bug #1580"); } } - /** Specifically test LATERAL() usage in Chat repository */ + /** Specifically test LATERAL() usage in Chat repository with hasChatReference */ @Test public void testChatLateral() { try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { String address = Crypto.toAddress(new byte[32]); - hsqldb.getChatRepository().getActiveChats(address, ChatMessage.Encoding.BASE58); + // Test without hasChatReference + hsqldb.getChatRepository().getActiveChats(address, ChatMessage.Encoding.BASE58, null); + + // Test with hasChatReference = true + hsqldb.getChatRepository().getActiveChats(address, ChatMessage.Encoding.BASE58, true); + + // Test with hasChatReference = false + hsqldb.getChatRepository().getActiveChats(address, ChatMessage.Encoding.BASE58, false); } catch (DataException e) { fail("HSQLDB bug #1580"); } diff --git a/src/test/java/org/qortal/test/TransferPrivsTests.java b/src/test/java/org/qortal/test/TransferPrivsTests.java index 86a0e743..ad1d2a2a 100644 --- a/src/test/java/org/qortal/test/TransferPrivsTests.java +++ b/src/test/java/org/qortal/test/TransferPrivsTests.java @@ -74,7 +74,7 @@ public class TransferPrivsTests extends Common { public void testAliceIntoNewAccountTransferPrivs() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { TestAccount alice = Common.getTestAccount(repository, "alice"); - assertTrue(alice.canMint()); + assertTrue(alice.canMint(false)); PrivateKeyAccount aliceMintingAccount = Common.getTestAccount(repository, "alice-reward-share"); @@ -86,8 +86,8 @@ public class TransferPrivsTests extends Common { combineAccounts(repository, alice, randomAccount, aliceMintingAccount); - assertFalse(alice.canMint()); - assertTrue(randomAccount.canMint()); + assertFalse(alice.canMint(false)); + assertTrue(randomAccount.canMint(false)); } } @@ -97,8 +97,8 @@ public class TransferPrivsTests extends Common { TestAccount alice = Common.getTestAccount(repository, "alice"); TestAccount dilbert = Common.getTestAccount(repository, "dilbert"); - assertTrue(alice.canMint()); - assertTrue(dilbert.canMint()); + assertTrue(alice.canMint(false)); + assertTrue(dilbert.canMint(false)); // Dilbert has level, Alice does not so we need Alice to mint enough blocks to bump Dilbert's level post-combine final int expectedPostCombineLevel = dilbert.getLevel() + 1; @@ -118,11 +118,11 @@ public class TransferPrivsTests extends Common { // Post-combine sender checks checkSenderPostTransfer(postCombineAliceData); - assertFalse(alice.canMint()); + assertFalse(alice.canMint(false)); // Post-combine recipient checks checkRecipientPostTransfer(preCombineAliceData, preCombineDilbertData, postCombineDilbertData, expectedPostCombineLevel); - assertTrue(dilbert.canMint()); + assertTrue(dilbert.canMint(false)); // Orphan previous block BlockUtils.orphanLastBlock(repository); @@ -130,12 +130,12 @@ public class TransferPrivsTests extends Common { // Sender checks AccountData orphanedAliceData = repository.getAccountRepository().getAccount(alice.getAddress()); checkAccountDataRestored("sender", preCombineAliceData, orphanedAliceData); - assertTrue(alice.canMint()); + assertTrue(alice.canMint(false)); // Recipient checks AccountData orphanedDilbertData = repository.getAccountRepository().getAccount(dilbert.getAddress()); checkAccountDataRestored("recipient", preCombineDilbertData, orphanedDilbertData); - assertTrue(dilbert.canMint()); + assertTrue(dilbert.canMint(false)); } } @@ -145,8 +145,8 @@ public class TransferPrivsTests extends Common { TestAccount alice = Common.getTestAccount(repository, "alice"); TestAccount dilbert = Common.getTestAccount(repository, "dilbert"); - assertTrue(dilbert.canMint()); - assertTrue(alice.canMint()); + assertTrue(dilbert.canMint(false)); + assertTrue(alice.canMint(false)); // Dilbert has level, Alice does not so we need Alice to mint enough blocks to surpass Dilbert's level post-combine final int expectedPostCombineLevel = dilbert.getLevel() + 1; @@ -166,11 +166,11 @@ public class TransferPrivsTests extends Common { // Post-combine sender checks checkSenderPostTransfer(postCombineDilbertData); - assertFalse(dilbert.canMint()); + assertFalse(dilbert.canMint(false)); // Post-combine recipient checks checkRecipientPostTransfer(preCombineDilbertData, preCombineAliceData, postCombineAliceData, expectedPostCombineLevel); - assertTrue(alice.canMint()); + assertTrue(alice.canMint(false)); // Orphan previous block BlockUtils.orphanLastBlock(repository); @@ -178,12 +178,12 @@ public class TransferPrivsTests extends Common { // Sender checks AccountData orphanedDilbertData = repository.getAccountRepository().getAccount(dilbert.getAddress()); checkAccountDataRestored("sender", preCombineDilbertData, orphanedDilbertData); - assertTrue(dilbert.canMint()); + assertTrue(dilbert.canMint(false)); // Recipient checks AccountData orphanedAliceData = repository.getAccountRepository().getAccount(alice.getAddress()); checkAccountDataRestored("recipient", preCombineAliceData, orphanedAliceData); - assertTrue(alice.canMint()); + assertTrue(alice.canMint(false)); } } @@ -202,8 +202,8 @@ public class TransferPrivsTests extends Common { TestAccount chloe = Common.getTestAccount(repository, "chloe"); TestAccount dilbert = Common.getTestAccount(repository, "dilbert"); - assertTrue(dilbert.canMint()); - assertFalse(chloe.canMint()); + assertTrue(dilbert.canMint(false)); + assertFalse(chloe.canMint(false)); // COMBINE DILBERT INTO CHLOE @@ -225,16 +225,16 @@ public class TransferPrivsTests extends Common { // Post-combine sender checks checkSenderPostTransfer(post1stCombineDilbertData); - assertFalse(dilbert.canMint()); + assertFalse(dilbert.canMint(false)); // Post-combine recipient checks checkRecipientPostTransfer(pre1stCombineDilbertData, pre1stCombineChloeData, post1stCombineChloeData, expectedPost1stCombineLevel); - assertTrue(chloe.canMint()); + assertTrue(chloe.canMint(false)); // COMBINE ALICE INTO CHLOE - assertTrue(alice.canMint()); - assertTrue(chloe.canMint()); + assertTrue(alice.canMint(false)); + assertTrue(chloe.canMint(false)); // Alice needs to mint enough blocks to surpass Chloe's level post-combine final int expectedPost2ndCombineLevel = chloe.getLevel() + 1; @@ -254,11 +254,11 @@ public class TransferPrivsTests extends Common { // Post-combine sender checks checkSenderPostTransfer(post2ndCombineAliceData); - assertFalse(alice.canMint()); + assertFalse(alice.canMint(false)); // Post-combine recipient checks checkRecipientPostTransfer(pre2ndCombineAliceData, pre2ndCombineChloeData, post2ndCombineChloeData, expectedPost2ndCombineLevel); - assertTrue(chloe.canMint()); + assertTrue(chloe.canMint(false)); // Orphan 2nd combine BlockUtils.orphanLastBlock(repository); @@ -266,12 +266,12 @@ public class TransferPrivsTests extends Common { // Sender checks AccountData orphanedAliceData = repository.getAccountRepository().getAccount(alice.getAddress()); checkAccountDataRestored("sender", pre2ndCombineAliceData, orphanedAliceData); - assertTrue(alice.canMint()); + assertTrue(alice.canMint(false)); // Recipient checks AccountData orphanedChloeData = repository.getAccountRepository().getAccount(chloe.getAddress()); checkAccountDataRestored("recipient", pre2ndCombineChloeData, orphanedChloeData); - assertTrue(chloe.canMint()); + assertTrue(chloe.canMint(false)); // Orphan 1nd combine BlockUtils.orphanToBlock(repository, pre1stCombineBlockHeight); @@ -279,7 +279,7 @@ public class TransferPrivsTests extends Common { // Sender checks AccountData orphanedDilbertData = repository.getAccountRepository().getAccount(dilbert.getAddress()); checkAccountDataRestored("sender", pre1stCombineDilbertData, orphanedDilbertData); - assertTrue(dilbert.canMint()); + assertTrue(dilbert.canMint(false)); // Recipient checks orphanedChloeData = repository.getAccountRepository().getAccount(chloe.getAddress()); @@ -287,7 +287,7 @@ public class TransferPrivsTests extends Common { // Chloe canMint() would return true here due to Alice-Chloe reward-share minting at top of method, so undo that minting by orphaning back to block 1 BlockUtils.orphanToBlock(repository, 1); - assertFalse(chloe.canMint()); + assertFalse(chloe.canMint(false)); } } diff --git a/src/test/java/org/qortal/test/api/CrossChainApiTests.java b/src/test/java/org/qortal/test/api/CrossChainApiTests.java index d4f25bce..e70193b2 100644 --- a/src/test/java/org/qortal/test/api/CrossChainApiTests.java +++ b/src/test/java/org/qortal/test/api/CrossChainApiTests.java @@ -26,7 +26,7 @@ public class CrossChainApiTests extends ApiCommon { @Test public void testGetCompletedTrades() { long minimumTimestamp = System.currentTimeMillis(); - assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, minimumTimestamp, limit, offset, reverse)); + assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, minimumTimestamp, null, null, limit, offset, reverse)); } @Test @@ -35,8 +35,8 @@ public class CrossChainApiTests extends ApiCommon { Integer offset = null; Boolean reverse = null; - assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, -1L /*minimumTimestamp*/, limit, offset, reverse)); - assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, 0L /*minimumTimestamp*/, limit, offset, reverse)); + assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, -1L /*minimumTimestamp*/, null, null, limit, offset, reverse)); + assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, 0L /*minimumTimestamp*/, null, null, limit, offset, reverse)); } } diff --git a/src/test/java/org/qortal/test/api/CrossChainUtilsTests.java b/src/test/java/org/qortal/test/api/CrossChainUtilsTests.java new file mode 100644 index 00000000..5c67267f --- /dev/null +++ b/src/test/java/org/qortal/test/api/CrossChainUtilsTests.java @@ -0,0 +1,194 @@ +package org.qortal.test.api; + +import org.json.simple.JSONObject; +import org.junit.Assert; +import org.junit.Test; +import org.qortal.api.model.CrossChainTradeLedgerEntry; +import org.qortal.api.resource.CrossChainUtils; +import org.qortal.test.common.ApiCommon; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CrossChainUtilsTests extends ApiCommon { + + @Test + public void testReduceDelimeters1() { + + String string = CrossChainUtils.reduceDelimeters("", 1, ','); + + Assert.assertEquals("", string); + } + + @Test + public void testReduceDelimeters2() { + + String string = CrossChainUtils.reduceDelimeters("0.17.0", 1, ','); + + Assert.assertEquals("0.17.0", string); + } + + @Test + public void testReduceDelimeters3() { + + String string = CrossChainUtils.reduceDelimeters("0.17.0", 1, '.'); + + Assert.assertEquals("0.17", string); + } + + @Test + public void testReduceDelimeters4() { + + String string = CrossChainUtils.reduceDelimeters("0.17.0", 2, '.'); + + Assert.assertEquals("0.17.0", string); + } + + @Test + public void testReduceDelimeters5() { + + String string = CrossChainUtils.reduceDelimeters("0.17.0", 10, '.'); + + Assert.assertEquals("0.17.0", string); + } + + @Test + public void testReduceDelimeters6() { + + String string = CrossChainUtils.reduceDelimeters("0.17.0", -1, '.'); + + Assert.assertEquals("0.17.0", string); + } + + @Test + public void testReduceDelimeters7() { + + String string = CrossChainUtils.reduceDelimeters("abcdef abcdef", 1, 'd'); + + Assert.assertEquals("abcdef abc", string); + } + + @Test + public void testGetVersionDecimalThrowNumberFormatExceptionTrue() { + + boolean thrown = false; + + try { + Map map = new HashMap<>(); + map.put("x", "v"); + double versionDecimal = CrossChainUtils.getVersionDecimal(new JSONObject(map), "x"); + } + catch( NumberFormatException e ) { + thrown = true; + } + + Assert.assertTrue(thrown); + } + + @Test + public void testGetVersionDecimalThrowNullPointerExceptionTrue() { + + boolean thrown = false; + + try { + Map map = new HashMap<>(); + + double versionDecimal = CrossChainUtils.getVersionDecimal(new JSONObject(map), "x"); + } + catch( NullPointerException e ) { + thrown = true; + } + + Assert.assertTrue(thrown); + } + + @Test + public void testGetVersionDecimalThrowAnyExceptionFalse() { + + boolean thrown = false; + + try { + Map map = new HashMap<>(); + map.put("x", "5"); + double versionDecimal = CrossChainUtils.getVersionDecimal(new JSONObject(map), "x"); + } + catch( NullPointerException | NumberFormatException e ) { + thrown = true; + } + + Assert.assertFalse(thrown); + } + + @Test + public void testGetVersionDecimal1() { + + boolean thrown = false; + + double versionDecimal = 0d; + + try { + Map map = new HashMap<>(); + map.put("x", "5.0.0"); + versionDecimal = CrossChainUtils.getVersionDecimal(new JSONObject(map), "x"); + } + catch( NullPointerException | NumberFormatException e ) { + thrown = true; + } + + Assert.assertEquals(5, versionDecimal, 0.001); + Assert.assertFalse(thrown); + } + + @Test + public void testWriteToLedgerHeaderOnly() throws IOException { + CrossChainUtils.writeToLedger(new PrintWriter(System.out), new ArrayList<>()); + } + + @Test + public void testWriteToLedgerOneRow() throws IOException { + CrossChainUtils.writeToLedger( + new PrintWriter(System.out), + List.of( + new CrossChainTradeLedgerEntry( + "QORT", + "LTC", + 1000, + 0, + "LTC", + 1, + System.currentTimeMillis()) + ) + ); + } + + @Test + public void testWriteToLedgerTwoRows() throws IOException { + CrossChainUtils.writeToLedger( + new PrintWriter(System.out), + List.of( + new CrossChainTradeLedgerEntry( + "QORT", + "LTC", + 1000, + 0, + "LTC", + 1, + System.currentTimeMillis() + ), + new CrossChainTradeLedgerEntry( + "LTC", + "QORT", + 1, + 0, + "LTC", + 1000, + System.currentTimeMillis() + ) + ) + ); + } +} diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java index c05ceabf..33375b62 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStorageCapacityTests.java @@ -145,56 +145,6 @@ public class ArbitraryDataStorageCapacityTests extends Common { } } - @Test - public void testDeleteRandomFilesForName() throws DataException, IOException, InterruptedException, IllegalAccessException { - try (final Repository repository = RepositoryManager.getRepository()) { - 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 - - // Set originalCopyIndicatorFileEnabled to false, otherwise nothing will be deleted as it all originates from this node - FieldUtils.writeField(Settings.getInstance(), "originalCopyIndicatorFileEnabled", false, true); - - // Alice hosts some data (with 10 chunks) - PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); - String aliceName = "alice"; - RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), aliceName, ""); - transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); - TransactionUtils.signAndMint(repository, transactionData, alice); - Path alicePath = ArbitraryUtils.generateRandomDataPath(dataLength); - ArbitraryDataFile aliceArbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, Base58.encode(alice.getPublicKey()), alicePath, aliceName, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize); - - // Bob hosts some data too (also with 10 chunks) - PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); - String bobName = "bob"; - transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(bob), bobName, ""); - transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); - TransactionUtils.signAndMint(repository, transactionData, bob); - Path bobPath = ArbitraryUtils.generateRandomDataPath(dataLength); - ArbitraryDataFile bobArbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, Base58.encode(bob.getPublicKey()), bobPath, bobName, identifier, ArbitraryTransactionData.Method.PUT, service, bob, chunkSize); - - // All 20 chunks should exist - assertEquals(10, aliceArbitraryDataFile.chunkCount()); - assertTrue(aliceArbitraryDataFile.allChunksExist()); - assertEquals(10, bobArbitraryDataFile.chunkCount()); - assertTrue(bobArbitraryDataFile.allChunksExist()); - - // Now pretend that Bob has reached his storage limit - this should delete random files - // Run it 10 times to remove the likelihood of the randomizer always picking Alice's files - for (int i=0; i<10; i++) { - ArbitraryDataCleanupManager.getInstance().storageLimitReachedForName(repository, bobName); - } - - // Alice should still have all chunks - assertTrue(aliceArbitraryDataFile.allChunksExist()); - - // Bob should be missing some chunks - assertFalse(bobArbitraryDataFile.allChunksExist()); - - } - } - private void deleteListsDirectory() { // Delete lists directory if exists Path listsPath = Paths.get(Settings.getInstance().getListsPath()); diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java index 1d8f23b3..1968eb60 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryDataStoragePolicyTests.java @@ -73,14 +73,14 @@ public class ArbitraryDataStoragePolicyTests extends Common { // We should store and pre-fetch data for this transaction assertEquals(StoragePolicy.FOLLOWED_OR_VIEWED, Settings.getInstance().getStoragePolicy()); assertTrue(storageManager.canStoreData(arbitraryTransactionData)); - assertTrue(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertTrue(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); // Now unfollow the name assertTrue(ResourceListManager.getInstance().removeFromList("followedNames", name, false)); // We should store but not pre-fetch data for this transaction assertTrue(storageManager.canStoreData(arbitraryTransactionData)); - assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); } } @@ -108,14 +108,14 @@ public class ArbitraryDataStoragePolicyTests extends Common { // We should store and pre-fetch data for this transaction assertEquals(StoragePolicy.FOLLOWED, Settings.getInstance().getStoragePolicy()); assertTrue(storageManager.canStoreData(arbitraryTransactionData)); - assertTrue(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertTrue(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); // Now unfollow the name assertTrue(ResourceListManager.getInstance().removeFromList("followedNames", name, false)); // We shouldn't store or pre-fetch data for this transaction assertFalse(storageManager.canStoreData(arbitraryTransactionData)); - assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); } } @@ -143,14 +143,14 @@ public class ArbitraryDataStoragePolicyTests extends Common { // We should store but not pre-fetch data for this transaction assertEquals(StoragePolicy.VIEWED, Settings.getInstance().getStoragePolicy()); assertTrue(storageManager.canStoreData(arbitraryTransactionData)); - assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); // Now unfollow the name assertTrue(ResourceListManager.getInstance().removeFromList("followedNames", name, false)); // We should store but not pre-fetch data for this transaction assertTrue(storageManager.canStoreData(arbitraryTransactionData)); - assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); } } @@ -178,14 +178,14 @@ public class ArbitraryDataStoragePolicyTests extends Common { // We should store and pre-fetch data for this transaction assertEquals(StoragePolicy.ALL, Settings.getInstance().getStoragePolicy()); assertTrue(storageManager.canStoreData(arbitraryTransactionData)); - assertTrue(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertTrue(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); // Now unfollow the name assertTrue(ResourceListManager.getInstance().removeFromList("followedNames", name, false)); // We should store and pre-fetch data for this transaction assertTrue(storageManager.canStoreData(arbitraryTransactionData)); - assertTrue(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertTrue(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); } } @@ -213,14 +213,14 @@ public class ArbitraryDataStoragePolicyTests extends Common { // We shouldn't store or pre-fetch data for this transaction assertEquals(StoragePolicy.NONE, Settings.getInstance().getStoragePolicy()); assertFalse(storageManager.canStoreData(arbitraryTransactionData)); - assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); // Now unfollow the name assertTrue(ResourceListManager.getInstance().removeFromList("followedNames", name, false)); // We shouldn't store or pre-fetch data for this transaction assertFalse(storageManager.canStoreData(arbitraryTransactionData)); - assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData)); + assertFalse(storageManager.shouldPreFetchData(repository, arbitraryTransactionData).isPass()); } } @@ -236,7 +236,7 @@ public class ArbitraryDataStoragePolicyTests extends Common { // We should store but not pre-fetch data for this transaction assertTrue(storageManager.canStoreData(transactionData)); - assertFalse(storageManager.shouldPreFetchData(repository, transactionData)); + assertFalse(storageManager.shouldPreFetchData(repository, transactionData).isPass()); } } diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index 5472fdb8..5ee74497 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -218,6 +218,8 @@ public class AtRepositoryTests extends Common { List atStates = repository.getATRepository().getMatchingFinalATStates( codeHash, + null, + null, isFinished, dataByteOffset, expectedValue, @@ -264,6 +266,8 @@ public class AtRepositoryTests extends Common { List atStates = repository.getATRepository().getMatchingFinalATStates( codeHash, + null, + null, isFinished, dataByteOffset, expectedValue, diff --git a/src/test/java/org/qortal/test/block/BlockTests.java b/src/test/java/org/qortal/test/block/BlockTests.java new file mode 100644 index 00000000..cd6f37e8 --- /dev/null +++ b/src/test/java/org/qortal/test/block/BlockTests.java @@ -0,0 +1,58 @@ +package org.qortal.test.block; + +import org.checkerframework.checker.units.qual.K; +import org.junit.Assert; +import org.junit.Test; +import org.qortal.block.Block; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +public class BlockTests { + + @Test + public void testDistributeToAccountsOneDistribution(){ + List addresses = new ArrayList<>(); + addresses.add("a"); + addresses.add("b"); + addresses.add("c"); + + HashMap balanceByAddress = new HashMap<>(); + long total = Block.distributeToAccounts( 10L, addresses, balanceByAddress); + + Assert.assertEquals(9, total); + + Assert.assertEquals(3, balanceByAddress.size()); + Assert.assertTrue(balanceByAddress.containsKey("a")); + Assert.assertTrue(balanceByAddress.containsKey("b")); + Assert.assertTrue(balanceByAddress.containsKey("c")); + Assert.assertEquals(3L, balanceByAddress.getOrDefault("a", 0L).longValue()); + Assert.assertEquals(3L, balanceByAddress.getOrDefault("b", 0L).longValue()); + Assert.assertEquals(3L, balanceByAddress.getOrDefault("c", 0L).longValue()); + } + + @Test + public void testDistributeToAccountsTwoDistributions(){ + List addresses = new ArrayList<>(); + addresses.add("a"); + addresses.add("b"); + addresses.add("c"); + + HashMap balanceByAddress = new HashMap<>(); + long total1 = Block.distributeToAccounts( 10L, addresses, balanceByAddress); + long total2 = Block.distributeToAccounts( 20L, addresses, balanceByAddress); + + Assert.assertEquals(9, total1); + Assert.assertEquals(18, total2); + + Assert.assertEquals(3, balanceByAddress.size()); + Assert.assertTrue(balanceByAddress.containsKey("a")); + Assert.assertTrue(balanceByAddress.containsKey("b")); + Assert.assertTrue(balanceByAddress.containsKey("c")); + Assert.assertEquals(9L, balanceByAddress.getOrDefault("a", 0L).longValue()); + Assert.assertEquals(9L, balanceByAddress.getOrDefault("b", 0L).longValue()); + Assert.assertEquals(9L, balanceByAddress.getOrDefault("c", 0L).longValue()); + } +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/Common.java b/src/test/java/org/qortal/test/crosschain/apps/Common.java index dd3130b9..5bba0dc4 100644 --- a/src/test/java/org/qortal/test/crosschain/apps/Common.java +++ b/src/test/java/org/qortal/test/crosschain/apps/Common.java @@ -92,7 +92,7 @@ public abstract class Common { List unspentOutputs = Collections.emptyList(); try { - unspentOutputs = bitcoiny.getUnspentOutputs(address58); + unspentOutputs = bitcoiny.getUnspentOutputs(address58, false); } catch (ForeignBlockchainException e) { System.err.println(String.format("Can't find unspent outputs for %s: %s", address58, e.getMessage())); return unspentOutputs; diff --git a/src/test/java/org/qortal/test/group/DevGroupAdminTests.java b/src/test/java/org/qortal/test/group/DevGroupAdminTests.java index 41352323..38ad0c53 100644 --- a/src/test/java/org/qortal/test/group/DevGroupAdminTests.java +++ b/src/test/java/org/qortal/test/group/DevGroupAdminTests.java @@ -4,7 +4,11 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; +import org.qortal.block.Block; +import org.qortal.block.BlockChain; +import org.qortal.data.group.GroupAdminData; import org.qortal.data.transaction.*; +import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -16,6 +20,8 @@ import org.qortal.test.common.transaction.TestTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.ValidationResult; +import java.util.List; + import static org.junit.Assert.*; /** @@ -40,8 +46,14 @@ import static org.junit.Assert.*; */ public class DevGroupAdminTests extends Common { + public static final int NULL_GROUP_MEMBERSHIP_HEIGHT = BlockChain.getInstance().getNullGroupMembershipHeight(); private static final int DEV_GROUP_ID = 1; + public static final String ALICE = "alice"; + public static final String BOB = "bob"; + public static final String CHLOE = "chloe"; + public static final String DILBERT = "dilbert"; + @Before public void beforeTest() throws DataException { Common.useDefaultSettings(); @@ -55,8 +67,8 @@ public class DevGroupAdminTests extends Common { @Test public void testGroupKickMember() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); - PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount alice = Common.getTestAccount(repository, ALICE); + PrivateKeyAccount bob = Common.getTestAccount(repository, BOB); // Dev group int groupId = DEV_GROUP_ID; @@ -80,16 +92,10 @@ public class DevGroupAdminTests extends Common { // Attempt to kick Bob result = groupKick(repository, alice, groupId, bob.getAddress()); - // Should be OK - assertEquals(ValidationResult.OK, result); + // Should not be OK, cannot kick member out of null owned group + assertNotSame(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 + // Confirm Bob remains a member assertTrue(isMember(repository, bob.getAddress(), groupId)); } } @@ -97,8 +103,8 @@ public class DevGroupAdminTests extends Common { @Test public void testGroupKickAdmin() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); - PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount alice = Common.getTestAccount(repository, ALICE); + PrivateKeyAccount bob = Common.getTestAccount(repository, BOB); // Dev group int groupId = DEV_GROUP_ID; @@ -123,7 +129,7 @@ public class DevGroupAdminTests extends Common { assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.PENDING, approvalStatus); // Have Alice approve Bob's approval-needed transaction - GroupUtils.approveTransaction(repository, "alice", addGroupAdminTransactionData.getSignature(), true); + GroupUtils.approveTransaction(repository, ALICE, addGroupAdminTransactionData.getSignature(), true); // Mint a block so that the transaction becomes approved BlockUtils.mintBlock(repository); @@ -167,8 +173,8 @@ public class DevGroupAdminTests extends Common { @Test public void testGroupBanMember() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); - PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount alice = Common.getTestAccount(repository, ALICE); + PrivateKeyAccount bob = Common.getTestAccount(repository, BOB); // Dev group int groupId = DEV_GROUP_ID; @@ -183,18 +189,13 @@ public class DevGroupAdminTests extends Common { // 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 + // Should not be OK, cannot ban someone from a null owned group assertNotSame(ValidationResult.OK, result); - // Orphan last block (Bob ban) - BlockUtils.orphanLastBlock(repository); - // Delete unconfirmed group-ban transaction - TransactionUtils.deleteUnconfirmedTransactions(repository); + // Bob attempts to join + result = joinGroup(repository, bob, groupId); + // Should be OK, but won't actually get him in the group + assertEquals(ValidationResult.OK, result); // Confirm Bob is not a member assertFalse(isMember(repository, bob.getAddress(), groupId)); @@ -204,65 +205,38 @@ public class DevGroupAdminTests extends Common { // Bob to join result = joinGroup(repository, bob, groupId); - // Should be OK - assertEquals(ValidationResult.OK, result); + // Should not be OK, bob should already be a member, he joined before the invite and + // the invite served as an approval + assertEquals(ValidationResult.ALREADY_GROUP_MEMBER, result); - // Confirm Bob now a member + // Confirm Bob now a member, now that he got an invite assertTrue(isMember(repository, bob.getAddress(), groupId)); // Attempt to ban Bob result = groupBan(repository, alice, groupId, bob.getAddress()); - // Should be OK - assertEquals(ValidationResult.OK, result); + // Should not be OK, because you can ban a member of a null owned group + assertNotSame(ValidationResult.OK, result); - // Confirm Bob no longer a member - assertFalse(isMember(repository, bob.getAddress(), groupId)); + // Confirm Bob is still a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); // Bob attempts to rejoin result = joinGroup(repository, bob, groupId); - // Should NOT be OK + // Should NOT be OK, because he is already a member 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 + // Should not be OK, because there was no ban to begin with 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"); + PrivateKeyAccount alice = Common.getTestAccount(repository, ALICE); + PrivateKeyAccount bob = Common.getTestAccount(repository, BOB); // Dev group int groupId = DEV_GROUP_ID; @@ -286,7 +260,7 @@ public class DevGroupAdminTests extends Common { assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.PENDING, approvalStatus); // Have Alice approve Bob's approval-needed transaction - GroupUtils.approveTransaction(repository, "alice", addGroupAdminTransactionData.getSignature(), true); + GroupUtils.approveTransaction(repository, ALICE, addGroupAdminTransactionData.getSignature(), true); // Mint a block so that the transaction becomes approved BlockUtils.mintBlock(repository); @@ -321,6 +295,322 @@ public class DevGroupAdminTests extends Common { } } + @Test + public void testAddAdmin2of3() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // establish accounts + PrivateKeyAccount alice = Common.getTestAccount(repository, ALICE); + PrivateKeyAccount bob = Common.getTestAccount(repository, BOB); + PrivateKeyAccount chloe = Common.getTestAccount(repository, CHLOE); + PrivateKeyAccount dilbert = Common.getTestAccount(repository, DILBERT); + + // assert admin statuses + assertEquals(2, repository.getGroupRepository().countGroupAdmins(DEV_GROUP_ID).intValue()); + assertTrue(isAdmin(repository, Group.NULL_OWNER_ADDRESS, DEV_GROUP_ID)); + assertTrue(isAdmin(repository, alice.getAddress(), DEV_GROUP_ID)); + assertFalse(isAdmin(repository, bob.getAddress(), DEV_GROUP_ID)); + assertFalse(isAdmin(repository, chloe.getAddress(), DEV_GROUP_ID)); + assertFalse(isAdmin(repository, dilbert.getAddress(), DEV_GROUP_ID)); + + // confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), DEV_GROUP_ID)); + + // alice invites bob + ValidationResult result = groupInvite(repository, alice, DEV_GROUP_ID, bob.getAddress(), 3600); + assertSame(ValidationResult.OK, result); + + // bob joins + joinGroup(repository, bob, DEV_GROUP_ID); + + // confirm Bob is a member now, but still not an admin + assertTrue(isMember(repository, bob.getAddress(), DEV_GROUP_ID)); + assertFalse(isAdmin(repository, bob.getAddress(), DEV_GROUP_ID)); + + // bob creates transaction to add himself as an admin + TransactionData addGroupAdminTransactionData1 = addGroupAdmin(repository, bob, DEV_GROUP_ID, bob.getAddress()); + + // bob creates add admin transaction for himself, alice signs which is 50% approval while 40% is needed + signForGroupApproval(repository, addGroupAdminTransactionData1, List.of(alice)); + + // assert 3 admins in group and bob is an admin now + assertEquals(3, repository.getGroupRepository().countGroupAdmins(DEV_GROUP_ID).intValue() ); + assertTrue(isAdmin(repository, bob.getAddress(), DEV_GROUP_ID)); + + // bob invites chloe + result = groupInvite(repository, bob, DEV_GROUP_ID, chloe.getAddress(), 3600); + assertSame(ValidationResult.OK, result); + + // chloe joins + joinGroup(repository, chloe, DEV_GROUP_ID); + + // confirm Chloe is a member now, but still not an admin + assertTrue(isMember(repository, chloe.getAddress(), DEV_GROUP_ID)); + assertFalse(isAdmin(repository, chloe.getAddress(), DEV_GROUP_ID)); + + // chloe creates transaction to add herself as an admin + TransactionData addChloeAsGroupAdmin = addGroupAdmin(repository, chloe, DEV_GROUP_ID, chloe.getAddress()); + + // no one has signed, so it should be pending + Transaction.ApprovalStatus addChloeAsGroupAdminStatus1 = GroupUtils.getApprovalStatus(repository, addChloeAsGroupAdmin.getSignature()); + assertEquals( Transaction.ApprovalStatus.PENDING, addChloeAsGroupAdminStatus1); + + // signer 1 + Transaction.ApprovalStatus addChloeAsGroupAdminStatus2 = signForGroupApproval(repository, addChloeAsGroupAdmin, List.of(alice)); + + // 1 out of 3 has signed, so it should be pending, because it is less than 40% + assertEquals( Transaction.ApprovalStatus.PENDING, addChloeAsGroupAdminStatus2); + + // signer 2 + Transaction.ApprovalStatus addChloeAsGroupAdminStatus3 = signForGroupApproval(repository, addChloeAsGroupAdmin, List.of(bob)); + + // 2 out of 3 has signed, so it should be approved, because it is more than 40% + assertEquals( Transaction.ApprovalStatus.APPROVED, addChloeAsGroupAdminStatus3); + } + } + + @Test + public void testNullOwnershipMembership() throws DataException{ + try (final Repository repository = RepositoryManager.getRepository()) { + + Block block = BlockUtils.mintBlocks(repository, NULL_GROUP_MEMBERSHIP_HEIGHT); + assertEquals(NULL_GROUP_MEMBERSHIP_HEIGHT + 1, block.getBlockData().getHeight().intValue()); + + // establish accounts + PrivateKeyAccount alice = Common.getTestAccount(repository, ALICE); + PrivateKeyAccount bob = Common.getTestAccount(repository, BOB); + PrivateKeyAccount chloe = Common.getTestAccount(repository, CHLOE); + PrivateKeyAccount dilbert = Common.getTestAccount(repository, DILBERT); + + // assert admin statuses + assertEquals(2, repository.getGroupRepository().countGroupAdmins(DEV_GROUP_ID).intValue()); + assertTrue(isAdmin(repository, Group.NULL_OWNER_ADDRESS, DEV_GROUP_ID)); + assertTrue(isAdmin(repository, alice.getAddress(), DEV_GROUP_ID)); + assertFalse(isAdmin(repository, bob.getAddress(), DEV_GROUP_ID)); + assertFalse(isAdmin(repository, chloe.getAddress(), DEV_GROUP_ID)); + assertFalse(isAdmin(repository, dilbert.getAddress(), DEV_GROUP_ID)); + + // confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), DEV_GROUP_ID)); + + // alice invites bob, alice signs which is 50% approval while 40% is needed + TransactionData createInviteTransactionData = createGroupInviteForGroupApproval(repository, alice, DEV_GROUP_ID, bob.getAddress(), 3600); + Transaction.ApprovalStatus bobsInviteStatus = signForGroupApproval(repository, createInviteTransactionData, List.of(alice)); + + // assert approval + assertEquals(Transaction.ApprovalStatus.APPROVED, bobsInviteStatus); + + // bob joins + joinGroup(repository, bob, DEV_GROUP_ID); + + // confirm Bob is a member now, but still not an admin + assertTrue(isMember(repository, bob.getAddress(), DEV_GROUP_ID)); + assertFalse(isAdmin(repository, bob.getAddress(), DEV_GROUP_ID)); + + // bob creates transaction to add himself as an admin + TransactionData addGroupAdminTransactionData1 = addGroupAdmin(repository, bob, DEV_GROUP_ID, bob.getAddress()); + + // bob creates add admin transaction for himself, alice signs which is 50% approval while 40% is needed + signForGroupApproval(repository, addGroupAdminTransactionData1, List.of(alice)); + + // assert 3 admins in group and bob is an admin now + assertEquals(3, repository.getGroupRepository().countGroupAdmins(DEV_GROUP_ID).intValue()); + assertTrue(isAdmin(repository, bob.getAddress(), DEV_GROUP_ID)); + + // bob invites chloe, bob signs which is 33% approval while 40% is needed + TransactionData chloeInvite = createGroupInviteForGroupApproval(repository, bob, DEV_GROUP_ID, chloe.getAddress(), 3600); + Transaction.ApprovalStatus chloeInviteStatus = signForGroupApproval(repository, chloeInvite, List.of(bob)); + + // assert pending + assertEquals(Transaction.ApprovalStatus.PENDING, chloeInviteStatus); + + // alice signs which is 66% approval while 40% is needed + chloeInviteStatus = signForGroupApproval(repository, chloeInvite, List.of(alice)); + + // assert approval + assertEquals(Transaction.ApprovalStatus.APPROVED, chloeInviteStatus); + + // chloe joins + joinGroup(repository, chloe, DEV_GROUP_ID); + + // assert chloe is in the group + assertTrue(isMember(repository, bob.getAddress(), DEV_GROUP_ID)); + + // alice kicks chloe, alice signs which is 33% approval while 40% is needed + TransactionData chloeKick = createGroupKickForGroupApproval(repository, alice, DEV_GROUP_ID, chloe.getAddress(),"testing chloe kick"); + Transaction.ApprovalStatus chloeKickStatus = signForGroupApproval(repository, chloeKick, List.of(alice)); + + // assert pending + assertEquals(Transaction.ApprovalStatus.PENDING, chloeKickStatus); + + // assert chloe is still in the group + assertTrue(isMember(repository, bob.getAddress(), DEV_GROUP_ID)); + + // bob signs which is 66% approval while 40% is needed + chloeKickStatus = signForGroupApproval(repository, chloeKick, List.of(bob)); + + // assert approval + assertEquals(Transaction.ApprovalStatus.APPROVED, chloeKickStatus); + + // assert chloe is not in the group + assertFalse(isMember(repository, chloe.getAddress(), DEV_GROUP_ID)); + + // bob invites chloe, alice and bob signs which is 66% approval while 40% is needed + TransactionData chloeInviteAgain = createGroupInviteForGroupApproval(repository, bob, DEV_GROUP_ID, chloe.getAddress(), 3600); + Transaction.ApprovalStatus chloeInviteAgainStatus = signForGroupApproval(repository, chloeInviteAgain, List.of(alice, bob)); + + // assert approved + assertEquals(Transaction.ApprovalStatus.APPROVED, chloeInviteAgainStatus); + + // chloe joins again + joinGroup(repository, chloe, DEV_GROUP_ID); + + // assert chloe is in the group + assertTrue(isMember(repository, bob.getAddress(), DEV_GROUP_ID)); + + // alice bans chloe, alice signs which is 33% approval while 40% is needed + TransactionData chloeBan = createGroupBanForGroupApproval(repository, alice, DEV_GROUP_ID, chloe.getAddress(), "testing group ban", 3600); + Transaction.ApprovalStatus chloeBanStatus1 = signForGroupApproval(repository, chloeBan, List.of(alice)); + + // assert pending + assertEquals(Transaction.ApprovalStatus.PENDING, chloeBanStatus1); + + // bob signs which 66% approval while 40% is needed + Transaction.ApprovalStatus chloeBanStatus2 = signForGroupApproval(repository, chloeBan, List.of(bob)); + + // assert approved + assertEquals(Transaction.ApprovalStatus.APPROVED, chloeBanStatus2); + + // assert chloe is not in the group + assertFalse(isMember(repository, chloe.getAddress(), DEV_GROUP_ID)); + + // bob invites chloe, alice and bob signs which is 66% approval while 40% is needed + ValidationResult chloeInviteValidation = signAndImportGroupInvite(repository, bob, DEV_GROUP_ID, chloe.getAddress(), 3600); + + // assert banned status on invite attempt + assertEquals(ValidationResult.BANNED_FROM_GROUP, chloeInviteValidation); + + // bob cancel ban on chloe, bob signs which is 33% approval while 40% is needed + TransactionData chloeCancelBan = createCancelGroupBanForGroupApproval( repository, bob, DEV_GROUP_ID, chloe.getAddress()); + Transaction.ApprovalStatus chloeCancelBanStatus1 = signForGroupApproval(repository, chloeCancelBan, List.of(bob)); + + // assert pending + assertEquals(Transaction.ApprovalStatus.PENDING, chloeCancelBanStatus1); + + // alice signs which is 66% approval while 40% is needed + Transaction.ApprovalStatus chloeCancelBanStatus2 = signForGroupApproval(repository, chloeCancelBan, List.of(alice)); + + // assert approved + assertEquals(Transaction.ApprovalStatus.APPROVED, chloeCancelBanStatus2); + + // bob invites chloe, alice and bob signs which is 66% approval while 40% is needed + TransactionData chloeInvite4 = createGroupInviteForGroupApproval(repository, bob, DEV_GROUP_ID, chloe.getAddress(), 3600); + Transaction.ApprovalStatus chloeInvite4Status = signForGroupApproval(repository, chloeInvite4, List.of(alice, bob)); + + // assert approved + assertEquals(Transaction.ApprovalStatus.APPROVED, chloeInvite4Status); + + // chloe joins again + joinGroup(repository, chloe, DEV_GROUP_ID); + + // assert chloe is in the group + assertTrue(isMember(repository, chloe.getAddress(), DEV_GROUP_ID)); + + // bob invites dilbert, alice and bob signs which is 66% approval while 40% is needed + TransactionData dilbertInvite1 = createGroupInviteForGroupApproval(repository, bob, DEV_GROUP_ID, dilbert.getAddress(), 3600); + Transaction.ApprovalStatus dibertInviteStatus1 = signForGroupApproval(repository, dilbertInvite1, List.of(alice, bob)); + + // assert approved + assertEquals(Transaction.ApprovalStatus.APPROVED, dibertInviteStatus1); + + // alice cancels dilbert's invite, alice signs which is 33% approval while 40% is needed + TransactionData cancelDilbertInvite = createCancelInviteForGroupApproval(repository, alice, DEV_GROUP_ID, dilbert.getAddress()); + Transaction.ApprovalStatus cancelDilbertInviteStatus1 = signForGroupApproval(repository, cancelDilbertInvite, List.of(alice)); + + // assert pending + assertEquals(Transaction.ApprovalStatus.PENDING, cancelDilbertInviteStatus1); + + // dilbert joins before the group approves cancellation + joinGroup(repository, dilbert, DEV_GROUP_ID); + + // assert dilbert is in the group + assertTrue(isMember(repository, dilbert.getAddress(), DEV_GROUP_ID)); + + // alice kicks out dilbert, alice and bob sign which is 66% approval while 40% is needed + TransactionData kickDilbert = createGroupKickForGroupApproval(repository, alice, DEV_GROUP_ID, dilbert.getAddress(), "he is sneaky"); + Transaction.ApprovalStatus kickDilbertStatus = signForGroupApproval(repository, kickDilbert, List.of(alice, bob)); + + // assert approved + assertEquals(Transaction.ApprovalStatus.APPROVED, kickDilbertStatus); + + // assert dilbert is out of the group + assertFalse(isMember(repository, dilbert.getAddress(), DEV_GROUP_ID)); + + // bob invites dilbert again, alice and bob signs which is 66% approval while 40% is needed + TransactionData dilbertInvite2 = createGroupInviteForGroupApproval(repository, bob, DEV_GROUP_ID, dilbert.getAddress(), 3600); + Transaction.ApprovalStatus dibertInviteStatus2 = signForGroupApproval(repository, dilbertInvite2, List.of(alice, bob)); + + // assert approved + assertEquals(Transaction.ApprovalStatus.APPROVED, dibertInviteStatus2); + + // alice cancels dilbert's invite, alice and bob signs which is 66% approval while 40% is needed + TransactionData cancelDilbertInvite2 = createCancelInviteForGroupApproval(repository, alice, DEV_GROUP_ID, dilbert.getAddress()); + Transaction.ApprovalStatus cancelDilbertInviteStatus2 = signForGroupApproval(repository, cancelDilbertInvite2, List.of(alice, bob)); + + // assert approved + assertEquals(Transaction.ApprovalStatus.APPROVED, cancelDilbertInviteStatus2); + + // dilbert tries to join after the group approves cancellation + joinGroup(repository, dilbert, DEV_GROUP_ID); + + // assert dilbert is not in the group + assertFalse(isMember(repository, dilbert.getAddress(), DEV_GROUP_ID)); + } + } + + @Test + public void testGetAdmin() throws DataException{ + try (final Repository repository = RepositoryManager.getRepository()) { + + // establish accounts + PrivateKeyAccount alice = Common.getTestAccount(repository, ALICE); + PrivateKeyAccount bob = Common.getTestAccount(repository, BOB); + + GroupAdminData aliceAdminData = repository.getGroupRepository().getAdmin(DEV_GROUP_ID, alice.getAddress()); + + assertNotNull(aliceAdminData); + assertEquals( alice.getAddress(), aliceAdminData.getAdmin() ); + assertEquals( DEV_GROUP_ID, aliceAdminData.getGroupId()); + + GroupAdminData bobAdminData = repository.getGroupRepository().getAdmin(DEV_GROUP_ID, bob.getAddress()); + + assertNull(bobAdminData); + } + } + + private Transaction.ApprovalStatus signForGroupApproval(Repository repository, TransactionData data, List signers) throws DataException { + + for (PrivateKeyAccount signer : signers) { + signTransactionDataForGroupApproval(repository, signer, data); + } + + BlockUtils.mintBlocks(repository, 2); + + // return approval status + return GroupUtils.getApprovalStatus(repository, data.getSignature()); + } + + private static void signTransactionDataForGroupApproval(Repository repository, PrivateKeyAccount signer, TransactionData transactionData) throws DataException { + byte[] reference = signer.getLastReference(); + long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1; + + BaseTransactionData baseTransactionData + = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, signer.getPublicKey(), GroupUtils.fee, null); + TransactionData groupApprovalTransactionData + = new GroupApprovalTransactionData(baseTransactionData, transactionData.getSignature(), true); + + TransactionUtils.signAndImportValid(repository, groupApprovalTransactionData, signer); + } private ValidationResult joinGroup(Repository repository, PrivateKeyAccount joiner, int groupId) throws DataException { JoinGroupTransactionData transactionData = new JoinGroupTransactionData(TestTransaction.generateBase(joiner), groupId); @@ -332,9 +622,31 @@ public class DevGroupAdminTests extends Common { return result; } - private void groupInvite(Repository repository, PrivateKeyAccount admin, int groupId, String invitee, int timeToLive) throws DataException { + private ValidationResult groupInvite(Repository repository, PrivateKeyAccount admin, int groupId, String invitee, int timeToLive) throws DataException { GroupInviteTransactionData transactionData = new GroupInviteTransactionData(TestTransaction.generateBase(admin), groupId, invitee, timeToLive); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private TransactionData createGroupInviteForGroupApproval(Repository repository, PrivateKeyAccount admin, int groupId, String invitee, int timeToLive) throws DataException { + GroupInviteTransactionData transactionData = new GroupInviteTransactionData(TestTransaction.generateBase(admin, groupId), groupId, invitee, timeToLive); TransactionUtils.signAndMint(repository, transactionData, admin); + return transactionData; + } + + private TransactionData createCancelInviteForGroupApproval(Repository repository, PrivateKeyAccount admin, int groupId, String inviteeToCancel) throws DataException { + CancelGroupInviteTransactionData transactionData = new CancelGroupInviteTransactionData(TestTransaction.generateBase(admin, groupId), groupId, inviteeToCancel); + TransactionUtils.signAndMint(repository, transactionData, admin); + return transactionData; + } + + private ValidationResult signAndImportGroupInvite(Repository repository, PrivateKeyAccount admin, int groupId, String invitee, int timeToLive) throws DataException { + GroupInviteTransactionData transactionData = new GroupInviteTransactionData(TestTransaction.generateBase(admin, groupId), groupId, invitee, timeToLive); + return TransactionUtils.signAndImport(repository, transactionData, admin); } private ValidationResult groupKick(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { @@ -347,6 +659,13 @@ public class DevGroupAdminTests extends Common { return result; } + private TransactionData createGroupKickForGroupApproval(Repository repository, PrivateKeyAccount admin, int groupId, String kicked, String reason) throws DataException { + GroupKickTransactionData transactionData = new GroupKickTransactionData(TestTransaction.generateBase(admin, groupId), groupId, kicked, reason); + TransactionUtils.signAndMint(repository, transactionData, admin); + + return transactionData; + } + 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); @@ -357,6 +676,13 @@ public class DevGroupAdminTests extends Common { return result; } + private TransactionData createGroupBanForGroupApproval(Repository repository, PrivateKeyAccount admin, int groupId, String banned, String reason, int timeToLive) throws DataException { + GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin, groupId), groupId, banned, reason, timeToLive); + TransactionUtils.signAndMint(repository, transactionData, admin); + + return transactionData; + } + 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); @@ -367,6 +693,14 @@ public class DevGroupAdminTests extends Common { return result; } + private TransactionData createCancelGroupBanForGroupApproval(Repository repository, PrivateKeyAccount admin, int groupId, String unbanned ) throws DataException { + CancelGroupBanTransactionData transactionData = new CancelGroupBanTransactionData( TestTransaction.generateBase(admin, groupId), groupId, unbanned); + + TransactionUtils.signAndMint(repository, transactionData, admin); + + return transactionData; + } + 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); diff --git a/src/test/java/org/qortal/test/repository/HSQLDBCacheUtilsTests.java b/src/test/java/org/qortal/test/repository/HSQLDBCacheUtilsTests.java new file mode 100644 index 00000000..6a984b1d --- /dev/null +++ b/src/test/java/org/qortal/test/repository/HSQLDBCacheUtilsTests.java @@ -0,0 +1,708 @@ +package org.qortal.test.repository; + +import org.junit.Assert; +import org.junit.Test; +import org.qortal.api.SearchMode; +import org.qortal.arbitrary.misc.Service; +import org.qortal.data.arbitrary.ArbitraryResourceData; +import org.qortal.data.arbitrary.ArbitraryResourceMetadata; +import org.qortal.data.arbitrary.ArbitraryResourceStatus; +import org.qortal.repository.hsqldb.HSQLDBCacheUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +public class HSQLDBCacheUtilsTests { + + private static final Map NAME_LEVEL = Map.of("Joe", 4); + private static final String SERVICE = "service"; + private static final String QUERY = "query"; + private static final String IDENTIFIER = "identifier"; + private static final String NAMES = "names"; + private static final String TITLE = "title"; + private static final String DESCRIPTION = "description"; + private static final String PREFIX_ONLY = "prefixOnly"; + private static final String EXACT_MATCH_NAMES = "exactMatchNames"; + private static final String KEYWORDS = "keywords"; + private static final String DEFAULT_RESOURCE = "defaultResource"; + private static final String MODE = "mode"; + private static final String MIN_LEVEL = "minLevel"; + private static final String FOLLOWED_ONLY = "followedOnly"; + private static final String EXCLUDE_BLOCKED = "excludeBlocked"; + private static final String INCLUDE_METADATA = "includeMetadata"; + private static final String INCLUDE_STATUS = "includeStatus"; + private static final String BEFORE = "before"; + private static final String AFTER = "after"; + private static final String LIMIT = "limit"; + private static final String OFFSET = "offset"; + private static final String REVERSE = "reverse"; + + @Test + public void test000EmptyQuery() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "joe"; + + List candidates = List.of(data); + + filterListByMap(candidates, NAME_LEVEL, new HashMap<>(Map.of()), 1); + } + + @Test + public void testLatestModeNoService() { + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(MODE, SearchMode.LATEST )), + 0); + } + + @Test + public void testLatestModeNoCreated() { + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "joe"; + data.service = Service.FILE; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(MODE, SearchMode.LATEST )), + 0); + } + + @Test + public void testLatestModeReturnFirst() { + ArbitraryResourceData first = new ArbitraryResourceData(); + first.name = "joe"; + first.service = Service.FILE; + first.created = 1L; + + ArbitraryResourceData last = new ArbitraryResourceData(); + last.name = "joe"; + last.service = Service.FILE; + last.created = 2L; + + List + results = filterListByMap( + List.of(first, last), + NAME_LEVEL, + new HashMap<>(Map.of(MODE, SearchMode.LATEST)), + 1 + ); + + ArbitraryResourceData singleResult = results.get(0); + Assert.assertTrue( singleResult.created == 2 ); + } + + @Test + public void testLatestModeReturn2From4() { + ArbitraryResourceData firstFile = new ArbitraryResourceData(); + firstFile.name = "joe"; + firstFile.service = Service.FILE; + firstFile.created = 1L; + + ArbitraryResourceData lastFile = new ArbitraryResourceData(); + lastFile.name = "joe"; + lastFile.service = Service.FILE; + lastFile.created = 2L; + + List + results = filterListByMap( + List.of(firstFile, lastFile), + NAME_LEVEL, + new HashMap<>(Map.of(MODE, SearchMode.LATEST)), + 1 + ); + + ArbitraryResourceData singleResult = results.get(0); + Assert.assertTrue( singleResult.created == 2 ); + } + + @Test + public void testServicePositive() { + ArbitraryResourceData data = new ArbitraryResourceData(); + data.service = Service.AUDIO; + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(SERVICE, Service.AUDIO)), + 1 + ); + } + + @Test + public void testQueryPositiveDescription() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.metadata = new ArbitraryResourceMetadata(); + data.metadata.setDescription("has keyword"); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(QUERY, "keyword")), + 1 + ); + } + + @Test + public void testQueryNegativeDescriptionPrefix() { + ArbitraryResourceData data = new ArbitraryResourceData(); + data.metadata = new ArbitraryResourceMetadata(); + data.metadata.setDescription("has keyword"); + data.name = "Joe"; + + filterListByMap(List.of(data), + NAME_LEVEL, new HashMap<>((Map.of(QUERY, "keyword", PREFIX_ONLY, true))), + 0); + } + + @Test + public void testQueryPositiveDescriptionPrefix() { + ArbitraryResourceData data = new ArbitraryResourceData(); + data.metadata = new ArbitraryResourceMetadata(); + data.metadata.setDescription("keyword starts sentence"); + data.name = "Joe"; + + filterListByMap(List.of(data), + NAME_LEVEL, new HashMap<>((Map.of(QUERY, "keyword", PREFIX_ONLY, true))), + 1); + } + + @Test + public void testQueryNegative() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + + data.name = "admin"; + data.identifier = "id-0"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(QUERY, "keyword")), + 0 + ); + } + + @Test + public void testExactNamePositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(EXACT_MATCH_NAMES,List.of("Joe"))), + 1 + ); + } + + @Test + public void testExactNameNegative() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(EXACT_MATCH_NAMES,List.of("Joey"))), + 0 + ); + } + + @Test + public void testNamePositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Mr Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(NAMES,List.of("Joe"))), + 1 + ); + } + + @Test + public void testNameNegative() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Jay"; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(NAMES,List.of("Joe"))), + 0 + ); + } + + @Test + public void testNamePrefixOnlyPositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joey"; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(NAMES,List.of("Joe"), PREFIX_ONLY, true)), + 1 + ); + } + + @Test + public void testNamePrefixOnlyNegative() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(NAMES,List.of("Joey"), PREFIX_ONLY, true)), + 0 + ); + } + + @Test + public void testIdentifierPositive() { + ArbitraryResourceData data = new ArbitraryResourceData(); + data.identifier = "007"; + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(IDENTIFIER, "007")), + 1 + ); + } + + @Test + public void testAfterPositive() { + ArbitraryResourceData data = new ArbitraryResourceData(); + data.created = 10L; + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(AFTER, 9L)), + 1 + ); + } + + @Test + public void testAfterNegative() { + ArbitraryResourceData data = new ArbitraryResourceData(); + data.created = 10L; + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(AFTER, 11L)), + 0 + ); + } + + @Test + public void testBeforePositive(){ + ArbitraryResourceData data = new ArbitraryResourceData(); + data.created = 10L; + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(BEFORE, 11L)), + 1 + ); + } + + @Test + public void testBeforeNegative(){ + ArbitraryResourceData data = new ArbitraryResourceData(); + data.created = 10L; + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(BEFORE, 9L)), + 0 + ); + } + + @Test + public void testTitlePositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.metadata = new ArbitraryResourceMetadata(); + data.metadata.setTitle("Sunday"); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(TITLE, "Sunday")), + 1 + ); + } + + @Test + public void testDescriptionPositive(){ + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.metadata = new ArbitraryResourceMetadata(); + data.metadata.setDescription("Once upon a time."); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(DESCRIPTION, "Once upon a time.")), + 1 + ); + } + + @Test + public void testMetadataNullificationBugSolution(){ + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.metadata = new ArbitraryResourceMetadata(); + data.metadata.setDescription("Once upon a time."); + data.name = "Joe"; + + List list = List.of(data); + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(DESCRIPTION, "Once upon a time.")), + 1 + ); + + Assert.assertNotNull(data.metadata); + } + + @Test + public void testMinLevelPositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(MIN_LEVEL, 4)), + 1 + ); + } + + @Test + public void testMinLevelNegative() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(MIN_LEVEL, 5)), + 0 + ); + } + + @Test + public void testDefaultResourcePositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(DEFAULT_RESOURCE, true)), + 1 + ); + } + + @Test + public void testFollowedNamesPositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + Supplier> supplier = () -> List.of("admin"); + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(FOLLOWED_ONLY, supplier)), + 1 + ); + } + + @Test + public void testExcludeBlockedNegative() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + Supplier> supplier = () -> List.of("admin"); + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(EXCLUDE_BLOCKED, supplier)), + 1 + ); + } + + @Test + public void testExcludeBlockedPositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + Supplier> supplier = () -> List.of("Joe"); + + filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(EXCLUDE_BLOCKED, supplier)), + 0 + ); + } + + @Test + public void testIncludeMetadataPositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.metadata = new ArbitraryResourceMetadata(); + data.name = "Joe"; + + ArbitraryResourceData result + = filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(INCLUDE_METADATA, true)), + 1 + ).get(0); + + Assert.assertNotNull(result); + Assert.assertNotNull(result.metadata); + } + + @Test + public void testIncludesMetadataNegative() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.metadata = new ArbitraryResourceMetadata(); + data.name = "Joe"; + + ArbitraryResourceData result + = filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(INCLUDE_METADATA, false)), + 1 + ).get(0); + + Assert.assertNotNull(result); + Assert.assertNull(result.metadata); + } + + @Test + public void testIncludeStatusPositive() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.status = new ArbitraryResourceStatus(); + data.name = "Joe"; + + ArbitraryResourceData result + = filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(INCLUDE_STATUS, true)), + 1 + ).get(0); + + Assert.assertNotNull(result); + Assert.assertNotNull(result.status); + } + + @Test + public void testIncludeStatusNegative() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.status = new ArbitraryResourceStatus(); + data.name = "Joe"; + + ArbitraryResourceData result + = filterListByMap( + List.of(data), + NAME_LEVEL, new HashMap<>(Map.of(INCLUDE_STATUS, false)), + 1 + ).get(0); + + Assert.assertNotNull(result); + Assert.assertNull(result.status); + } + + @Test + public void testLimit() { + + ArbitraryResourceData data1 = new ArbitraryResourceData(); + data1.name = "Joe"; + + ArbitraryResourceData data2 = new ArbitraryResourceData(); + data2.name = "Joe"; + + ArbitraryResourceData data3 = new ArbitraryResourceData(); + data3.name = "Joe"; + + filterListByMap( + List.of(data1, data2, data3), + NAME_LEVEL, new HashMap<>(Map.of(LIMIT, 2)), + 2 + ); + } + + @Test + public void testLimitZero() { + + ArbitraryResourceData data = new ArbitraryResourceData(); + data.name = "Joe"; + + filterListByMap( + List.of(data), + NAME_LEVEL, + new HashMap<>(Map.of(LIMIT, 0)), + 1 + ); + } + + @Test + public void testOffset() { + + ArbitraryResourceData data1 = new ArbitraryResourceData(); + data1.created = 1L; + data1.name = "Joe"; + + ArbitraryResourceData data2 = new ArbitraryResourceData(); + data2.created = 2L; + data2.name = "Joe"; + + ArbitraryResourceData data3 = new ArbitraryResourceData(); + data3.created = 3L; + data3.name = "Joe"; + + List result + = filterListByMap( + List.of(data1, data2, data3), + NAME_LEVEL, new HashMap<>(Map.of(OFFSET, 1)), + 2 + ); + + Assert.assertNotNull(result.get(0)); + Assert.assertTrue(2L == result.get(0).created); + } + + @Test + public void testOrder() { + + ArbitraryResourceData data2 = new ArbitraryResourceData(); + data2.created = 2L; + data2.name = "Joe"; + + ArbitraryResourceData data1 = new ArbitraryResourceData(); + data1.created = 1L; + data1.name = "Joe"; + + List result + = filterListByMap( + List.of(data2, data1), + NAME_LEVEL, new HashMap<>(), + 2 + ); + + Assert.assertNotNull(result.get(0)); + Assert.assertTrue( result.get(0).created == 1L ); + } + + @Test + public void testReverseOrder() { + ArbitraryResourceData data1 = new ArbitraryResourceData(); + data1.created = 1L; + data1.name = "Joe"; + + ArbitraryResourceData data2 = new ArbitraryResourceData(); + data2.created = 2L; + data2.name = "Joe"; + + List result + = filterListByMap( + List.of(data1, data2), + NAME_LEVEL, new HashMap<>(Map.of(REVERSE, true)), + 2); + + Assert.assertNotNull( result.get(0)); + Assert.assertTrue( result.get(0).created == 2L); + + } + public static List filterListByMap( + List candidates, + Map levelByName, + HashMap valueByKey, + int sizeToAssert) { + + Optional service = Optional.ofNullable((Service) valueByKey.get(SERVICE)); + Optional query = Optional.ofNullable( (String) valueByKey.get(QUERY)); + Optional identifier = Optional.ofNullable((String) valueByKey.get(IDENTIFIER)); + Optional> names = Optional.ofNullable((List) valueByKey.get(NAMES)); + Optional title = Optional.ofNullable((String) valueByKey.get(TITLE)); + Optional description = Optional.ofNullable((String) valueByKey.get(DESCRIPTION)); + boolean prefixOnly = valueByKey.containsKey(PREFIX_ONLY); + Optional> exactMatchNames = Optional.ofNullable((List) valueByKey.get(EXACT_MATCH_NAMES)); + Optional> keywords = Optional.ofNullable((List) valueByKey.get(KEYWORDS)); + boolean defaultResource = valueByKey.containsKey(DEFAULT_RESOURCE); + Optional mode = Optional.of((SearchMode) valueByKey.getOrDefault(MODE, SearchMode.ALL)); + Optional minLevel = Optional.ofNullable((Integer) valueByKey.get(MIN_LEVEL)); + Optional>> followedOnly = Optional.ofNullable((Supplier>) valueByKey.get(FOLLOWED_ONLY)); + Optional>> excludeBlocked = Optional.ofNullable((Supplier>) valueByKey.get(EXCLUDE_BLOCKED)); + Optional includeMetadata = Optional.ofNullable((Boolean) valueByKey.get(INCLUDE_METADATA)); + Optional includeStatus = Optional.ofNullable((Boolean) valueByKey.get(INCLUDE_STATUS)); + Optional before = Optional.ofNullable((Long) valueByKey.get(BEFORE)); + Optional after = Optional.ofNullable((Long) valueByKey.get(AFTER)); + Optional limit = Optional.ofNullable((Integer) valueByKey.get(LIMIT)); + Optional offset = Optional.ofNullable((Integer) valueByKey.get(OFFSET)); + Optional reverse = Optional.ofNullable((Boolean) valueByKey.get(REVERSE)); + + List filteredList + = HSQLDBCacheUtils.filterList( + candidates, + levelByName, + mode, + service, + query, + identifier, + names, + title, + description, + prefixOnly, + exactMatchNames, + keywords, + defaultResource, + minLevel, + followedOnly, + excludeBlocked, + includeMetadata, + includeStatus, + before, + after, + limit, + offset, + reverse); + + Assert.assertEquals(sizeToAssert, filteredList.size()); + + return filteredList; + } +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/utils/BalanceRecorderUtilsTests.java b/src/test/java/org/qortal/test/utils/BalanceRecorderUtilsTests.java new file mode 100644 index 00000000..0a35657a --- /dev/null +++ b/src/test/java/org/qortal/test/utils/BalanceRecorderUtilsTests.java @@ -0,0 +1,763 @@ +package org.qortal.test.utils; + +import org.junit.Assert; +import org.junit.Test; +import org.qortal.asset.Asset; +import org.qortal.block.Block; +import org.qortal.crypto.Crypto; +import org.qortal.data.PaymentData; +import org.qortal.data.account.AccountBalanceData; +import org.qortal.data.account.AddressAmountData; +import org.qortal.data.account.BlockHeightRange; +import org.qortal.data.account.BlockHeightRangeAddressAmounts; +import org.qortal.data.transaction.ATTransactionData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.BuyNameTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MultiPaymentTransactionData; +import org.qortal.data.transaction.PaymentTransactionData; +import org.qortal.data.transaction.RegisterNameTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.data.transaction.TransferAssetTransactionData; +import org.qortal.utils.BalanceRecorderUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +public class BalanceRecorderUtilsTests { + + public static final String RECIPIENT_ADDRESS = "recipient"; + public static final String AT_ADDRESS = "atAddress"; + public static final String OTHER = "Other"; + + @Test + public void testNotZeroForZero() { + boolean test = BalanceRecorderUtils.ADDRESS_AMOUNT_DATA_NOT_ZERO.test( new AddressAmountData("", 0)); + + Assert.assertFalse(test); + } + + @Test + public void testNotZeroForPositive() { + boolean test = BalanceRecorderUtils.ADDRESS_AMOUNT_DATA_NOT_ZERO.test(new AddressAmountData("", 1)); + + Assert.assertTrue(test); + } + + @Test + public void testNotZeroForNegative() { + boolean test = BalanceRecorderUtils.ADDRESS_AMOUNT_DATA_NOT_ZERO.test( new AddressAmountData("", -10)); + + Assert.assertTrue(test); + } + + @Test + public void testAddressAmountComparatorReverseOrder() { + + BlockHeightRangeAddressAmounts addressAmounts1 = new BlockHeightRangeAddressAmounts(new BlockHeightRange(2, 3, false), new ArrayList<>(0)); + BlockHeightRangeAddressAmounts addressAmounts2 = new BlockHeightRangeAddressAmounts(new BlockHeightRange(1, 2, false), new ArrayList<>(0)); + + int compare = BalanceRecorderUtils.BLOCK_HEIGHT_RANGE_ADDRESS_AMOUNTS_COMPARATOR.compare(addressAmounts1, addressAmounts2); + + Assert.assertTrue( compare > 0); + } + + @Test + public void testAddressAmountComparatorForwardOrder() { + + BlockHeightRangeAddressAmounts addressAmounts1 = new BlockHeightRangeAddressAmounts(new BlockHeightRange(1, 2, false), new ArrayList<>(0)); + BlockHeightRangeAddressAmounts addressAmounts2 = new BlockHeightRangeAddressAmounts(new BlockHeightRange(2, 3, false), new ArrayList<>(0)); + + int compare = BalanceRecorderUtils.BLOCK_HEIGHT_RANGE_ADDRESS_AMOUNTS_COMPARATOR.compare(addressAmounts1, addressAmounts2); + + Assert.assertTrue( compare < 0 ); + } + + @Test + public void testAddressAmountDataComparator() { + + AddressAmountData addressAmount1 = new AddressAmountData("a", 10); + AddressAmountData addressAmount2 = new AddressAmountData("b", 20); + + int compare = BalanceRecorderUtils.ADDRESS_AMOUNT_DATA_COMPARATOR.compare(addressAmount1, addressAmount2); + + Assert.assertTrue( compare < 0); + } + + @Test + public void testRemoveRecordingsBelowHeightNoBalances() { + + int currentHeight = 5; + ConcurrentHashMap> balancesByHeight = new ConcurrentHashMap<>(); + + BalanceRecorderUtils.removeRecordingsBelowHeight(currentHeight, balancesByHeight); + + Assert.assertEquals(0, balancesByHeight.size()); + } + + @Test + public void testRemoveRecordingsBelowHeightOneBalanceBelow() { + int currentHeight = 5; + + ConcurrentHashMap> balancesByHeight = new ConcurrentHashMap<>(1); + + balancesByHeight.put(1, new ArrayList<>(0)); + + Assert.assertEquals(1, balancesByHeight.size()); + + BalanceRecorderUtils.removeRecordingsBelowHeight(currentHeight, balancesByHeight); + + Assert.assertEquals(0, balancesByHeight.size()); + } + + @Test + public void testRemoveRecordingsBelowHeightOneBalanceAbove() { + int currentHeight = 5; + + ConcurrentHashMap> balancesByHeight = new ConcurrentHashMap<>(1); + + balancesByHeight.put(10, new ArrayList<>(0)); + + Assert.assertEquals(1, balancesByHeight.size()); + + BalanceRecorderUtils.removeRecordingsBelowHeight(currentHeight, balancesByHeight); + + Assert.assertEquals(1, balancesByHeight.size()); + } + + @Test + public void testBuildBalanceDynamicsOneAccountOneChange() { + + String address = "a"; + + List balances = new ArrayList<>(1); + balances.add(new AccountBalanceData(address, 0, 2)); + + List priorBalances = new ArrayList<>(1); + priorBalances.add(new AccountBalanceData(address, 0, 1)); + + List dynamics = BalanceRecorderUtils.buildBalanceDynamics(balances, priorBalances, 0, new ArrayList<>(0)); + + Assert.assertNotNull(dynamics); + Assert.assertEquals(1, dynamics.size()); + + AddressAmountData addressAmountData = dynamics.get(0); + Assert.assertNotNull(addressAmountData); + Assert.assertEquals(address, addressAmountData.getAddress()); + Assert.assertEquals(1, addressAmountData.getAmount()); + } + + @Test + public void testBuildBalanceDynamicsOneAccountNoPrior() { + + String address = "a"; + + List balances = new ArrayList<>(1); + balances.add(new AccountBalanceData(address, 0, 2)); + + List priorBalances = new ArrayList<>(0); + + List dynamics = BalanceRecorderUtils.buildBalanceDynamics(balances, priorBalances, 0, new ArrayList<>(0)); + + Assert.assertNotNull(dynamics); + Assert.assertEquals(1, dynamics.size()); + + AddressAmountData addressAmountData = dynamics.get(0); + Assert.assertNotNull(addressAmountData); + Assert.assertEquals(address, addressAmountData.getAddress()); + Assert.assertEquals(2, addressAmountData.getAmount()); + } + + @Test + public void testBuildBalanceDynamicOneAccountAdjustment() { + List balances = new ArrayList<>(1); + balances.add(new AccountBalanceData(RECIPIENT_ADDRESS, 0, 20)); + + List priorBalances = new ArrayList<>(0); + priorBalances.add(new AccountBalanceData(RECIPIENT_ADDRESS, 0, 12)); + + List transactions = new ArrayList<>(); + + final long amount = 5L; + final long fee = 1L; + + boolean exceptionThrown = false; + + try { + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + + PaymentTransactionData paymentData + = new PaymentTransactionData( + new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + RECIPIENT_ADDRESS, + amount + ); + + transactions.add(paymentData); + + List dynamics + = BalanceRecorderUtils.buildBalanceDynamics( + balances, + priorBalances, + 0, + transactions + ); + + Assert.assertNotNull(dynamics); + Assert.assertEquals(1, dynamics.size()); + + AddressAmountData addressAmountData = dynamics.get(0); + Assert.assertNotNull(addressAmountData); + Assert.assertEquals(RECIPIENT_ADDRESS, addressAmountData.getAddress()); + Assert.assertEquals(3, addressAmountData.getAmount()); + } catch( Exception e ) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testBuildBalanceDynamicsTwoAccountsNegativeValues() { + + String address1 = "a"; + String address2 = "b"; + + List balances = new ArrayList<>(2); + balances.add(new AccountBalanceData(address1, 0, 10_000)); + balances.add(new AccountBalanceData(address2, 0, 100)); + + List priorBalances = new ArrayList<>(2); + priorBalances.add(new AccountBalanceData(address2, 0, 200)); + priorBalances.add(new AccountBalanceData(address1, 0, 5000)); + + List dynamics = BalanceRecorderUtils.buildBalanceDynamics(balances, priorBalances, -100L, new ArrayList<>(0)); + + Assert.assertNotNull(dynamics); + Assert.assertEquals(2, dynamics.size()); + + Map amountByAddress + = dynamics.stream() + .collect(Collectors.toMap(dynamic -> dynamic.getAddress(), dynamic -> dynamic.getAmount())); + + Assert.assertTrue(amountByAddress.containsKey(address1)); + + long amount1 = amountByAddress.get(address1); + + Assert.assertNotNull(amount1); + Assert.assertEquals(5000L, amount1 ); + + Assert.assertTrue(amountByAddress.containsKey(address2)); + + long amount2 = amountByAddress.get(address2); + + Assert.assertNotNull(amount2); + Assert.assertEquals(-100L, amount2); + } + + @Test + public void testBuildBalanceDynamicsForAccountNoPriorAnyAccount() { + List priorBalances = new ArrayList<>(0); + AccountBalanceData accountBalance = new AccountBalanceData("a", 0, 10); + + AddressAmountData dynamic = BalanceRecorderUtils.buildBalanceDynamicsForAccount(priorBalances, accountBalance); + + Assert.assertNotNull(dynamic); + Assert.assertEquals(10, dynamic.getAmount()); + Assert.assertEquals("a", dynamic.getAddress()); + } + + @Test + public void testBuildBalanceDynamicsForAccountNoPriorThisAccount() { + List priorBalances = new ArrayList<>(2); + priorBalances.add(new AccountBalanceData("b", 0, 100)); + + AccountBalanceData accountBalanceData = new AccountBalanceData("a", 0, 10); + + AddressAmountData dynamic = BalanceRecorderUtils.buildBalanceDynamicsForAccount(priorBalances, accountBalanceData); + + Assert.assertNotNull(dynamic); + Assert.assertEquals(10, dynamic.getAmount()); + Assert.assertEquals("a", dynamic.getAddress()); + } + + @Test + public void testBuildBalanceDynamicsForAccountPriorForThisAndOthers() { + List priorBalances = new ArrayList<>(2); + priorBalances.add(new AccountBalanceData("a", 0, 100)); + priorBalances.add(new AccountBalanceData("b", 0, 200)); + priorBalances.add(new AccountBalanceData("c", 0, 300)); + + AccountBalanceData accountBalance = new AccountBalanceData("b", 0, 1000); + + AddressAmountData dynamic = BalanceRecorderUtils.buildBalanceDynamicsForAccount(priorBalances, accountBalance); + + Assert.assertNotNull(dynamic); + Assert.assertEquals(800, dynamic.getAmount()); + Assert.assertEquals("b", dynamic.getAddress()); + } + + @Test + public void testRemoveRecordingAboveHeightOneOfTwo() { + + int currentHeight = 10; + ConcurrentHashMap> balancesByHeight = new ConcurrentHashMap<>(); + + balancesByHeight.put(3, new ArrayList<>()); + balancesByHeight.put(20, new ArrayList<>()); + + Assert.assertEquals(2, balancesByHeight.size()); + + BalanceRecorderUtils.removeRecordingsAboveHeight(currentHeight, balancesByHeight); + + Assert.assertEquals(1, balancesByHeight.size()); + Assert.assertTrue( balancesByHeight.containsKey(3)); + } + + @Test + public void testPriorHeightBeforeAfter() { + + int currentHeight = 10; + ConcurrentHashMap> balancesByHeight = new ConcurrentHashMap<>(); + balancesByHeight.put( 2, new ArrayList<>()); + balancesByHeight.put(7, new ArrayList<>()); + balancesByHeight.put(12, new ArrayList<>()); + + Optional priorHeight = BalanceRecorderUtils.getPriorHeight(currentHeight, balancesByHeight); + + Assert.assertNotNull(priorHeight); + Assert.assertTrue(priorHeight.isPresent()); + Assert.assertEquals( 7, priorHeight.get().intValue()); + } + + @Test + public void testPriorHeightNoPriorAfterOnly() { + + int currentHeight = 10; + ConcurrentHashMap> balancesByHeight = new ConcurrentHashMap<>(); + balancesByHeight.put(12, new ArrayList<>()); + + Optional priorHeight = BalanceRecorderUtils.getPriorHeight(currentHeight, balancesByHeight); + + Assert.assertNotNull(priorHeight); + Assert.assertTrue(priorHeight.isEmpty()); + } + + @Test + public void testPriorHeightPriorOnly() { + + int currentHeight = 10; + + ConcurrentHashMap> balancesByHeight = new ConcurrentHashMap<>(); + balancesByHeight.put(7, new ArrayList<>()); + + Optional priorHeight = BalanceRecorderUtils.getPriorHeight(currentHeight, balancesByHeight); + + Assert.assertNotNull(priorHeight); + Assert.assertTrue(priorHeight.isPresent()); + Assert.assertEquals(7, priorHeight.get().intValue()); + } + + @Test + public void testRemoveDynamicsOnOrAboveHeightOneAbove() { + + int currentHeight = 10; + + CopyOnWriteArrayList dynamics = new CopyOnWriteArrayList<>(); + + BlockHeightRange range1 = new BlockHeightRange(10, 20, false); + dynamics.add(new BlockHeightRangeAddressAmounts(range1, new ArrayList<>())); + + BlockHeightRange range2 = new BlockHeightRange(1, 4, false); + dynamics.add(new BlockHeightRangeAddressAmounts(range2, new ArrayList<>())); + + Assert.assertEquals(2, dynamics.size()); + BalanceRecorderUtils.removeDynamicsOnOrAboveHeight(currentHeight, dynamics); + + Assert.assertEquals(1, dynamics.size()); + Assert.assertEquals(range2, dynamics.get(0).getRange()); + } + + @Test + public void testRemoveDynamicsOnOrAboveOneOnOneAbove() { + int currentHeight = 11; + + CopyOnWriteArrayList dynamics = new CopyOnWriteArrayList<>(); + + BlockHeightRange range1 = new BlockHeightRange(1,5, false); + dynamics.add(new BlockHeightRangeAddressAmounts(range1, new ArrayList<>())); + + BlockHeightRange range2 = new BlockHeightRange(6, 11, false); + dynamics.add((new BlockHeightRangeAddressAmounts(range2, new ArrayList<>()))); + + BlockHeightRange range3 = new BlockHeightRange(22, 16, false); + dynamics.add(new BlockHeightRangeAddressAmounts(range3, new ArrayList<>())); + + Assert.assertEquals(3, dynamics.size()); + + BalanceRecorderUtils.removeDynamicsOnOrAboveHeight(currentHeight, dynamics); + + Assert.assertEquals(1, dynamics.size()); + Assert.assertTrue( dynamics.get(0).getRange().equals(range1)); + } + + @Test + public void testRemoveOldestDynamicsTwice() { + CopyOnWriteArrayList dynamics = new CopyOnWriteArrayList<>(); + + dynamics.add(new BlockHeightRangeAddressAmounts(new BlockHeightRange(1, 5, false), new ArrayList<>())); + dynamics.add(new BlockHeightRangeAddressAmounts(new BlockHeightRange(5, 9, false), new ArrayList<>())); + + Assert.assertEquals(2, dynamics.size()); + + BalanceRecorderUtils.removeOldestDynamics(dynamics); + + Assert.assertEquals(1, dynamics.size()); + Assert.assertTrue(dynamics.get(0).getRange().equals(new BlockHeightRange(5, 9, false))); + + BalanceRecorderUtils.removeOldestDynamics(dynamics); + + Assert.assertEquals(0, dynamics.size()); + } + + @Test + public void testMapBalanceModificationsForPaymentTransaction() { + + boolean exceptionThrown = false; + + try { + final long amount = 1L; + final long fee = 1L; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + + PaymentTransactionData paymentData + = new PaymentTransactionData( + new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + RECIPIENT_ADDRESS, + amount + ); + + // map balance modifications for addresses in the transaction + Map amountsByAddress = new HashMap<>(); + BalanceRecorderUtils.mapBalanceModicationsForPaymentTransaction(amountsByAddress, paymentData); + + // this will not add the fee, that is done in a different place + assertAmountsByAddress(amountsByAddress, amount, creatorPublicKey, RECIPIENT_ADDRESS); + } catch (Exception e) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testMapBalanceModificationsForAssetOrderTransaction() { + + boolean exceptionThrown = false; + + try{ + final long amount = 1L; + final long fee = 1L; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + + TransferAssetTransactionData transferAssetData + = new TransferAssetTransactionData( + new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + RECIPIENT_ADDRESS, + amount, + 0 + ); + + // map balance modifications for addresses in the transaction + Map amountsByAddress = new HashMap<>(); + BalanceRecorderUtils.mapBalanceModificationsForTransferAssetTransaction(amountsByAddress, transferAssetData); + + assertAmountsByAddress(amountsByAddress, amount, creatorPublicKey, RECIPIENT_ADDRESS); + } catch( Exception e) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testMapBalanceModificationsForATTransactionMessageType() { + + boolean exceptionThrown = false; + + try { + + final long fee = 1L; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + Map amountsByAddress = new HashMap<>(); + + ATTransactionData atTransactionData = new ATTransactionData(new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + AT_ADDRESS, + RECIPIENT_ADDRESS, + new byte[0]); + BalanceRecorderUtils.mapBalanceModificationsForAtTransaction( amountsByAddress, atTransactionData); + + // no balance changes for AT message + Assert.assertTrue(amountsByAddress.size() == 0); + } catch( Exception e) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testMapBalanceModificationsForATTransactionPaymentType() { + + boolean exceptionThrown = false; + + try{ + final long amount = 1L; + final long fee = 1L; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + + Map amountsByAddress = new HashMap<>(); + + ATTransactionData atTransactionData + = new ATTransactionData( + new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + AT_ADDRESS, + RECIPIENT_ADDRESS, + amount, + 0 + ); + + BalanceRecorderUtils.mapBalanceModificationsForAtTransaction( amountsByAddress, atTransactionData); + + assertAmountByAddress(amountsByAddress, amount, RECIPIENT_ADDRESS); + + assertAmountByAddress(amountsByAddress, -amount, AT_ADDRESS); + } catch( Exception e) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testMapBalanceModificationsForBuyNameTransaction() { + + boolean exceptionThrown = false; + + try{ + final long amount = 100L; + final long fee = 1L; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + Map amountsByAddress = new HashMap<>(); + + BuyNameTransactionData buyNameData + = new BuyNameTransactionData( + new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + "null", + amount, + RECIPIENT_ADDRESS + ); + + BalanceRecorderUtils.mapBalanceModificationsForBuyNameTransaction(amountsByAddress, buyNameData); + + assertAmountsByAddress(amountsByAddress, amount, creatorPublicKey, RECIPIENT_ADDRESS); + } catch( Exception e) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testMapBalanceModificationsForMultiPaymentTransaction() { + + boolean exceptionThrown = false; + + try{ + final long amount = 100L; + final long fee = 1L; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + Map amountsByAddress = new HashMap<>(); + + List payments = new ArrayList<>(); + + payments.add(new PaymentData(RECIPIENT_ADDRESS, 0, amount)); + + MultiPaymentTransactionData multiPayment + = new MultiPaymentTransactionData(new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + payments); + BalanceRecorderUtils.mapBalanceModificationsForMultiPaymentTransaction(amountsByAddress,multiPayment); + assertAmountsByAddress(amountsByAddress, amount, creatorPublicKey, RECIPIENT_ADDRESS); + } catch( Exception e ) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testMapBalanceModificationsForMultiPaymentTransaction2PaymentsOneAddress() { + + boolean exceptionThrown = false; + + try{ + final long amount = 100L; + final long fee = 1L; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + Map amountsByAddress = new HashMap<>(); + + List payments = new ArrayList<>(); + + payments.add(new PaymentData(RECIPIENT_ADDRESS, 0, amount)); + payments.add(new PaymentData(RECIPIENT_ADDRESS, 0, amount)); + + MultiPaymentTransactionData multiPayment + = new MultiPaymentTransactionData(new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + payments); + BalanceRecorderUtils.mapBalanceModificationsForMultiPaymentTransaction(amountsByAddress,multiPayment); + assertAmountsByAddress(amountsByAddress, 2*amount, creatorPublicKey, RECIPIENT_ADDRESS); + } catch( Exception e ) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testMapBalanceModificationsForMultiPaymentTransaction2PaymentsTwoAddresses() { + + boolean exceptionThrown = false; + + try{ + final long amount = 100L; + final long fee = 1L; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + Map amountsByAddress = new HashMap<>(); + + List payments = new ArrayList<>(); + + payments.add(new PaymentData(RECIPIENT_ADDRESS, 0, amount)); + payments.add(new PaymentData(OTHER, 0, amount)); + + MultiPaymentTransactionData multiPayment + = new MultiPaymentTransactionData(new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + payments); + BalanceRecorderUtils.mapBalanceModificationsForMultiPaymentTransaction(amountsByAddress,multiPayment); + assertAmountByAddress(amountsByAddress, amount, RECIPIENT_ADDRESS); + assertAmountByAddress(amountsByAddress, amount, OTHER); + + String creatorAddress = Crypto.toAddress(creatorPublicKey); + + assertAmountByAddress(amountsByAddress, 2*-amount, creatorAddress); + } catch( Exception e ) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testMapBalanceModificationsForDeployAtTransaction() { + + boolean exceptionThrown = false; + + try{ + final long amount = 3L; + final long fee = 1L; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + Map amountsByAddress = new HashMap<>(); + + DeployAtTransactionData deployAt + = new DeployAtTransactionData( + new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + AT_ADDRESS, "name", "description", "type", "tags", new byte[0], amount, Asset.QORT + ); + + BalanceRecorderUtils.mapBalanceModificationsForDeployAtTransaction(amountsByAddress,deployAt); + assertAmountsByAddress(amountsByAddress, amount, creatorPublicKey, AT_ADDRESS); + } catch( Exception e) { + exceptionThrown = true; + e.printStackTrace(); + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testMapBalanceModificationsForTransaction() { + + boolean exceptionThrown = false; + + try { + final long fee = 2; + + byte[] creatorPublicKey = TestUtils.generatePublicKey(); + Map amountsByAddress = new HashMap<>(); + + BalanceRecorderUtils.mapBalanceModificationsForTransaction( + amountsByAddress, + new RegisterNameTransactionData( + new BaseTransactionData(0L, 0, null, creatorPublicKey, fee, null), + "aaa", "data", "aaa") + ); + + String creatorAddress = Crypto.toAddress(creatorPublicKey); + + assertAmountByAddress(amountsByAddress, -fee, creatorAddress); + } catch(Exception e) { + exceptionThrown = true; + } + + Assert.assertFalse(exceptionThrown); + } + + @Test + public void testBlockHeightRangeEqualityTrue() { + + BlockHeightRange range1 = new BlockHeightRange(2, 4, false); + BlockHeightRange range2 = new BlockHeightRange(2, 4, true); + + Assert.assertTrue(range1.equals(range2)); + Assert.assertEquals(range1, range2); + } + + @Test + public void testBloHeightRangeEqualityFalse() { + + BlockHeightRange range1 = new BlockHeightRange(2, 3, true); + BlockHeightRange range2 = new BlockHeightRange(2, 4, true); + + Assert.assertFalse(range1.equals(range2)); + } + + private static void assertAmountsByAddress(Map amountsByAddress, long amount, byte[] creatorPublicKey, String recipientAddress) { + assertAmountByAddress(amountsByAddress, amount, recipientAddress); + + String creatorAddress = Crypto.toAddress(creatorPublicKey); + + assertAmountByAddress(amountsByAddress, -amount, creatorAddress); + } + + private static void assertAmountByAddress(Map amountsByAddress, long amount, String address) { + Long amountForAddress = amountsByAddress.get(address); + + Assert.assertTrue(amountsByAddress.containsKey(address)); + Assert.assertNotNull(amountForAddress); + Assert.assertEquals(amount, amountForAddress.longValue()); + } +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/utils/GroupsTestUtils.java b/src/test/java/org/qortal/test/utils/GroupsTestUtils.java new file mode 100644 index 00000000..52f106a7 --- /dev/null +++ b/src/test/java/org/qortal/test/utils/GroupsTestUtils.java @@ -0,0 +1,102 @@ +package org.qortal.test.utils; + +import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.transaction.CreateGroupTransactionData; +import org.qortal.data.transaction.GroupInviteTransactionData; +import org.qortal.data.transaction.JoinGroupTransactionData; +import org.qortal.data.transaction.LeaveGroupTransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.transaction.TestTransaction; + +/** + * Class GroupsTestUtils + * + * Utility methods for testing the Groups class. + */ +public class GroupsTestUtils { + + /** + * Create Group + * + * @param repository the data repository + * @param owner the group owner + * @param groupName the group name + * @param isOpen true if the group is public, false for private + * + * @return the group Id + * @throws DataException + */ + public static Integer createGroup(Repository repository, PrivateKeyAccount owner, String groupName, boolean isOpen) throws DataException { + String description = groupName + " (description)"; + + Group.ApprovalThreshold approvalThreshold = Group.ApprovalThreshold.ONE; + int minimumBlockDelay = 10; + int maximumBlockDelay = 1440; + + CreateGroupTransactionData transactionData = new CreateGroupTransactionData(TestTransaction.generateBase(owner), groupName, description, isOpen, approvalThreshold, minimumBlockDelay, maximumBlockDelay); + TransactionUtils.signAndMint(repository, transactionData, owner); + + return repository.getGroupRepository().fromGroupName(groupName).getGroupId(); + } + + /** + * Join Group + * + * @param repository the data repository + * @param joiner the address for the account joining the group + * @param groupId the Id for the group to join + * + * @throws DataException + */ + public static void joinGroup(Repository repository, PrivateKeyAccount joiner, int groupId) throws DataException { + JoinGroupTransactionData transactionData = new JoinGroupTransactionData(TestTransaction.generateBase(joiner), groupId); + TransactionUtils.signAndMint(repository, transactionData, joiner); + } + + /** + * Group Invite + * + * @param repository the data repository + * @param admin the admin account to sign the invite + * @param groupId the Id of the group to invite to + * @param invitee the recipient address for the invite + * @param timeToLive the time length of the invite + * + * @throws DataException + */ + public static 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); + } + + /** + * Leave Group + * + * @param repository the data repository + * @param leaver the account leaving + * @param groupId the Id of the group being left + * + * @throws DataException + */ + public static void leaveGroup(Repository repository, PrivateKeyAccount leaver, int groupId) throws DataException { + LeaveGroupTransactionData transactionData = new LeaveGroupTransactionData(TestTransaction.generateBase(leaver), groupId); + TransactionUtils.signAndMint(repository, transactionData, leaver); + } + + /** + * Is Member? + * + * @param repository the data repository + * @param address the account address + * @param groupId the group Id + * + * @return true if the account is a member of the group, otherwise false + * @throws DataException + */ + public static boolean isMember(Repository repository, String address, int groupId) throws DataException { + return repository.getGroupRepository().memberExists(groupId, address); + } +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/utils/GroupsTests.java b/src/test/java/org/qortal/test/utils/GroupsTests.java new file mode 100644 index 00000000..c9a69f1f --- /dev/null +++ b/src/test/java/org/qortal/test/utils/GroupsTests.java @@ -0,0 +1,199 @@ +package org.qortal.test.utils; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.block.Block; +import org.qortal.block.BlockChain; +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.utils.Groups; + +import java.util.List; + +import static org.junit.Assert.*; + +public class GroupsTests extends Common { + + public static final String ALICE = "alice"; + public static final String BOB = "bob"; + public static final String CHLOE = "chloe"; + public static final String DILBERT = "dilbert"; + + + private static final int HEIGHT_1 = 5; + private static final int HEIGHT_2 = 8; + private static final int HEIGHT_3 = 12; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @After + public void afterTest() throws DataException { + Common.orphanCheck(); + } + + @Test + public void testGetGroupIdsToMintSimple() { + List ids = Groups.getGroupIdsToMint(BlockChain.getInstance(), 0); + + Assert.assertNotNull(ids); + Assert.assertEquals(0, ids.size()); + } + + @Test + public void testGetGroupIdsToMintComplex() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + Block block1 = BlockUtils.mintBlocks(repository, HEIGHT_1); + int height1 = block1.getBlockData().getHeight().intValue(); + assertEquals(HEIGHT_1 + 1, height1); + + List ids1 = Groups.getGroupIdsToMint(BlockChain.getInstance(), height1); + + Assert.assertEquals(1, ids1.size() ); + Assert.assertTrue( ids1.contains( 694 ) ); + + Block block2 = BlockUtils.mintBlocks(repository, HEIGHT_2 - HEIGHT_1); + int height2 = block2.getBlockData().getHeight().intValue(); + assertEquals( HEIGHT_2 + 1, height2); + + List ids2 = Groups.getGroupIdsToMint(BlockChain.getInstance(), height2); + + Assert.assertEquals(2, ids2.size() ); + + Assert.assertTrue( ids2.contains( 694 ) ); + Assert.assertTrue( ids2.contains( 800 ) ); + + Block block3 = BlockUtils.mintBlocks(repository, HEIGHT_3 - HEIGHT_2); + int height3 = block3.getBlockData().getHeight().intValue(); + assertEquals( HEIGHT_3 + 1, height3); + + List ids3 = Groups.getGroupIdsToMint(BlockChain.getInstance(), height3); + + Assert.assertEquals( 1, ids3.size() ); + + Assert.assertTrue( ids3.contains( 800 ) ); + } + } + + @Test + public void testMemberExistsInAnyGroupSimple() throws DataException { + + try (final Repository repository = RepositoryManager.getRepository()) { + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Create group + int groupId = GroupsTestUtils.createGroup(repository, alice, "closed-group", false); + + // Confirm Bob is not a member + Assert.assertFalse( Groups.memberExistsInAnyGroup(repository.getGroupRepository(), List.of(groupId), bob.getAddress()) ); + + // Bob to join + GroupsTestUtils.joinGroup(repository, bob, groupId); + + // Confirm Bob still not a member + assertFalse(GroupsTestUtils.isMember(repository, bob.getAddress(), groupId)); + + // Have Alice 'invite' Bob to confirm membership + GroupsTestUtils.groupInvite(repository, alice, groupId, bob.getAddress(), 0); // non-expiring invite + + // Confirm Bob now a member + Assert.assertTrue( Groups.memberExistsInAnyGroup(repository.getGroupRepository(), List.of(groupId), bob.getAddress()) ); + } + } + + @Test + public void testGroupsListedFunctionality() throws DataException { + + try (final Repository repository = RepositoryManager.getRepository()) { + + PrivateKeyAccount alice = Common.getTestAccount(repository, ALICE); + PrivateKeyAccount bob = Common.getTestAccount(repository, BOB); + PrivateKeyAccount chloe = Common.getTestAccount(repository, CHLOE); + PrivateKeyAccount dilbert = Common.getTestAccount(repository, DILBERT); + + // Create groups + int group1Id = GroupsTestUtils.createGroup(repository, alice, "group-1", false); + int group2Id = GroupsTestUtils.createGroup(repository, bob, "group-2", false); + + // test memberExistsInAnyGroup + Assert.assertTrue(Groups.memberExistsInAnyGroup(repository.getGroupRepository(), List.of(group1Id, group2Id), alice.getAddress())); + Assert.assertFalse(Groups.memberExistsInAnyGroup(repository.getGroupRepository(), List.of(group1Id, group2Id), chloe.getAddress())); + + // alice is a member + Assert.assertTrue(GroupsTestUtils.isMember(repository, alice.getAddress(), group1Id)); + List allMembersBeforeJoin = Groups.getAllMembers(repository.getGroupRepository(), List.of(group1Id)); + + // assert one member + Assert.assertNotNull(allMembersBeforeJoin); + Assert.assertEquals(1, allMembersBeforeJoin.size()); + + List allAdminsBeforeJoin = Groups.getAllAdmins(repository.getGroupRepository(), List.of(group1Id)); + + // assert one admin + Assert.assertNotNull(allAdminsBeforeJoin); + Assert.assertEquals( 1, allAdminsBeforeJoin.size()); + + // Bob to join + GroupsTestUtils.joinGroup(repository, bob, group1Id); + + // Have Alice 'invite' Bob to confirm membership + GroupsTestUtils.groupInvite(repository, alice, group1Id, bob.getAddress(), 0); // non-expiring invite + + List allMembersAfterJoin = Groups.getAllMembers(repository.getGroupRepository(), List.of(group1Id)); + + // alice and bob are members + Assert.assertNotNull(allMembersAfterJoin); + Assert.assertEquals(2, allMembersAfterJoin.size()); + + List allAdminsAfterJoin = Groups.getAllAdmins(repository.getGroupRepository(), List.of(group1Id)); + + // assert still one admin + Assert.assertNotNull(allAdminsAfterJoin); + Assert.assertEquals(1, allAdminsAfterJoin.size()); + + List allAdminsFor2Groups = Groups.getAllAdmins(repository.getGroupRepository(), List.of(group1Id, group2Id)); + + // assert 2 admins when including the second group + Assert.assertNotNull(allAdminsFor2Groups); + Assert.assertEquals(2, allAdminsFor2Groups.size()); + + List allMembersFor2Groups = Groups.getAllMembers(repository.getGroupRepository(), List.of(group1Id, group2Id)); + + // assert 2 members when including the seconds group + Assert.assertNotNull(allMembersFor2Groups); + Assert.assertEquals(2, allMembersFor2Groups.size()); + + GroupsTestUtils.leaveGroup(repository, bob, group1Id); + + List allMembersForAfterBobLeavesGroup1InAllGroups = Groups.getAllMembers(repository.getGroupRepository(), List.of(group1Id, group2Id)); + + // alice and bob are members of one group still + Assert.assertNotNull(allMembersForAfterBobLeavesGroup1InAllGroups); + Assert.assertEquals(2, allMembersForAfterBobLeavesGroup1InAllGroups.size()); + + GroupsTestUtils.groupInvite(repository, alice, group1Id, chloe.getAddress(), 3600); + GroupsTestUtils.groupInvite(repository, bob, group2Id, chloe.getAddress(), 3600); + + GroupsTestUtils.joinGroup(repository, chloe, group1Id); + GroupsTestUtils.joinGroup(repository, chloe, group2Id); + + List allMembersAfterDilbert = Groups.getAllMembers((repository.getGroupRepository()), List.of(group1Id, group2Id)); + + // 3 accounts are now members of one group or another + Assert.assertNotNull(allMembersAfterDilbert); + Assert.assertEquals(3, allMembersAfterDilbert.size()); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/utils/TestUtils.java b/src/test/java/org/qortal/test/utils/TestUtils.java new file mode 100644 index 00000000..b66591a0 --- /dev/null +++ b/src/test/java/org/qortal/test/utils/TestUtils.java @@ -0,0 +1,48 @@ +package org.qortal.test.utils; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.security.Security; + +public class TestUtils { + public static byte[] generatePublicKey() throws Exception { + // Add the Bouncy Castle provider + Security.addProvider(new BouncyCastleProvider()); + + // Generate a key pair + KeyPair keyPair = generateKeyPair(); + + // Get the public key + PublicKey publicKey = keyPair.getPublic(); + + // Get the public key as a byte array + byte[] publicKeyBytes = publicKey.getEncoded(); + + // Generate a RIPEMD160 message digest from the public key + byte[] ripeMd160Digest = generateRipeMd160Digest(publicKeyBytes); + + return ripeMd160Digest; + } + + public static KeyPair generateKeyPair() throws Exception { + // Generate a key pair using the RSA algorithm + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); // Key size (bits) + return keyGen.generateKeyPair(); + } + + public static byte[] generateRipeMd160Digest(byte[] input) throws Exception { + // Create a RIPEMD160 message digest instance + MessageDigest ripeMd160 = MessageDigest.getInstance("RIPEMD160", new BouncyCastleProvider()); + + // Update the message digest with the input bytes + ripeMd160.update(input); + + // Get the message digest bytes + return ripeMd160.digest(); + } +} diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 17fc80c4..4e49e86d 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -81,7 +81,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -91,7 +91,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 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 33054732..9ad59d79 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -84,7 +84,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 0, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -94,7 +94,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 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 577a07f1..e4182d7d 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 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 82e4ace7..04005b2b 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 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 16032a9c..ddb29ca5 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 0, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 9999999999999, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-penalty-fix.json b/src/test/resources/test-chain-v2-penalty-fix.json index e62fc9f2..cac92c16 100644 --- a/src/test/resources/test-chain-v2-penalty-fix.json +++ b/src/test/resources/test-chain-v2-penalty-fix.json @@ -83,16 +83,24 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, - "selfSponsorshipAlgoV1Height": 99999999, + "selfSponsorshipAlgoV1Height": 999999999, + "selfSponsorshipAlgoV2Height": 999999999, + "selfSponsorshipAlgoV3Height": 999999999, "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0, "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, - "selfSponsorshipAlgoV2Height": 9999999, "disableTransferPrivsTimestamp": 9999999999500, "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 9999999999999, "penaltyFixHeight": 5 }, "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 3ec11942..566d8515 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 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 2b8834ce..c7ed2270 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -86,7 +86,7 @@ "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, "aggregateSignatureTimestamp": 0, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -96,7 +96,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 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 ab96a243..1c4f0d93 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 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 35535c75..30d952e1 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 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 616d0925..612f02a5 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 500, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 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 ec6ffd2e..2f332233 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo-v1.json b/src/test/resources/test-chain-v2-self-sponsorship-algo-v1.json index d0d989cf..3ea8bc70 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo-v1.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo-v1.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 20, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo-v2.json b/src/test/resources/test-chain-v2-self-sponsorship-algo-v2.json index 5f09cb47..ae424704 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo-v2.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo-v2.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 30, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo-v3.json b/src/test/resources/test-chain-v2-self-sponsorship-algo-v3.json index f7d1faa2..2a24473b 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo-v3.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo-v3.json @@ -85,7 +85,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -95,7 +95,14 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 542c0cf8..5395116f 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -31,6 +31,12 @@ "blockRewardBatchStartHeight": 999999000, "blockRewardBatchSize": 10, "blockRewardBatchAccountsBlockCount": 3, + "mintingGroupIds": [ + { "height": 0, "ids": []}, + { "height": 5, "ids": [694]}, + { "height": 8, "ids": [694, 800]}, + { "height": 12, "ids": [800]} + ], "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -86,7 +92,7 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999990, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, "selfSponsorshipAlgoV2Height": 999999999, @@ -96,7 +102,19 @@ "arbitraryOptionalFeeTimestamp": 0, "unconfirmableRewardSharesHeight": 99999999, "disableTransferPrivsTimestamp": 9999999999500, - "enableTransferPrivsTimestamp": 9999999999950 + "enableTransferPrivsTimestamp": 9999999999950, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 9999999999990, + "enableRewardshareHeight": 9999999999999, + "onlyMintWithNameHeight": 9999999999990, + "groupMemberCheckHeight": 9999999999999, + "decreaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "removeOnlyMintWithNameHeight": 9999999999999, + "fixBatchRewardHeight": 9999999999999, + "adminsReplaceFoundersHeight": 9999999999999, + "ignoreLevelForRewardShareHeight": 9999999999999, + "nullGroupMembershipHeight": 20, + "adminQueryFixHeight": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/start.sh b/start.sh index cb738fa2..20322e4f 100755 --- a/start.sh +++ b/start.sh @@ -33,8 +33,13 @@ 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 -# Uncomment (remove '#' sign) line below if your system has less than 12GB of RAM for optimal RAM defaults -#JVM_MEMORY_ARGS="-Xss256m -XX:+UseSerialGC" +# JAVA MEMORY SETTINGS BELOW - These settings are essentially optimized default settings. +# Combined with the latest changes on the Qortal Core in version 4.6.6 and beyond, +# should give a dramatic increase In performance due to optimized Garbage Collection. +# These memory arguments should work on machines with as little as 6GB of RAM. +# If you want to run on a machine with less than 6GB of RAM, it is suggested to increase the '50' below to '75' +# The Qortal Core will utilize only as much RAM as it needs, but up-to the amount set in percentage below. +JVM_MEMORY_ARGS="-XX:MaxRAMPercentage=50 -XX:+UseG1GC -Xss1024k" # Although java.net.preferIPv4Stack is supposed to be false # by default in Java 11, on some platforms (e.g. FreeBSD 12), @@ -43,9 +48,9 @@ fi nohup nice -n 20 java \ -Djava.net.preferIPv4Stack=false \ ${JVM_MEMORY_ARGS} \ - --add-opens=java.base/java.lang=ALL-UNNAMED \ - --add-opens=java.base/java.net=ALL-UNNAMED \ - --illegal-access=warn \ + --add-opens=java.base/java.lang=ALL-UNNAMED \ + --add-opens=java.base/java.net=ALL-UNNAMED \ + --illegal-access=warn \ -jar qortal.jar \ 1>run.log 2>&1 & diff --git a/testnet/log4j2.properties b/testnet/log4j2.properties new file mode 100644 index 00000000..54f295c1 --- /dev/null +++ b/testnet/log4j2.properties @@ -0,0 +1,73 @@ +rootLogger.level = info +# On Windows, uncomment next line to set dirname: +# property.dirname = ${sys:user.home}\\AppData\\Local\\qortal\\ +# property.filename = ${sys:log4j2.filenameTemplate:-log.txt} + +rootLogger.appenderRef.console.ref = stdout +rootLogger.appenderRef.rolling.ref = FILE + +# Suppress extraneous bitcoinj library output +logger.bitcoinj.name = org.bitcoinj +logger.bitcoinj.level = error + +# Override HSQLDB logging level to "warn" as too much is logged at "info" +logger.hsqldb.name = hsqldb.db +logger.hsqldb.level = warn + +# Support optional, per-session HSQLDB debugging +logger.hsqldbRepository.name = org.qortal.repository.hsqldb +logger.hsqldbRepository.level = debug + +# Suppress extraneous Jersey warning +logger.jerseyInject.name = org.glassfish.jersey.internal.inject.Providers +logger.jerseyInject.level = off + +# Suppress extraneous Jersey EOF 'errors' (actually remote peers disconnecting early) +logger.jerseyEOF.name = org.glassfish.jersey.server.internal +logger.jerseyEOF.level = off + +# Suppress extraneous Jetty entries +# 2019-02-14 11:46:27 INFO ContextHandler:851 - Started o.e.j.s.ServletContextHandler@6949e948{/,null,AVAILABLE} +# 2019-02-14 11:46:27 INFO AbstractConnector:289 - Started ServerConnector@50ad322b{HTTP/1.1,[http/1.1]}{0.0.0.0:9085} +# 2019-02-14 11:46:27 INFO Server:374 - jetty-9.4.11.v20180605; built: 2018-06-05T18:24:03.829Z; git: d5fc0523cfa96bfebfbda19606cad384d772f04c; jvm 1.8.0_181-b13 +# 2019-02-14 11:46:27 INFO Server:411 - Started @2539ms +logger.jetty.name = org.eclipse.jetty +logger.jetty.level = warn +# Even more extraneous Jetty output +# 2019-01-26 02:18:10 WARN ResourceService:718 - java.util.concurrent.TimeoutException: Idle timeout expired: 30000/30000 ms +logger.jettyRS.name = org.eclipse.jetty.server.ResourceService +logger.jettyRS.level = error + +# Suppress extraneous slf4j entries +# 2019-02-14 11:46:27 INFO log:193 - Logging initialized @1636ms to org.eclipse.jetty.util.log.Slf4jLog +logger.slf4j.name = org.slf4j +logger.slf4j.level = warn + +# Suppress extraneous Reflections entry +# 2019-02-27 10:45:25 WARN Reflections:179 - given scan urls are empty. set urls in the configuration +logger.orgReflections.name = org.reflections.Reflections +logger.orgReflections.level = off +logger.sunReflections.name = sun.reflect.Reflection +logger.sunReflections.level = off + +appender.console.type = Console +appender.console.name = stdout +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n +appender.console.filter.threshold.type = ThresholdFilter +appender.console.filter.threshold.level = error + +appender.rolling.type = RollingFile +appender.rolling.name = FILE +appender.rolling.fileName = qortal.log +appender.rolling.filePattern = qortal.%d{dd-MMM}.log.gz +appender.rolling.layout.type = PatternLayout +appender.rolling.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n +appender.rolling.policy.type = SizeBasedTriggeringPolicy +appender.rolling.policy.size = 10MB +appender.rolling.strategy.type = DefaultRolloverStrategy +appender.rolling.strategy.max = 7 +# Set the immediate flush to true (default) +# appender.rolling.immediateFlush = true +# Set the append to true (default), should not overwrite +# appender.rolling.append=true diff --git a/testnet/settings-test.json b/testnet/settings-test.json index e49368f8..ef00f162 100755 --- a/testnet/settings-test.json +++ b/testnet/settings-test.json @@ -1,5 +1,11 @@ { + "listenPort": 62392, + "apiPort": 62391, + "bindAddress": "0.0.0.0", "isTestNet": true, + "singleNodeTestnet": false, + "minPeerVersion": "4.5.2", + "allowConnectionsWithOlderPeerVersions": false, "bitcoinNet": "TEST3", "litecoinNet": "TEST3", "dogecoinNet": "TEST3", @@ -13,6 +19,17 @@ "bootstrap": false, "maxPeerConnectionTime": 999999999, "localAuthBypassEnabled": true, - "singleNodeTestnet": false, - "recoveryModeTimeout": 0 + "recoveryModeTimeout": 0, + "uiLocalServers": [ + "localhost", + "127.0.0.1", + "0.0.0.0/0", + "::/0" + ], + "apiWhitelist": [ + "localhost", + "127.0.0.1", + "0.0.0.0/0", + "::/0" + ] } diff --git a/testnet/start.sh b/testnet/start.sh new file mode 100644 index 00000000..030b58ee --- /dev/null +++ b/testnet/start.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +# Validate Java is installed and the minimum version is available +MIN_JAVA_VER='11' + +if command -v java > /dev/null 2>&1; then + # Example: openjdk version "11.0.6" 2020-01-14 + version=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | cut -d'.' -f1,2) + if echo "${version}" "${MIN_JAVA_VER}" | awk '{ if ($2 > 0 && $1 >= $2) exit 0; else exit 1}'; then + echo 'Passed Java version check' + else + echo "Please upgrade your Java to version ${MIN_JAVA_VER} or greater" + exit 1 + fi +else + echo "Java is not available, please install Java ${MIN_JAVA_VER} or greater" + exit 1 +fi + +# No qortal.jar but we have a Maven built one? +# Be helpful and copy across to correct location +if [ ! -e qortal.jar -a -f target/qortal*.jar ]; then + echo "Copying Maven-built Qortal JAR to correct pathname" + cp target/qortal*.jar qortal.jar +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="-Xss256m -XX:+UseSerialGC" + +# Although java.net.preferIPv4Stack is supposed to be false +# by default in Java 11, on some platforms (e.g. FreeBSD 12), +# it is overridden to be true by default. Hence we explicitly +# set it to false to obtain desired behaviour. +nohup nice -n 20 java \ + -Djava.net.preferIPv4Stack=false \ + ${JVM_MEMORY_ARGS} \ + -jar qortal.jar \ + settings-test.json \ + 1>run.log 2>&1 & + +# Save backgrounded process's PID +echo $! > run.pid +echo qortal running as pid $! diff --git a/testnet/stop.sh b/testnet/stop.sh new file mode 100644 index 00000000..d79a51db --- /dev/null +++ b/testnet/stop.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +# Check for color support +if [ -t 1 ]; then + ncolors=$( tput colors ) + if [ -n "${ncolors}" -a "${ncolors}" -ge 8 ]; then + if normal="$( tput sgr0 )"; then + # use terminfo names + red="$( tput setaf 1 )" + green="$( tput setaf 2)" + else + # use termcap names for FreeBSD compat + normal="$( tput me )" + red="$( tput AF 1 )" + green="$( tput AF 2)" + fi + fi +fi + +# Track the pid if we can find it +read pid 2>/dev/null /dev/null 2>&1; then + success=1 + fi +fi + +# Try to kill process with SIGTERM +if [ "$success" -ne 1 ] && [ -n "$pid" ]; then + echo "Stopping Qortal process $pid..." + if kill -15 "${pid}"; then + success=1 + fi +fi + +# Warn and exit if still no success +if [ "$success" -ne 1 ]; then + if [ -n "$pid" ]; then + echo "${red}Stop command failed - not running with process id ${pid}?${normal}" + else + echo "${red}Stop command failed - not running?${normal}" + fi + exit 1 +fi + +if [ "$success" -eq 1 ]; then + echo "Qortal node should be shutting down" + if [ "${is_pid_valid}" -eq 0 ]; then + echo -n "Monitoring for Qortal node to end" + while s=`ps -p $pid -o stat=` && [[ "$s" && "$s" != 'Z' ]]; do + echo -n . + sleep 1 + done + echo + echo "${green}Qortal ended gracefully${normal}" + rm -f run.pid + fi +fi + +exit 0 diff --git a/testnet/testchain.json b/testnet/testchain.json index 66287bef..06f5df12 100644 --- a/testnet/testchain.json +++ b/testnet/testchain.json @@ -5,7 +5,7 @@ "maxBlockSize": 2097152, "maxBytesPerUnitFee": 1024, "unitFees": [ - { "timestamp": 0, "fee": "0.001" } + { "timestamp": 0, "fee": "0.01" } ], "nameRegistrationUnitFees": [ { "timestamp": 0, "fee": "1.25" } @@ -26,10 +26,14 @@ "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 0, "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "selfSponsorshipAlgoV2SnapshotTimestamp": 9999999999999, + "selfSponsorshipAlgoV3SnapshotTimestamp": 9999999999999, + "referenceTimestampBlock": 1670684455220, "mempowTransactionUpdatesTimestamp": 1692554400000, - "blockRewardBatchStartHeight": 10000, + "blockRewardBatchStartHeight": 2000000, "blockRewardBatchSize": 1000, "blockRewardBatchAccountsBlockCount": 25, + "mintingGroupId": 2, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, @@ -90,13 +94,23 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 9999999, + "selfSponsorshipAlgoV2Height": 9999900, + "selfSponsorshipAlgoV3Height": 9999900, "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0, - "arbitraryOptionalFeeTimestamp": 0 + "arbitraryOptionalFeeTimestamp": 0, + "unconfirmableRewardSharesHeight": 9999999, + "disableTransferPrivsTimestamp": 9999999999990, + "enableTransferPrivsTimestamp": 9999999999999, + "cancelSellNameValidationTimestamp": 9999999999999, + "disableRewardshareHeight": 8450, + "enableRewardshareHeight": 11400, + "onlyMintWithNameHeight": 8500, + "groupMemberCheckHeight": 11200 }, "genesisInfo": { "version": 4, - "timestamp": "1701874800000", + "timestamp": "1726152900000", "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 }, diff --git a/tools/publish-auto-update.pl b/tools/publish-auto-update.pl index 9e6b885b..ed473acb 100755 --- a/tools/publish-auto-update.pl +++ b/tools/publish-auto-update.pl @@ -69,7 +69,8 @@ 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}`; +#my $update_hash = `git rev-parse refs/heads/auto-update-${commit_hash}`; +my $update_hash = `git rev-parse origin/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; @@ -132,11 +133,57 @@ my $signed_tx = `curl --silent -H "accept: text/plain" -H "Content-Type: applica 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 +# Get the origin URL - So that we will be able to TEST the obtaining of the qortal.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; +chomp $origin; # Remove any trailing newlines +die("Unable to get github url for 'origin'?\n") unless $origin; + +# Debug: Print the origin URL +print "Full Origin URL: $origin\n"; + +# Extract the repository path (e.g., Qortal/qortal) NOTE - github is case-sensitive with repo names +my $repo; +if ($origin =~ m/[:\/]([\w\-]+\/[\w\-]+)\.git$/) { + $repo = $1; + print "Extracted direct repository path: $repo\n"; + if ($repo =~ m/^qortal\//i) { + $repo =~ s/^qortal\//Qortal\//; + print "Corrected repository path capitalization: $repo\n"; + } + print "Please verify the direct repository path. Current: '$repo'\n"; + print "If incorrect, input the correct direct repository path (e.g., 'Qortal/qortal' or 'bob/qortal').NOTE - github is CASE SENSITIVE for repository urls... Press Enter to keep the extracted version: "; + my $input = ; + if ($input =~ m/^qortal\//i) { + $input =~ s/^qortal\//Qortal\//; + print "Corrected repository path capitalization: $repo\n"; + } + chomp $input; + $repo = $input if $input; # Update repo if user provides input + +} else { + # Default to qortal/qortal if extraction fails + $repo = "Qortal/qortal"; + print "Failed to extract repository path from origin URL. Using default: $repo\n"; + + # Prompt the user for confirmation or input + print "Please verify the repository path. Current: '$repo'\n"; + print "If incorrect, input the correct repository path (e.g., 'Qortal/qortal' or 'BobsCodeburgers/qortal'). NOTE - GitHub is CASE SENSITIVE for repository urls... Press Enter to keep the default: "; + my $input = ; + if ($input =~ m/^qortal\//i) { + $input =~ s/^qortal\//Qortal\//; + print "Corrected repository path capitalization: $repo\n"; + } + chomp $input; + $repo = $input if $input; # Update repo if user provides input +} + +# Debug: Print the final repository path +print "Final direct repository path: $repo\n"; + +# Construct the update URL my $update_url = "https://github.com/${repo}/raw/${update_hash}/${project}.update"; +print "Final update URL: $update_url\n"; + 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';