From 30df320e7f7d8046db92d83f026bec543ce8f792 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 8 Nov 2019 17:30:09 +0000 Subject: [PATCH] Redoing account balances with block height. *** WARNING *** Possible block reward bug in this commit. Further investigation needed. Reverted AccountBalances back to height-less form. Added HistoricAccountBalances table that is populated via trigger on AccountBalances. This saves work when performing common requests for latest/confirmed balances, shunting the extra work to when requesting height-related account balances. Unified API call GET /addresses/balance/{address} by having address/assetId/height as query params. Simpler call for fetching legacy QORA holders during block rewarding. Improved SQL for fetching asset balances, in all conditions, e.g. with/without filtering addresses, with/without filtering assetIds, etc. Unit test for above to make sure query execution is fast enough. (At one point, some SQL query was taking 6 seconds!) Added optional 'height' Integer to AccountBalanceData, but this is not populated/used very often. HSQLDBAccountRepository.save(AccountBalanceData) now checks zero balance saves to see if the row can be deleted instead. This fixes a lot of unhappy tests that complain that there are residual account balance rows left after post-test orphaning back to genesis block. Yet more tests. Removed very old 'TransactionTests' which are mostly covered in more specific tests elsewhere. Added cancel-sell-name test from above. Fixed AssetsApiTests to check for QORT not QORA! Changed hard-coded assetIDs in test.common.AssetUtils in light of new LEGACY_QORA & QORT_FROM_QORA genesis assets. Some test blockchain config changes. --- src/main/java/org/qora/account/Account.java | 36 +- .../qora/api/resource/AddressesResource.java | 37 +- src/main/java/org/qora/block/Block.java | 13 +- .../qora/data/account/AccountBalanceData.java | 21 +- .../qora/repository/AccountRepository.java | 3 + .../hsqldb/HSQLDBAccountRepository.java | 185 ++- .../hsqldb/HSQLDBDatabaseUpdates.java | 28 +- .../repository/hsqldb/HSQLDBRepository.java | 46 + .../org/qora/test/AccountBalanceTests.java | 120 +- .../java/org/qora/test/TransactionTests.java | 1164 ----------------- .../org/qora/test/api/AssetsApiTests.java | 2 +- .../java/org/qora/test/common/AssetUtils.java | 7 +- .../java/org/qora/test/common/Common.java | 10 +- .../qora/test/common/TransactionUtils.java | 2 +- ...{OrphaningTests.java => BuySellTests.java} | 28 +- src/test/resources/test-chain-old-asset.json | 9 +- src/test/resources/test-chain-v2.json | 2 +- 17 files changed, 390 insertions(+), 1323 deletions(-) delete mode 100644 src/test/java/org/qora/test/TransactionTests.java rename src/test/java/org/qora/test/naming/{OrphaningTests.java => BuySellTests.java} (89%) diff --git a/src/main/java/org/qora/account/Account.java b/src/main/java/org/qora/account/Account.java index d5ede3d2..27ef27fb 100644 --- a/src/main/java/org/qora/account/Account.java +++ b/src/main/java/org/qora/account/Account.java @@ -5,14 +5,11 @@ import java.util.List; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qora.block.Block; import org.qora.block.BlockChain; import org.qora.data.account.AccountBalanceData; import org.qora.data.account.AccountData; import org.qora.data.account.RewardShareData; -import org.qora.data.block.BlockData; import org.qora.data.transaction.TransactionData; -import org.qora.repository.BlockRepository; import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.transaction.Transaction; @@ -54,35 +51,14 @@ public class Account { return new AccountData(this.address); } - // Balance manipulations - assetId is 0 for QORA + // Balance manipulations - assetId is 0 for QORT - public BigDecimal getBalance(long assetId, int confirmations) throws DataException { - // Simple case: we only need balance with 1 confirmation - if (confirmations == 1) - return this.getConfirmedBalance(assetId); + public BigDecimal getBalance(long assetId, int height) throws DataException { + AccountBalanceData accountBalanceData = this.repository.getAccountRepository().getBalance(this.address, assetId, height); + if (accountBalanceData == null) + return BigDecimal.ZERO.setScale(8); - /* - * For a balance with more confirmations work back from last block, undoing transactions involving this account, until we have processed required number - * of blocks. - */ - BlockRepository blockRepository = this.repository.getBlockRepository(); - BigDecimal balance = this.getConfirmedBalance(assetId); - BlockData blockData = blockRepository.getLastBlock(); - - // Note: "blockData.getHeight() > 1" to make sure we don't examine genesis block - for (int i = 1; i < confirmations && blockData != null && blockData.getHeight() > 1; ++i) { - Block block = new Block(this.repository, blockData); - - // CIYAM AT transactions should be fetched from repository so no special handling needed here - for (Transaction transaction : block.getTransactions()) - if (transaction.isInvolved(this)) - balance = balance.subtract(transaction.getAmount(this)); - - blockData = block.getParent(); - } - - // Return balance - return balance; + return accountBalanceData.getBalance(); } public BigDecimal getConfirmedBalance(long assetId) throws DataException { diff --git a/src/main/java/org/qora/api/resource/AddressesResource.java b/src/main/java/org/qora/api/resource/AddressesResource.java index 5ce6c668..5d7a4d86 100644 --- a/src/main/java/org/qora/api/resource/AddressesResource.java +++ b/src/main/java/org/qora/api/resource/AddressesResource.java @@ -191,7 +191,8 @@ public class AddressesResource { @GET @Path("/balance/{address}") @Operation( - summary = "Returns the confirmed balance of the given address", + summary = "Returns account balance", + description = "Returns account's balance, optionally of given asset and at given height", responses = { @ApiResponse( description = "the balance", @@ -199,14 +200,27 @@ public class AddressesResource { ) } ) - @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public BigDecimal getConfirmedBalance(@PathParam("address") String address) { + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.INVALID_ASSET_ID, ApiError.INVALID_HEIGHT, ApiError.REPOSITORY_ISSUE}) + public BigDecimal getBalance(@PathParam("address") String address, + @QueryParam("assetId") Long assetId, + @QueryParam("height") Integer height) { if (!Crypto.isValidAddress(address)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); try (final Repository repository = RepositoryManager.getRepository()) { Account account = new Account(repository, address); - return account.getConfirmedBalance(Asset.QORT); + + if (assetId == null) + assetId = Asset.QORT; + else if (!repository.getAssetRepository().assetExists(assetId)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ASSET_ID); + + if (height == null) + height = repository.getBlockRepository().getBlockchainHeight(); + else if (height <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT); + + return account.getBalance(assetId, height); } catch (ApiException e) { throw e; } catch (DataException e) { @@ -214,21 +228,6 @@ public class AddressesResource { } } - @GET - @Path("/balance/{address}/{confirmations}") - @Operation( - summary = "Calculates the balance of the given address for the given confirmations", - responses = { - @ApiResponse( - description = "the balance", - content = @Content(schema = @Schema(type = "string", format = "number")) - ) - } - ) - public String getConfirmedBalance(@PathParam("address") String address, @PathParam("confirmations") int confirmations) { - throw new UnsupportedOperationException(); - } - @GET @Path("/publickey/{address}") @Operation( diff --git a/src/main/java/org/qora/block/Block.java b/src/main/java/org/qora/block/Block.java index b68983ad..6267b117 100644 --- a/src/main/java/org/qora/block/Block.java +++ b/src/main/java/org/qora/block/Block.java @@ -8,7 +8,6 @@ import java.math.BigInteger; import java.math.RoundingMode; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -40,7 +39,6 @@ import org.qora.data.block.BlockTransactionData; import org.qora.data.network.OnlineAccountData; import org.qora.data.transaction.TransactionData; import org.qora.repository.ATRepository; -import org.qora.repository.AccountRepository.BalanceOrdering; import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.TransactionRepository; @@ -1621,9 +1619,7 @@ public class Block { BigDecimal qoraHoldersAmount = BlockChain.getInstance().getQoraHoldersShare().multiply(totalAmount).setScale(8, RoundingMode.DOWN); LOGGER.trace(() -> String.format("Legacy QORA holders share of %s: %s", totalAmount.toPlainString(), qoraHoldersAmount.toPlainString())); - List assetAddresses = Collections.emptyList(); - List assetIds = Collections.singletonList(Asset.LEGACY_QORA); - List qoraHolders = this.repository.getAccountRepository().getAssetBalances(assetAddresses, assetIds, BalanceOrdering.ASSET_ACCOUNT, true, null, null, null); + List qoraHolders = this.repository.getAccountRepository().getAssetBalances(Asset.LEGACY_QORA, true); // Filter out qoraHolders who have received max QORT due to holding legacy QORA, (ratio from blockchain config) BigDecimal qoraPerQortReward = BlockChain.getInstance().getQoraPerQortReward(); @@ -1708,7 +1704,12 @@ public class Block { } qoraHolderAccount.setConfirmedBalance(Asset.QORT, qoraHolderAccount.getConfirmedBalance(Asset.QORT).add(holderReward)); - qoraHolderAccount.setConfirmedBalance(Asset.QORT_FROM_QORA, newQortFromQoraBalance); + + if (newQortFromQoraBalance.signum() > 0) + qoraHolderAccount.setConfirmedBalance(Asset.QORT_FROM_QORA, newQortFromQoraBalance); + else + // Remove QORT_FROM_QORA balance as it's zero + qoraHolderAccount.deleteBalance(Asset.QORT_FROM_QORA); sharedAmount = sharedAmount.add(holderReward); } diff --git a/src/main/java/org/qora/data/account/AccountBalanceData.java b/src/main/java/org/qora/data/account/AccountBalanceData.java index 9244a447..25c4f557 100644 --- a/src/main/java/org/qora/data/account/AccountBalanceData.java +++ b/src/main/java/org/qora/data/account/AccountBalanceData.java @@ -13,7 +13,9 @@ public class AccountBalanceData { private String address; private long assetId; private BigDecimal balance; + // Not always present: + private Integer height; private String assetName; // Constructors @@ -22,15 +24,22 @@ public class AccountBalanceData { protected AccountBalanceData() { } - public AccountBalanceData(String address, long assetId, BigDecimal balance, String assetName) { + public AccountBalanceData(String address, long assetId, BigDecimal balance) { this.address = address; this.assetId = assetId; this.balance = balance; - this.assetName = assetName; } - public AccountBalanceData(String address, long assetId, BigDecimal balance) { - this(address, assetId, balance, null); + public AccountBalanceData(String address, long assetId, BigDecimal balance, int height) { + this(address, assetId, balance); + + this.height = height; + } + + public AccountBalanceData(String address, long assetId, BigDecimal balance, String assetName) { + this(address, assetId, balance); + + this.assetName = assetName; } // Getters/Setters @@ -51,6 +60,10 @@ public class AccountBalanceData { this.balance = balance; } + public Integer getHeight() { + return this.height; + } + public String getAssetName() { return this.assetName; } diff --git a/src/main/java/org/qora/repository/AccountRepository.java b/src/main/java/org/qora/repository/AccountRepository.java index 19e920c8..e69892ad 100644 --- a/src/main/java/org/qora/repository/AccountRepository.java +++ b/src/main/java/org/qora/repository/AccountRepository.java @@ -89,6 +89,9 @@ public interface AccountRepository { /** Returns account balance data for address & assetId at (or before) passed block height. */ public AccountBalanceData getBalance(String address, long assetId, int height) throws DataException; + /** Returns per-height historic balance for address & assetId. */ + public List getHistoricBalances(String address, long assetId) throws DataException; + public enum BalanceOrdering { ASSET_BALANCE_ACCOUNT, ACCOUNT_ASSET, diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBAccountRepository.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBAccountRepository.java index f05ca721..4672f76c 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBAccountRepository.java @@ -259,7 +259,7 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public AccountBalanceData getBalance(String address, long assetId) throws DataException { - String sql = "SELECT balance FROM AccountBalances WHERE account = ? AND asset_id = ? ORDER BY height DESC LIMIT 1"; + String sql = "SELECT IFNULL(balance, 0) FROM AccountBalances WHERE account = ? AND asset_id = ? LIMIT 1"; try (ResultSet resultSet = this.repository.checkedExecute(sql, address, assetId)) { if (resultSet == null) @@ -275,7 +275,7 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public AccountBalanceData getBalance(String address, long assetId, int height) throws DataException { - String sql = "SELECT balance FROM AccountBalances WHERE account = ? AND asset_id = ? AND height <= ? ORDER BY height DESC LIMIT 1"; + String sql = "SELECT IFNULL(balance, 0) FROM HistoricAccountBalances WHERE account = ? AND asset_id = ? AND height <= ? ORDER BY height DESC LIMIT 1"; try (ResultSet resultSet = this.repository.checkedExecute(sql, address, assetId, height)) { if (resultSet == null) @@ -289,10 +289,32 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public List getHistoricBalances(String address, long assetId) throws DataException { + String sql = "SELECT height, balance FROM HistoricAccountBalances WHERE account = ? AND asset_id = ? ORDER BY height DESC"; + + List historicBalances = new ArrayList<>(); + try (ResultSet resultSet = this.repository.checkedExecute(sql, address, assetId)) { + if (resultSet == null) + return historicBalances; + + do { + int height = resultSet.getInt(1); + BigDecimal balance = resultSet.getBigDecimal(2); + + historicBalances.add(new AccountBalanceData(address, assetId, balance, height)); + } while (resultSet.next()); + + return historicBalances; + } catch (SQLException e) { + throw new DataException("Unable to fetch historic account balances from repository", e); + } + } + @Override public List getAssetBalances(long assetId, Boolean excludeZero) throws DataException { StringBuilder sql = new StringBuilder(1024); - sql.append("SELECT account, IFNULL(balance, 0) FROM NewestAccountBalances WHERE asset_id = ?"); + sql.append("SELECT account, IFNULL(balance, 0) FROM AccountBalances WHERE asset_id = ?"); if (excludeZero != null && excludeZero) sql.append(" AND balance != 0"); @@ -321,76 +343,83 @@ public class HSQLDBAccountRepository implements AccountRepository { StringBuilder sql = new StringBuilder(1024); sql.append("SELECT account, asset_id, IFNULL(balance, 0), asset_name FROM "); - if (!addresses.isEmpty()) { - sql.append("(VALUES "); + final boolean haveAddresses = addresses != null && !addresses.isEmpty(); + final boolean haveAssetIds = assetIds != null && !assetIds.isEmpty(); - final int addressesSize = addresses.size(); - for (int ai = 0; ai < addressesSize; ++ai) { - if (ai != 0) - sql.append(", "); + // Fill temporary table with filtering addresses/assetIDs + if (haveAddresses) + HSQLDBRepository.temporaryValuesTableSql(sql, addresses.size(), "TmpAccounts", "account"); - sql.append("(?)"); + if (haveAssetIds) { + if (haveAddresses) + sql.append("CROSS JOIN "); + + HSQLDBRepository.temporaryValuesTableSql(sql, assetIds, "TmpAssetIds", "asset_id"); + } + + if (haveAddresses || haveAssetIds) { + // Now use temporary table to filter AccountBalances (using index) and optional zero balance exclusion + sql.append("JOIN AccountBalances ON "); + + if (haveAddresses) + sql.append("AccountBalances.account = TmpAccounts.account "); + + if (haveAssetIds) { + if (haveAddresses) + sql.append("AND "); + + sql.append("AccountBalances.asset_id = TmpAssetIds.asset_id "); } - sql.append(") AS Accounts (account) "); - sql.append("CROSS JOIN Assets LEFT OUTER JOIN NewestAccountBalances USING (asset_id, account) "); + if (!haveAddresses || (excludeZero != null && excludeZero)) + sql.append("AND AccountBalances.balance != 0 "); } else { - // Simplier, no-address query - sql.append("NewestAccountBalances NATURAL JOIN Assets "); + // Simpler form if no filtering + sql.append("AccountBalances "); + + // Zero balance exclusion comes later } - if (!assetIds.isEmpty()) { - // longs are safe enough to use literally - sql.append("WHERE asset_id IN ("); + // Join for asset name + sql.append("JOIN Assets ON Assets.asset_id = AccountBalances.asset_id "); - final int assetIdsSize = assetIds.size(); - for (int ai = 0; ai < assetIdsSize; ++ai) { - if (ai != 0) - sql.append(", "); + // Zero balance exclusion if no filtering + if (!haveAddresses && !haveAssetIds && excludeZero != null && excludeZero) + sql.append("WHERE AccountBalances.balance != 0 "); - sql.append(assetIds.get(ai)); + if (balanceOrdering != null) { + String[] orderingColumns; + switch (balanceOrdering) { + case ACCOUNT_ASSET: + orderingColumns = new String[] { "account", "asset_id" }; + break; + + case ASSET_ACCOUNT: + orderingColumns = new String[] { "asset_id", "account" }; + break; + + case ASSET_BALANCE_ACCOUNT: + orderingColumns = new String[] { "asset_id", "balance", "account" }; + break; + + default: + throw new DataException(String.format("Unsupported asset balance result ordering: %s", balanceOrdering.name())); } - sql.append(") "); - } + sql.append("ORDER BY "); + for (int oi = 0; oi < orderingColumns.length; ++oi) { + if (oi != 0) + sql.append(", "); - // For no-address queries, or unless specifically requested, only return accounts with non-zero balance - if (addresses.isEmpty() || (excludeZero != null && excludeZero)) { - sql.append(assetIds.isEmpty() ? " WHERE " : " AND "); - sql.append("balance != 0 "); - } - - String[] orderingColumns; - switch (balanceOrdering) { - case ACCOUNT_ASSET: - orderingColumns = new String[] { "account", "asset_id" }; - break; - - case ASSET_ACCOUNT: - orderingColumns = new String[] { "asset_id", "account" }; - break; - - case ASSET_BALANCE_ACCOUNT: - orderingColumns = new String[] { "asset_id", "balance", "account" }; - break; - - default: - throw new DataException(String.format("Unsupported asset balance result ordering: %s", balanceOrdering.name())); - } - - sql.append("ORDER BY "); - for (int oi = 0; oi < orderingColumns.length; ++oi) { - if (oi != 0) - sql.append(", "); - - sql.append(orderingColumns[oi]); - if (reverse != null && reverse) - sql.append(" DESC"); + sql.append(orderingColumns[oi]); + if (reverse != null && reverse) + sql.append(" DESC"); + } } HSQLDBRepository.limitOffsetSql(sql, limit, offset); - String[] addressesArray = addresses.toArray(new String[addresses.size()]); + String[] addressesArray = addresses == null ? new String[0] : addresses.toArray(new String[addresses.size()]); List accountBalances = new ArrayList<>(); try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), (Object[]) addressesArray)) { @@ -414,15 +443,41 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public void save(AccountBalanceData accountBalanceData) throws DataException { + // If balance is zero and there are no prior historic balance, then simply delete balances for this assetId (typically during orphaning) + if (accountBalanceData.getBalance().signum() == 0) { + boolean hasPriorBalances; + try { + hasPriorBalances = this.repository.exists("HistoricAccountBalances", "account = ? AND asset_id = ? AND height < (SELECT IFNULL(MAX(height), 1) FROM Blocks)", + accountBalanceData.getAddress(), accountBalanceData.getAssetId()); + } catch (SQLException e) { + throw new DataException("Unable to check for historic account balances in repository", e); + } + + if (!hasPriorBalances) { + try { + this.repository.delete("AccountBalances", "account = ? AND asset_id = ?", accountBalanceData.getAddress(), accountBalanceData.getAssetId()); + } catch (SQLException e) { + throw new DataException("Unable to delete account balance from repository", e); + } + + // I don't think we need to do this as Block.orphan() would do this for us? + try { + this.repository.delete("HistoricAccountBalances", "account = ? AND asset_id = ?", accountBalanceData.getAddress(), accountBalanceData.getAssetId()); + } catch (SQLException e) { + throw new DataException("Unable to delete historic account balances from repository", e); + } + + return; + } + } + HSQLDBSaver saveHelper = new HSQLDBSaver("AccountBalances"); saveHelper.bind("account", accountBalanceData.getAddress()).bind("asset_id", accountBalanceData.getAssetId()).bind("balance", accountBalanceData.getBalance()); try { - // Fill in 'height' - int height = this.repository.checkedExecute("SELECT COUNT(*) + 1 FROM Blocks").getInt(1); - saveHelper.bind("height", height); + // HistoricAccountBalances auto-updated via trigger saveHelper.execute(this.repository); } catch (SQLException e) { @@ -437,14 +492,20 @@ public class HSQLDBAccountRepository implements AccountRepository { } catch (SQLException e) { throw new DataException("Unable to delete account balance from repository", e); } + + try { + this.repository.delete("HistoricAccountBalances", "account = ? AND asset_id = ?", address, assetId); + } catch (SQLException e) { + throw new DataException("Unable to delete historic account balances from repository", e); + } } @Override public int deleteBalancesFromHeight(int height) throws DataException { try { - return this.repository.delete("AccountBalances", "height >= ?", height); + return this.repository.delete("HistoricAccountBalances", "height >= ?", height); } catch (SQLException e) { - throw new DataException("Unable to delete old account balances from repository", e); + throw new DataException("Unable to delete historic account balances from repository", e); } } diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java index 4b88c19c..3f8c9d91 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -847,21 +847,19 @@ public class HSQLDBDatabaseUpdates { + "PRIMARY KEY (account), FOREIGN KEY (account) REFERENCES Accounts (account) ON DELETE CASCADE)"); break; - case 60: // Adding height to account balances - // We need to drop primary key first - stmt.execute("ALTER TABLE AccountBalances DROP PRIMARY KEY"); - // Add height to account balances - stmt.execute("ALTER TABLE AccountBalances ADD COLUMN height INT NOT NULL DEFAULT 0 BEFORE BALANCE"); - // Add new primary key - stmt.execute("ALTER TABLE AccountBalances ADD PRIMARY KEY (asset_id, account, height)"); - /// Create a view for account balances at greatest height - stmt.execute("CREATE VIEW NewestAccountBalances (account, asset_id, balance) AS " - + "SELECT AccountBalances.account, AccountBalances.asset_id, AccountBalances.balance FROM AccountBalances " - + "LEFT OUTER JOIN AccountBalances AS NewerAccountBalances " - + "ON NewerAccountBalances.account = AccountBalances.account " - + "AND NewerAccountBalances.asset_id = AccountBalances.asset_id " - + "AND NewerAccountBalances.height > AccountBalances.height " - + "WHERE NewerAccountBalances.height IS NULL"); + case 60: + // Index for speeding up fetch legacy QORA holders for Block processing + stmt.execute("CREATE INDEX AccountBalances_Asset_Balance_Index ON AccountBalances (asset_id, balance)"); + // Tracking height-history to account balances + stmt.execute("CREATE TABLE HistoricAccountBalances (account QoraAddress, asset_id AssetID, height INT DEFAULT 1, balance QoraAmount NOT NULL, " + + "PRIMARY KEY (account, asset_id, height), FOREIGN KEY (account) REFERENCES Accounts (account) ON DELETE CASCADE)"); + // Create triggers on changes to AccountBalances rows to update historic + stmt.execute("CREATE TRIGGER Historic_account_balance_insert_trigger AFTER INSERT ON AccountBalances REFERENCING NEW ROW AS new_row FOR EACH ROW " + + "INSERT INTO HistoricAccountBalances VALUES (new_row.account, new_row.asset_id, (SELECT IFNULL(MAX(height), 0) + 1 FROM Blocks), new_row.balance) " + + "ON DUPLICATE KEY UPDATE balance = new_row.balance"); + stmt.execute("CREATE TRIGGER Historic_account_balance_update_trigger AFTER UPDATE ON AccountBalances REFERENCING NEW ROW AS new_row FOR EACH ROW " + + "INSERT INTO HistoricAccountBalances VALUES (new_row.account, new_row.asset_id, (SELECT IFNULL(MAX(height), 0) + 1 FROM Blocks), new_row.balance) " + + "ON DUPLICATE KEY UPDATE balance = new_row.balance"); break; default: diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBRepository.java index ee05c513..bbcce653 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBRepository.java @@ -577,6 +577,52 @@ public class HSQLDBRepository implements Repository { } } + /** + * Appends SQL for filling a temporary VALUES table, values NOT supplied. + *

+ * (Convenience method for HSQLDB repository subclasses). + */ + /* package */ static void temporaryValuesTableSql(StringBuilder stringBuilder, int valuesCount, String tableName, String columnName) { + stringBuilder.append("(VALUES "); + + for (int i = 0; i < valuesCount; ++i) { + if (i != 0) + stringBuilder.append(", "); + + stringBuilder.append("(?)"); + } + + stringBuilder.append(") AS "); + stringBuilder.append(tableName); + stringBuilder.append(" ("); + stringBuilder.append(columnName); + stringBuilder.append(") "); + } + + /** + * Appends SQL for filling a temporary VALUES table, literal values ARE supplied. + *

+ * (Convenience method for HSQLDB repository subclasses). + */ + /* package */ static void temporaryValuesTableSql(StringBuilder stringBuilder, List values, String tableName, String columnName) { + stringBuilder.append("(VALUES "); + + for (int i = 0; i < values.size(); ++i) { + if (i != 0) + stringBuilder.append(", "); + + stringBuilder.append("("); + stringBuilder.append(values.get(i)); + stringBuilder.append(")"); + } + + stringBuilder.append(") AS "); + stringBuilder.append(tableName); + stringBuilder.append(" ("); + stringBuilder.append(columnName); + stringBuilder.append(") "); + } + /** Logs other HSQLDB sessions then re-throws passed exception */ public SQLException examineException(SQLException e) throws SQLException { LOGGER.error(String.format("HSQLDB error (session %d): %s", this.sessionId, e.getMessage()), e); diff --git a/src/test/java/org/qora/test/AccountBalanceTests.java b/src/test/java/org/qora/test/AccountBalanceTests.java index 899de3d0..eff6d836 100644 --- a/src/test/java/org/qora/test/AccountBalanceTests.java +++ b/src/test/java/org/qora/test/AccountBalanceTests.java @@ -1,10 +1,14 @@ package org.qora.test; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Random; +import java.util.stream.Collectors; import org.junit.After; import org.junit.Before; @@ -15,9 +19,11 @@ import org.qora.account.PublicKeyAccount; import org.qora.asset.Asset; import org.qora.block.BlockChain; import org.qora.data.account.AccountBalanceData; +import org.qora.data.account.AccountData; import org.qora.data.transaction.BaseTransactionData; import org.qora.data.transaction.PaymentTransactionData; import org.qora.data.transaction.TransactionData; +import org.qora.repository.AccountRepository.BalanceOrdering; import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryManager; @@ -78,7 +84,7 @@ public class AccountBalanceTests extends Common { BigDecimal orphanedBalance = alice.getConfirmedBalance(Asset.QORT); // Confirm post-orphan balance is same as initial - assertTrue("Post-orphan balance should match initial", orphanedBalance.equals(initialBalance)); + assertEqualBigDecimals("Post-orphan balance should match initial", initialBalance, orphanedBalance); } } @@ -95,7 +101,7 @@ public class AccountBalanceTests extends Common { BigDecimal genesisBalance = accountBalanceData.getBalance(); // Confirm genesis balance is same as initial - assertTrue("Genesis balance should match initial", genesisBalance.equals(initialBalance)); + assertEqualBigDecimals("Genesis balance should match initial", initialBalance, genesisBalance); } } @@ -116,7 +122,7 @@ public class AccountBalanceTests extends Common { // Confirm recipient balance is zero BigDecimal balance = recipientAccount.getConfirmedBalance(Asset.QORT); - assertTrue("recipient's balance should be zero", balance.signum() == 0); + assertEqualBigDecimals("recipient's balance should be zero", BigDecimal.ZERO, balance); // Send 1 QORT to recipient TestAccount sendingAccount = Common.getTestAccount(repository, "alice"); @@ -129,24 +135,28 @@ public class AccountBalanceTests extends Common { // Send more QORT to recipient BigDecimal amount = BigDecimal.valueOf(random.nextInt(123456)); pay(repository, sendingAccount, recipientAccount, amount); + BigDecimal totalAmount = BigDecimal.ONE.add(amount); // Mint some more blocks for (int i = 0; i < 10; ++i) BlockUtils.mintBlock(repository); // Confirm recipient balance is as expected - BigDecimal totalAmount = amount.add(BigDecimal.ONE); balance = recipientAccount.getConfirmedBalance(Asset.QORT); - assertTrue("recipient's balance incorrect", balance.compareTo(totalAmount) == 0); + assertEqualBigDecimals("recipient's balance incorrect", totalAmount, balance); + + List historicBalances = repository.getAccountRepository().getHistoricBalances(recipientAccount.getAddress(), Asset.QORT); + for (AccountBalanceData historicBalance : historicBalances) + System.out.println(String.format("Block %d: %s", historicBalance.getHeight(), historicBalance.getBalance().toPlainString())); // Confirm balance as of 2 blocks ago int height = repository.getBlockRepository().getBlockchainHeight(); balance = repository.getAccountRepository().getBalance(recipientAccount.getAddress(), Asset.QORT, height - 2).getBalance(); - assertTrue("recipient's historic balance incorrect", balance.compareTo(totalAmount) == 0); + assertEqualBigDecimals("recipient's historic balance incorrect", totalAmount, balance); // Confirm balance prior to last payment balance = repository.getAccountRepository().getBalance(recipientAccount.getAddress(), Asset.QORT, height - 15).getBalance(); - assertTrue("recipient's historic balance incorrect", balance.compareTo(BigDecimal.ONE) == 0); + assertEqualBigDecimals("recipient's historic balance incorrect", BigDecimal.ONE, balance); // Orphan blocks to before last payment BlockUtils.orphanBlocks(repository, 10 + 5); @@ -154,7 +164,7 @@ public class AccountBalanceTests extends Common { // Re-check balance from (now) invalid height AccountBalanceData accountBalanceData = repository.getAccountRepository().getBalance(recipientAccount.getAddress(), Asset.QORT, height - 2); balance = accountBalanceData.getBalance(); - assertTrue("recipient's invalid-height balance should be one", balance.compareTo(BigDecimal.ONE) == 0); + assertEqualBigDecimals("recipient's invalid-height balance should be one", BigDecimal.ONE, balance); // Orphan blocks to before initial 1 QORT payment BlockUtils.orphanBlocks(repository, 10 + 5); @@ -177,4 +187,94 @@ public class AccountBalanceTests extends Common { TransactionUtils.signAndMint(repository, transactionData, sendingAccount); } + /** Tests SQL query speed for account balance fetches. */ + @Test + public void testRepositorySpeed() throws DataException, SQLException { + Random random = new Random(); + final long MAX_QUERY_TIME = 100L; // ms + + try (final Repository repository = RepositoryManager.getRepository()) { + System.out.println("Creating random accounts..."); + + // Generate some random accounts + List accounts = new ArrayList<>(); + for (int ai = 0; ai < 20; ++ai) { + byte[] publicKey = new byte[32]; + random.nextBytes(publicKey); + + PublicKeyAccount account = new PublicKeyAccount(repository, publicKey); + accounts.add(account); + + AccountData accountData = new AccountData(account.getAddress()); + repository.getAccountRepository().ensureAccount(accountData); + } + repository.saveChanges(); + + System.out.println("Creating random balances..."); + + // Fill with lots of random balances + for (int i = 0; i < 100000; ++i) { + Account account = accounts.get(random.nextInt(accounts.size())); + int assetId = random.nextInt(2); + BigDecimal balance = BigDecimal.valueOf(random.nextInt(100000)); + + AccountBalanceData accountBalanceData = new AccountBalanceData(account.getAddress(), assetId, balance); + repository.getAccountRepository().save(accountBalanceData); + + // Maybe mint a block to change height + if (i > 0 && (i % 1000) == 0) + BlockUtils.mintBlock(repository); + } + repository.saveChanges(); + + // Address filtering test cases + List testAddresses = accounts.stream().limit(3).map(account -> account.getAddress()).collect(Collectors.toList()); + List> addressFilteringCases = Arrays.asList(null, testAddresses); + + // AssetID filtering test cases + List> assetIdFilteringCases = Arrays.asList(null, Arrays.asList(0L, 1L, 2L)); + + // Results ordering test cases + List orderingCases = new ArrayList<>(); + orderingCases.add(null); + orderingCases.addAll(Arrays.asList(BalanceOrdering.values())); + + // Zero exclusion test cases + List zeroExclusionCases = Arrays.asList(null, true, false); + + // Limit test cases + List limitCases = Arrays.asList(null, 10); + + // Offset test cases + List offsetCases = Arrays.asList(null, 10); + + // Reverse results cases + List reverseCases = Arrays.asList(null, true, false); + + repository.setDebug(true); + + // Test all cases + for (List addresses : addressFilteringCases) + for (List assetIds : assetIdFilteringCases) + for (BalanceOrdering balanceOrdering : orderingCases) + for (Boolean excludeZero : zeroExclusionCases) + for (Integer limit : limitCases) + for (Integer offset : offsetCases) + for (Boolean reverse : reverseCases) { + repository.discardChanges(); + + System.out.println(String.format("Testing query: %s addresses, %s assetIDs, %s ordering, %b zero-exclusion, %d limit, %d offset, %b reverse", + (addresses == null ? "no" : "with"), (assetIds == null ? "no" : "with"), balanceOrdering, excludeZero, limit, offset, reverse)); + + long before = System.currentTimeMillis(); + repository.getAccountRepository().getAssetBalances(addresses, assetIds, balanceOrdering, excludeZero, limit, offset, reverse); + final long period = System.currentTimeMillis() - before; + assertTrue(String.format("Query too slow: %dms", period), period < MAX_QUERY_TIME); + } + } + + // Rebuild repository to avoid orphan check + Common.useDefaultSettings(); + } + } \ No newline at end of file diff --git a/src/test/java/org/qora/test/TransactionTests.java b/src/test/java/org/qora/test/TransactionTests.java deleted file mode 100644 index cb8acba9..00000000 --- a/src/test/java/org/qora/test/TransactionTests.java +++ /dev/null @@ -1,1164 +0,0 @@ -package org.qora.test; - -import org.junit.After; -import org.junit.Test; -import org.qora.account.Account; -import org.qora.account.PrivateKeyAccount; -import org.qora.account.PublicKeyAccount; -import org.qora.asset.Asset; -import org.qora.block.Block; -import org.qora.block.BlockChain; -import org.qora.data.PaymentData; -import org.qora.data.account.AccountBalanceData; -import org.qora.data.account.AccountData; -import org.qora.data.asset.AssetData; -import org.qora.data.asset.OrderData; -import org.qora.data.asset.TradeData; -import org.qora.data.block.BlockData; -import org.qora.data.naming.NameData; -import org.qora.data.transaction.BaseTransactionData; -import org.qora.data.transaction.BuyNameTransactionData; -import org.qora.data.transaction.CancelAssetOrderTransactionData; -import org.qora.data.transaction.CancelSellNameTransactionData; -import org.qora.data.transaction.CreateAssetOrderTransactionData; -import org.qora.data.transaction.CreatePollTransactionData; -import org.qora.data.transaction.IssueAssetTransactionData; -import org.qora.data.transaction.MessageTransactionData; -import org.qora.data.transaction.MultiPaymentTransactionData; -import org.qora.data.transaction.PaymentTransactionData; -import org.qora.data.transaction.RegisterNameTransactionData; -import org.qora.data.transaction.SellNameTransactionData; -import org.qora.data.transaction.TransactionData; -import org.qora.data.transaction.TransferAssetTransactionData; -import org.qora.data.transaction.UpdateNameTransactionData; -import org.qora.data.transaction.VoteOnPollTransactionData; -import org.qora.data.voting.PollData; -import org.qora.data.voting.PollOptionData; -import org.qora.data.voting.VoteOnPollData; -import org.qora.group.Group; -import org.qora.repository.AccountRepository; -import org.qora.repository.AssetRepository; -import org.qora.repository.DataException; -import org.qora.repository.Repository; -import org.qora.repository.RepositoryManager; -import org.qora.test.common.Common; -import org.qora.transaction.BuyNameTransaction; -import org.qora.transaction.CancelAssetOrderTransaction; -import org.qora.transaction.CancelSellNameTransaction; -import org.qora.transaction.CreateAssetOrderTransaction; -import org.qora.transaction.CreatePollTransaction; -import org.qora.transaction.IssueAssetTransaction; -import org.qora.transaction.MessageTransaction; -import org.qora.transaction.MultiPaymentTransaction; -import org.qora.transaction.PaymentTransaction; -import org.qora.transaction.RegisterNameTransaction; -import org.qora.transaction.SellNameTransaction; -import org.qora.transaction.Transaction; -import org.qora.transaction.TransferAssetTransaction; -import org.qora.transaction.UpdateNameTransaction; -import org.qora.transaction.VoteOnPollTransaction; -import org.qora.transaction.Transaction.ValidationResult; - -import static org.junit.Assert.*; - -import java.io.UnsupportedEncodingException; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import com.google.common.hash.HashCode; - -public class TransactionTests extends Common { - - private static final byte[] generatorSeed = HashCode.fromString("0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210").asBytes(); - private static final byte[] senderSeed = HashCode.fromString("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef").asBytes(); - private static final byte[] recipientSeed = HashCode.fromString("fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210").asBytes(); - - private static final BigDecimal initialGeneratorBalance = BigDecimal.valueOf(1_000_000_000L).setScale(8); - private static final BigDecimal initialSenderBalance = BigDecimal.valueOf(1_000_000L).setScale(8); - private static final BigDecimal genericPaymentAmount = BigDecimal.valueOf(1_000L).setScale(8); - - private Repository repository; - private AccountRepository accountRepository; - private BlockData parentBlockData; - private PrivateKeyAccount sender; - private PrivateKeyAccount generator; - private byte[] reference; - - public void createTestAccounts(Long genesisTimestamp) throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - assertEquals("Blockchain should be empty for this test", 0, repository.getBlockRepository().getBlockchainHeight()); - } - - // This needs to be called outside of acquiring our own repository or it will deadlock - BlockChain.validate(); - - // Grab repository for further use, including during test itself - repository = RepositoryManager.getRepository(); - - // Grab genesis block - parentBlockData = repository.getBlockRepository().fromHeight(1); - - accountRepository = repository.getAccountRepository(); - - // Create test generator account - generator = new PrivateKeyAccount(repository, generatorSeed); - accountRepository.setLastReference(new AccountData(generator.getAddress(), generatorSeed, generator.getPublicKey(), Group.NO_GROUP, 0, 0, 0, 0)); - accountRepository.save(new AccountBalanceData(generator.getAddress(), Asset.QORT, initialGeneratorBalance)); - - // Create test sender account - sender = new PrivateKeyAccount(repository, senderSeed); - - // Mock account - reference = senderSeed; - accountRepository.setLastReference(new AccountData(sender.getAddress(), reference, sender.getPublicKey(), Group.NO_GROUP, 0, 0, 0, 0)); - - // Mock balance - accountRepository.save(new AccountBalanceData(sender.getAddress(), Asset.QORT, initialSenderBalance)); - - repository.saveChanges(); - } - - @After - public void afterTest() throws DataException { - repository.close(); - } - - private Transaction createPayment(PrivateKeyAccount sender, String recipient) throws DataException { - // Make a new payment transaction - BigDecimal amount = genericPaymentAmount; - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - PaymentTransactionData paymentTransactionData = new PaymentTransactionData(baseTransactionData, recipient, amount); - - Transaction paymentTransaction = new PaymentTransaction(repository, paymentTransactionData); - paymentTransaction.sign(sender); - - return paymentTransaction; - } - - @Test - public void testPaymentTransaction() throws DataException { - createTestAccounts(null); - - // Make a new payment transaction - Account recipient = new PublicKeyAccount(repository, recipientSeed); - BigDecimal amount = BigDecimal.valueOf(1_000L); - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - PaymentTransactionData paymentTransactionData = new PaymentTransactionData(baseTransactionData, recipient.getAddress(), amount); - - Transaction paymentTransaction = new PaymentTransaction(repository, paymentTransactionData); - paymentTransaction.sign(sender); - assertTrue(paymentTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, paymentTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(paymentTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check sender's balance - BigDecimal expectedBalance = initialSenderBalance.subtract(amount).subtract(fee); - BigDecimal actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Fee should be in generator's balance - expectedBalance = initialGeneratorBalance.add(fee); - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Amount should be in recipient's balance - expectedBalance = amount; - actualBalance = accountRepository.getBalance(recipient.getAddress(), Asset.QORT).getBalance(); - assertTrue("Recipient's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Check recipient's reference - byte[] recipientsReference = recipient.getLastReference(); - assertTrue("Recipient's new reference incorrect", Arrays.equals(paymentTransaction.getTransactionData().getSignature(), recipientsReference)); - - // Orphan block - block.orphan(); - repository.saveChanges(); - - // Check sender's balance - actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's reverted balance incorrect", initialSenderBalance.compareTo(actualBalance) == 0); - - // Check generator's balance - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's new balance incorrect", initialGeneratorBalance.compareTo(actualBalance) == 0); - } - - @Test - public void testRegisterNameTransaction() throws DataException { - createTestAccounts(null); - - // Make a new register name transaction - String name = "test name"; - String data = "{\"key\":\"value\"}"; - - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - RegisterNameTransactionData registerNameTransactionData = new RegisterNameTransactionData(baseTransactionData, sender.getAddress(), name, data); - - Transaction registerNameTransaction = new RegisterNameTransaction(repository, registerNameTransactionData); - registerNameTransaction.sign(sender); - assertTrue(registerNameTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, registerNameTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(registerNameTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check sender's balance - BigDecimal expectedBalance = initialSenderBalance.subtract(fee); - BigDecimal actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Fee should be in generator's balance - expectedBalance = initialGeneratorBalance.add(fee); - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Check name was registered - NameData actualNameData = this.repository.getNameRepository().fromName(name); - assertNotNull(actualNameData); - - // Check sender's reference - assertTrue("Sender's new reference incorrect", Arrays.equals(registerNameTransactionData.getSignature(), sender.getLastReference())); - - // Update variables for use by other tests - reference = sender.getLastReference(); - parentBlockData = block.getBlockData(); - } - - @Test - public void testUpdateNameTransaction() throws DataException { - // Register name using another test - testRegisterNameTransaction(); - - String name = "test name"; - NameData originalNameData = this.repository.getNameRepository().fromName(name); - - // Update name's owner and data - Account newOwner = new PublicKeyAccount(repository, recipientSeed); - String newData = "{\"newKey\":\"newValue\"}"; - - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - UpdateNameTransactionData updateNameTransactionData = new UpdateNameTransactionData(baseTransactionData, newOwner.getAddress(), name, newData); - - Transaction updateNameTransaction = new UpdateNameTransaction(repository, updateNameTransactionData); - updateNameTransaction.sign(sender); - assertTrue(updateNameTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, updateNameTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(updateNameTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check name was updated - NameData actualNameData = this.repository.getNameRepository().fromName(name); - assertEquals(newOwner.getAddress(), actualNameData.getOwner()); - assertEquals(newData, actualNameData.getData()); - - // Now orphan block - block.orphan(); - repository.saveChanges(); - - // Check name has been reverted correctly - actualNameData = this.repository.getNameRepository().fromName(name); - assertEquals(originalNameData.getOwner(), actualNameData.getOwner()); - assertEquals(originalNameData.getData(), actualNameData.getData()); - } - - @Test - public void testSellNameTransaction() throws DataException { - // Register name using another test - testRegisterNameTransaction(); - - String name = "test name"; - - // Sale price - BigDecimal amount = BigDecimal.valueOf(1234L).setScale(8); - - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - SellNameTransactionData sellNameTransactionData = new SellNameTransactionData(baseTransactionData, name, amount); - - Transaction sellNameTransaction = new SellNameTransaction(repository, sellNameTransactionData); - sellNameTransaction.sign(sender); - assertTrue(sellNameTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, sellNameTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(sellNameTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check name was updated - NameData actualNameData = this.repository.getNameRepository().fromName(name); - assertTrue(actualNameData.getIsForSale()); - assertEquals(amount, actualNameData.getSalePrice()); - - // Now orphan block - block.orphan(); - repository.saveChanges(); - - // Check name has been reverted correctly - actualNameData = this.repository.getNameRepository().fromName(name); - assertFalse(actualNameData.getIsForSale()); - assertNull(actualNameData.getSalePrice()); - - // Re-process block for use by other tests - block.process(); - repository.saveChanges(); - - // Update variables for use by other tests - reference = sender.getLastReference(); - parentBlockData = block.getBlockData(); - } - - @Test - public void testCancelSellNameTransaction() throws DataException { - // Register and sell name using another test - testSellNameTransaction(); - - String name = "test name"; - NameData originalNameData = this.repository.getNameRepository().fromName(name); - - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - CancelSellNameTransactionData cancelSellNameTransactionData = new CancelSellNameTransactionData(baseTransactionData, name); - - Transaction cancelSellNameTransaction = new CancelSellNameTransaction(repository, cancelSellNameTransactionData); - cancelSellNameTransaction.sign(sender); - assertTrue(cancelSellNameTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, cancelSellNameTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(cancelSellNameTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check name was updated - NameData actualNameData = this.repository.getNameRepository().fromName(name); - assertFalse(actualNameData.getIsForSale()); - assertEquals(originalNameData.getSalePrice(), actualNameData.getSalePrice()); - - // Now orphan block - block.orphan(); - repository.saveChanges(); - - // Check name has been reverted correctly - actualNameData = this.repository.getNameRepository().fromName(name); - assertTrue(actualNameData.getIsForSale()); - assertEquals(originalNameData.getSalePrice(), actualNameData.getSalePrice()); - - // Update variables for use by other tests - reference = sender.getLastReference(); - parentBlockData = block.getBlockData(); - } - - @Test - public void testBuyNameTransaction() throws DataException { - // Register and sell name using another test - testSellNameTransaction(); - - String name = "test name"; - NameData originalNameData = this.repository.getNameRepository().fromName(name); - String seller = originalNameData.getOwner(); - - // Buyer - PrivateKeyAccount buyer = new PrivateKeyAccount(repository, recipientSeed); - - // Send buyer some funds so they have a reference - Transaction somePaymentTransaction = createPayment(sender, buyer.getAddress()); - byte[] buyersReference = somePaymentTransaction.getTransactionData().getSignature(); - - // Forge new block with transaction - Block block = forgeBlock(somePaymentTransaction.getTransactionData()); - - block.process(); - repository.saveChanges(); - parentBlockData = block.getBlockData(); - - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, buyersReference, buyer.getPublicKey(), fee, null); - BuyNameTransactionData buyNameTransactionData = new BuyNameTransactionData(baseTransactionData, name, originalNameData.getSalePrice(), seller); - - Transaction buyNameTransaction = new BuyNameTransaction(repository, buyNameTransactionData); - buyNameTransaction.sign(buyer); - assertTrue(buyNameTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, buyNameTransaction.isValid()); - - // Forge new block with transaction - block = forgeBlock(buyNameTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check name was updated - NameData actualNameData = this.repository.getNameRepository().fromName(name); - assertFalse(actualNameData.getIsForSale()); - assertEquals(originalNameData.getSalePrice(), actualNameData.getSalePrice()); - assertEquals(buyer.getAddress(), actualNameData.getOwner()); - - // Now orphan block - block.orphan(); - repository.saveChanges(); - - // Check name has been reverted correctly - actualNameData = this.repository.getNameRepository().fromName(name); - assertTrue(actualNameData.getIsForSale()); - assertEquals(originalNameData.getSalePrice(), actualNameData.getSalePrice()); - assertEquals(originalNameData.getOwner(), actualNameData.getOwner()); - } - - @Test - public void testCreatePollTransaction() throws DataException { - // This test requires GenesisBlock's timestamp is set to something after BlockChain.VOTING_RELEASE_TIMESTAMP - createTestAccounts(BlockChain.getInstance().getVotingReleaseTimestamp() + 1_000L); - - // Make a new create poll transaction - String pollName = "test poll"; - String description = "test poll description"; - - List pollOptions = new ArrayList(); - pollOptions.add(new PollOptionData("abort")); - pollOptions.add(new PollOptionData("retry")); - pollOptions.add(new PollOptionData("fail")); - - Account recipient = new PublicKeyAccount(repository, recipientSeed); - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - CreatePollTransactionData createPollTransactionData = new CreatePollTransactionData(baseTransactionData, recipient.getAddress(), pollName, description, pollOptions); - - Transaction createPollTransaction = new CreatePollTransaction(repository, createPollTransactionData); - createPollTransaction.sign(sender); - assertTrue(createPollTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, createPollTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(createPollTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check sender's balance - BigDecimal expectedBalance = initialSenderBalance.subtract(fee); - BigDecimal actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Fee should be in generator's balance - expectedBalance = initialGeneratorBalance.add(fee); - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Check poll was created - PollData actualPollData = this.repository.getVotingRepository().fromPollName(pollName); - assertNotNull(actualPollData); - - // Check sender's reference - assertTrue("Sender's new reference incorrect", Arrays.equals(createPollTransactionData.getSignature(), sender.getLastReference())); - - // Update variables for use by other tests - reference = sender.getLastReference(); - parentBlockData = block.getBlockData(); - } - - @Test - public void testVoteOnPollTransaction() throws DataException { - // Create poll using another test - testCreatePollTransaction(); - - // Try all options, plus invalid optionIndex (note use of <= for this) - String pollName = "test poll"; - int pollOptionsSize = 3; - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - for (int optionIndex = 0; optionIndex <= pollOptionsSize; ++optionIndex) { - // Make a vote-on-poll transaction - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - VoteOnPollTransactionData voteOnPollTransactionData = new VoteOnPollTransactionData(baseTransactionData, pollName, optionIndex); - - Transaction voteOnPollTransaction = new VoteOnPollTransaction(repository, voteOnPollTransactionData); - voteOnPollTransaction.sign(sender); - assertTrue(voteOnPollTransaction.isSignatureValid()); - - if (optionIndex == pollOptionsSize) { - assertEquals(ValidationResult.POLL_OPTION_DOES_NOT_EXIST, voteOnPollTransaction.isValid()); - break; - } - assertEquals(ValidationResult.OK, voteOnPollTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(voteOnPollTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check vote was registered properly - VoteOnPollData actualVoteOnPollData = repository.getVotingRepository().getVote(pollName, sender.getPublicKey()); - assertNotNull(actualVoteOnPollData); - assertEquals(optionIndex, actualVoteOnPollData.getOptionIndex()); - - // update variables for next round - parentBlockData = block.getBlockData(); - timestamp += 1_000; - reference = voteOnPollTransaction.getTransactionData().getSignature(); - } - - // Check poll's votes - List votes = repository.getVotingRepository().getVotes(pollName); - assertNotNull(votes); - - assertEquals("Only one vote expected", 1, votes.size()); - - assertEquals("Wrong vote option index", pollOptionsSize - 1, votes.get(0).getOptionIndex()); - assertTrue("Wrong voter public key", Arrays.equals(sender.getPublicKey(), votes.get(0).getVoterPublicKey())); - - // Orphan last block - BlockData lastBlockData = repository.getBlockRepository().getLastBlock(); - Block lastBlock = new Block(repository, lastBlockData); - lastBlock.orphan(); - repository.saveChanges(); - - // Re-check poll's votes - votes = repository.getVotingRepository().getVotes(pollName); - assertNotNull(votes); - - assertEquals("Only one vote expected", 1, votes.size()); - - assertEquals("Wrong vote option index", pollOptionsSize - 1 - 1, votes.get(0).getOptionIndex()); - assertTrue("Wrong voter public key", Arrays.equals(sender.getPublicKey(), votes.get(0).getVoterPublicKey())); - } - - @Test - public void testIssueAssetTransaction() throws DataException { - createTestAccounts(null); - - // Create new asset - String assetName = "test asset"; - String description = "test asset description"; - long quantity = 1_000_000L; - boolean isDivisible = true; - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - String data = (timestamp >= BlockChain.getInstance().getQoraV2Timestamp()) ? "{}" : null; - boolean isUnspendable = false; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - IssueAssetTransactionData issueAssetTransactionData = new IssueAssetTransactionData(baseTransactionData, sender.getAddress(), assetName, description, quantity, isDivisible, data, isUnspendable); - - Transaction issueAssetTransaction = new IssueAssetTransaction(repository, issueAssetTransactionData); - issueAssetTransaction.sign(sender); - assertTrue(issueAssetTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, issueAssetTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(issueAssetTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check sender's balance - BigDecimal expectedBalance = initialSenderBalance.subtract(fee); - BigDecimal actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Fee should be in generator's balance - expectedBalance = initialGeneratorBalance.add(fee); - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Check we now have an assetId - Long assetId = issueAssetTransactionData.getAssetId(); - assertNotNull(assetId); - // Should NOT collide with Asset.QORA - assertFalse(assetId == Asset.QORT); - - // Check asset now exists - AssetRepository assetRepo = this.repository.getAssetRepository(); - assertTrue(assetRepo.assetExists(assetId)); - assertTrue(assetRepo.assetExists(assetName)); - // Check asset data - AssetData assetData = assetRepo.fromAssetId(assetId); - assertNotNull(assetData); - assertEquals(assetName, assetData.getName()); - assertEquals(description, assetData.getDescription()); - - // Orphan block - block.orphan(); - repository.saveChanges(); - - // Check sender's balance - actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's reverted balance incorrect", initialSenderBalance.compareTo(actualBalance) == 0); - - // Check generator's balance - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's reverted balance incorrect", initialGeneratorBalance.compareTo(actualBalance) == 0); - - // Check asset no longer exists - assertFalse(assetRepo.assetExists(assetId)); - assertFalse(assetRepo.assetExists(assetName)); - assetData = assetRepo.fromAssetId(assetId); - assertNull(assetData); - - // Re-process block for use by other tests - block.process(); - repository.saveChanges(); - - // Update variables for use by other tests - reference = sender.getLastReference(); - parentBlockData = block.getBlockData(); - } - - @Test - public void testTransferAssetTransaction() throws DataException { - // Issue asset using another test - testIssueAssetTransaction(); - - String assetName = "test asset"; - AssetRepository assetRepo = this.repository.getAssetRepository(); - AssetData originalAssetData = assetRepo.fromAssetName(assetName); - long assetId = originalAssetData.getAssetId(); - BigDecimal originalSenderBalance = sender.getConfirmedBalance(Asset.QORT); - BigDecimal originalGeneratorBalance = generator.getConfirmedBalance(Asset.QORT); - - // Transfer asset to new recipient - Account recipient = new PublicKeyAccount(repository, recipientSeed); - BigDecimal amount = BigDecimal.valueOf(1_000L).setScale(8); - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - TransferAssetTransactionData transferAssetTransactionData = new TransferAssetTransactionData(baseTransactionData, recipient.getAddress(), amount, assetId); - - Transaction transferAssetTransaction = new TransferAssetTransaction(repository, transferAssetTransactionData); - transferAssetTransaction.sign(sender); - assertTrue(transferAssetTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, transferAssetTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(transferAssetTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check sender's balance - BigDecimal expectedBalance = originalSenderBalance.subtract(fee); - BigDecimal actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Fee should be in generator's balance - expectedBalance = originalGeneratorBalance.add(fee); - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Check asset balances - BigDecimal actualSenderAssetBalance = sender.getConfirmedBalance(assetId); - assertNotNull(actualSenderAssetBalance); - BigDecimal expectedSenderAssetBalance = BigDecimal.valueOf(originalAssetData.getQuantity()).setScale(8).subtract(amount); - assertEquals(expectedSenderAssetBalance, actualSenderAssetBalance); - - BigDecimal actualRecipientAssetBalance = recipient.getConfirmedBalance(assetId); - assertNotNull(actualRecipientAssetBalance); - assertEquals(amount, actualRecipientAssetBalance); - - // Orphan block - block.orphan(); - repository.saveChanges(); - - // Check sender's balance - actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's reverted balance incorrect", originalSenderBalance.compareTo(actualBalance) == 0); - - // Check generator's balance - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's reverted balance incorrect", originalGeneratorBalance.compareTo(actualBalance) == 0); - - // Check asset balances - actualSenderAssetBalance = sender.getConfirmedBalance(assetId); - assertNotNull(actualSenderAssetBalance); - expectedSenderAssetBalance = BigDecimal.valueOf(originalAssetData.getQuantity()).setScale(8); - assertEquals(expectedSenderAssetBalance, actualSenderAssetBalance); - - actualRecipientAssetBalance = recipient.getConfirmedBalance(assetId); - if (actualRecipientAssetBalance != null) - assertEquals(BigDecimal.ZERO.setScale(8), actualRecipientAssetBalance); - - // Re-process block for use by other tests - block.process(); - repository.saveChanges(); - - // Update variables for use by other tests - reference = sender.getLastReference(); - parentBlockData = block.getBlockData(); - } - - @Test - public void testCreateAssetOrderTransaction() throws DataException { - // Issue asset using another test - testIssueAssetTransaction(); - - // Asset info - String assetName = "test asset"; - AssetRepository assetRepo = this.repository.getAssetRepository(); - AssetData originalAssetData = assetRepo.fromAssetName(assetName); - long assetId = originalAssetData.getAssetId(); - - // Buyer - PrivateKeyAccount buyer = new PrivateKeyAccount(repository, recipientSeed); - - // Send buyer some funds so they have a reference - Transaction somePaymentTransaction = createPayment(sender, buyer.getAddress()); - byte[] buyersReference = somePaymentTransaction.getTransactionData().getSignature(); - - // Forge new block with transaction - Block block = forgeBlock(somePaymentTransaction.getTransactionData()); - - block.process(); - repository.saveChanges(); - parentBlockData = block.getBlockData(); - - // Order: buyer has 10 QORA and wants to buy "test asset" at a price of 50 "test asset" per QORA. - long haveAssetId = Asset.QORT; - BigDecimal amount = BigDecimal.valueOf(10).setScale(8); - long wantAssetId = assetId; - BigDecimal price = BigDecimal.valueOf(50).setScale(8); - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, buyersReference, sender.getPublicKey(), fee, null); - CreateAssetOrderTransactionData createOrderTransactionData = new CreateAssetOrderTransactionData(baseTransactionData, haveAssetId, wantAssetId, amount, price); - - Transaction createOrderTransaction = new CreateAssetOrderTransaction(this.repository, createOrderTransactionData); - createOrderTransaction.sign(buyer); - assertTrue(createOrderTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, createOrderTransaction.isValid()); - - // Forge new block with transaction - block = forgeBlock(createOrderTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check order was created - byte[] orderId = createOrderTransactionData.getSignature(); - OrderData orderData = assetRepo.fromOrderId(orderId); - assertNotNull(orderData); - - // Check buyer's balance reduced - BigDecimal expectedBalance = genericPaymentAmount.subtract(amount).subtract(fee); - BigDecimal actualBalance = buyer.getConfirmedBalance(haveAssetId); - assertTrue(expectedBalance.compareTo(actualBalance) == 0); - - // Orphan transaction - block.orphan(); - repository.saveChanges(); - - // Check order no longer exists - orderData = assetRepo.fromOrderId(orderId); - assertNull(orderData); - - // Check buyer's balance restored - expectedBalance = genericPaymentAmount; - actualBalance = buyer.getConfirmedBalance(haveAssetId); - assertTrue(expectedBalance.compareTo(actualBalance) == 0); - - // Re-process to allow use by other tests - block.process(); - repository.saveChanges(); - - // Update variables for use by other tests - reference = sender.getLastReference(); - parentBlockData = block.getBlockData(); - } - - @Test - public void testCancelAssetOrderTransaction() throws DataException { - // Issue asset and create order using another test - testCreateAssetOrderTransaction(); - - // Asset info - String assetName = "test asset"; - AssetRepository assetRepo = this.repository.getAssetRepository(); - AssetData originalAssetData = assetRepo.fromAssetName(assetName); - long assetId = originalAssetData.getAssetId(); - - // Buyer - PrivateKeyAccount buyer = new PrivateKeyAccount(repository, recipientSeed); - - // Fetch orders - long haveAssetId = Asset.QORT; - long wantAssetId = assetId; - List orders = assetRepo.getOpenOrders(haveAssetId, wantAssetId); - - assertNotNull(orders); - assertEquals(1, orders.size()); - - OrderData originalOrderData = orders.get(0); - assertNotNull(originalOrderData); - assertFalse(originalOrderData.getIsClosed()); - - // Create cancel order transaction - byte[] orderId = originalOrderData.getOrderId(); - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - byte[] buyersReference = buyer.getLastReference(); - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, buyersReference, sender.getPublicKey(), fee, null); - CancelAssetOrderTransactionData cancelOrderTransactionData = new CancelAssetOrderTransactionData(baseTransactionData, orderId); - - Transaction cancelOrderTransaction = new CancelAssetOrderTransaction(this.repository, cancelOrderTransactionData); - cancelOrderTransaction.sign(buyer); - assertTrue(cancelOrderTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, cancelOrderTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(cancelOrderTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check order is marked as cancelled - OrderData cancelledOrderData = assetRepo.fromOrderId(orderId); - assertNotNull(cancelledOrderData); - assertTrue(cancelledOrderData.getIsClosed()); - - // Orphan - block.orphan(); - repository.saveChanges(); - - // Check order is no longer marked as cancelled - OrderData uncancelledOrderData = assetRepo.fromOrderId(orderId); - assertNotNull(uncancelledOrderData); - assertFalse(uncancelledOrderData.getIsClosed()); - - } - - @Test - public void testMatchingCreateAssetOrderTransaction() throws DataException { - // Issue asset and create order using another test - testCreateAssetOrderTransaction(); - - // Asset info - String assetName = "test asset"; - AssetRepository assetRepo = this.repository.getAssetRepository(); - AssetData originalAssetData = assetRepo.fromAssetName(assetName); - long assetId = originalAssetData.getAssetId(); - - // Buyer - PrivateKeyAccount buyer = new PrivateKeyAccount(repository, recipientSeed); - - // Fetch orders - long originalHaveAssetId = Asset.QORT; - long originalWantAssetId = assetId; - List orders = assetRepo.getOpenOrders(originalHaveAssetId, originalWantAssetId); - - assertNotNull(orders); - assertEquals(1, orders.size()); - - OrderData originalOrderData = orders.get(0); - assertNotNull(originalOrderData); - assertFalse(originalOrderData.getIsClosed()); - - // Unfulfilled order: "buyer" has 10 QORA and wants to buy "test asset" at a price of 50 "test asset" per QORA. - // buyer's order: have=QORA, amount=10, want=test-asset, price=50 (test-asset per QORA, so max return is 500 test-asset) - - // Original asset owner (sender) will sell asset to "buyer" - - // Order: seller has 40 "test asset" and wants to buy QORA at a price of 1/60 QORA per "test asset". - // This order should be a partial match for original order, and at a better price than asked - long haveAssetId = assetId; - BigDecimal amount = BigDecimal.valueOf(40).setScale(8); - long wantAssetId = Asset.QORT; - BigDecimal price = BigDecimal.ONE.setScale(8).divide(BigDecimal.valueOf(60).setScale(8), RoundingMode.DOWN); - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - BigDecimal senderPreTradeWantBalance = sender.getConfirmedBalance(wantAssetId); - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - CreateAssetOrderTransactionData createOrderTransactionData = new CreateAssetOrderTransactionData(baseTransactionData, haveAssetId, wantAssetId, amount, price); - - Transaction createOrderTransaction = new CreateAssetOrderTransaction(this.repository, createOrderTransactionData); - createOrderTransaction.sign(sender); - assertTrue(createOrderTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, createOrderTransaction.isValid()); - - // Forge new block with transaction - Block block = forgeBlock(createOrderTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check order was created - byte[] orderId = createOrderTransactionData.getSignature(); - OrderData orderData = assetRepo.fromOrderId(orderId); - assertNotNull(orderData); - - // Check order has trades - List trades = assetRepo.getOrdersTrades(orderId); - assertNotNull(trades); - assertEquals("Trade didn't happen", 1, trades.size()); - TradeData tradeData = trades.get(0); - - // Check trade has correct values - BigDecimal expectedAmount = amount.divide(originalOrderData.getPrice()).setScale(8); - BigDecimal actualAmount = tradeData.getTargetAmount(); - assertTrue(expectedAmount.compareTo(actualAmount) == 0); - - BigDecimal expectedPrice = amount; - BigDecimal actualPrice = tradeData.getInitiatorAmount(); - assertTrue(expectedPrice.compareTo(actualPrice) == 0); - - // Check seller's "test asset" balance - BigDecimal expectedBalance = BigDecimal.valueOf(1_000_000L).setScale(8).subtract(amount); - BigDecimal actualBalance = sender.getConfirmedBalance(haveAssetId); - assertTrue(expectedBalance.compareTo(actualBalance) == 0); - - // Check buyer's "test asset" balance - expectedBalance = amount; - actualBalance = buyer.getConfirmedBalance(haveAssetId); - assertTrue(expectedBalance.compareTo(actualBalance) == 0); - - // Check seller's QORA balance - expectedBalance = senderPreTradeWantBalance.subtract(BigDecimal.ONE).add(expectedAmount); - actualBalance = sender.getConfirmedBalance(wantAssetId); - assertTrue(expectedBalance.compareTo(actualBalance) == 0); - - // Check seller's order is correctly fulfilled - assertTrue(orderData.getIsFulfilled()); - - // Check buyer's order is still not fulfilled - OrderData buyersOrderData = assetRepo.fromOrderId(originalOrderData.getOrderId()); - assertFalse(buyersOrderData.getIsFulfilled()); - - // Orphan transaction - block.orphan(); - repository.saveChanges(); - - // Check order no longer exists - orderData = assetRepo.fromOrderId(orderId); - assertNull(orderData); - - // Check trades no longer exist - trades = assetRepo.getOrdersTrades(orderId); - assertNotNull(trades); - assertEquals(0, trades.size()); - - // Check seller's "test asset" balance restored - expectedBalance = BigDecimal.valueOf(1_000_000L).setScale(8); - actualBalance = sender.getConfirmedBalance(haveAssetId); - assertTrue(expectedBalance.compareTo(actualBalance) == 0); - - // Check buyer's "test asset" balance restored - expectedBalance = BigDecimal.ZERO.setScale(8); - actualBalance = buyer.getConfirmedBalance(haveAssetId); - assertTrue(expectedBalance.compareTo(actualBalance) == 0); - } - - @Test - public void testMultiPaymentTransaction() throws DataException { - createTestAccounts(null); - - // Make a new multi-payment transaction - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - - // Payments - BigDecimal expectedSenderBalance = initialSenderBalance.subtract(fee); - List payments = new ArrayList(); - for (int i = 0; i < 5; ++i) { - byte[] seed = recipientSeed.clone(); - seed[0] += i; - Account recipient = new PublicKeyAccount(repository, seed); - long assetId = Asset.QORT; - - BigDecimal amount = BigDecimal.valueOf(1_000L + i).setScale(8); - expectedSenderBalance = expectedSenderBalance.subtract(amount); - - PaymentData paymentData = new PaymentData(recipient.getAddress(), assetId, amount); - - payments.add(paymentData); - } - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - MultiPaymentTransactionData multiPaymentTransactionData = new MultiPaymentTransactionData(baseTransactionData, payments); - - Transaction multiPaymentTransaction = new MultiPaymentTransaction(repository, multiPaymentTransactionData); - multiPaymentTransaction.sign(sender); - assertTrue(multiPaymentTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, multiPaymentTransaction.isValid()); - - // Forge new block with payment transaction - Block block = forgeBlock(multiPaymentTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check sender's balance - BigDecimal actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's new balance incorrect", expectedSenderBalance.compareTo(actualBalance) == 0); - - // Fee should be in generator's balance - BigDecimal expectedBalance = initialGeneratorBalance.add(fee); - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Check recipients - for (int i = 0; i < payments.size(); ++i) { - PaymentData paymentData = payments.get(i); - Account recipient = new Account(this.repository, paymentData.getRecipient()); - - byte[] recipientsReference = recipient.getLastReference(); - assertTrue("Recipient's new reference incorrect", Arrays.equals(multiPaymentTransaction.getTransactionData().getSignature(), recipientsReference)); - - // Amount should be in recipient's balance - expectedBalance = paymentData.getAmount(); - actualBalance = accountRepository.getBalance(recipient.getAddress(), Asset.QORT).getBalance(); - assertTrue("Recipient's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - } - - // Orphan block - block.orphan(); - repository.saveChanges(); - - // Check sender's balance - actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's reverted balance incorrect", initialSenderBalance.compareTo(actualBalance) == 0); - - // Check generator's balance - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's new balance incorrect", initialGeneratorBalance.compareTo(actualBalance) == 0); - } - - @Test - public void testMessageTransaction() throws DataException, UnsupportedEncodingException { - createTestAccounts(1431861220336L); // timestamp taken from main blockchain block 99000 - - // Make a new message transaction - Account recipient = new PublicKeyAccount(repository, recipientSeed); - BigDecimal amount = BigDecimal.valueOf(1_000L); - BigDecimal fee = BigDecimal.ONE; - long timestamp = parentBlockData.getTimestamp() + 1_000; - int version = Transaction.getVersionByTimestamp(timestamp); - byte[] data = "test".getBytes("UTF-8"); - boolean isText = true; - boolean isEncrypted = false; - - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, sender.getPublicKey(), fee, null); - MessageTransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, - recipient.getAddress(), Asset.QORT, amount, data, isText, isEncrypted); - - Transaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - messageTransaction.sign(sender); - assertTrue(messageTransaction.isSignatureValid()); - assertEquals(ValidationResult.OK, messageTransaction.isValid()); - - // Forge new block with message transaction - Block block = forgeBlock(messageTransactionData); - - assertTrue("Block signatures invalid", block.isSignatureValid()); - assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); - - block.process(); - repository.saveChanges(); - - // Check sender's balance - BigDecimal expectedBalance = initialSenderBalance.subtract(amount).subtract(fee); - BigDecimal actualBalance = accountRepository.getBalance(sender.getAddress(), Asset.QORT).getBalance(); - assertTrue("Sender's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Fee should be in generator's balance - expectedBalance = initialGeneratorBalance.add(fee); - actualBalance = accountRepository.getBalance(generator.getAddress(), Asset.QORT).getBalance(); - assertTrue("Generator's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - - // Amount should be in recipient's balance - expectedBalance = amount; - actualBalance = accountRepository.getBalance(recipient.getAddress(), Asset.QORT).getBalance(); - assertTrue("Recipient's new balance incorrect", expectedBalance.compareTo(actualBalance) == 0); - } - - private Block forgeBlock(TransactionData transactionData) throws DataException { - Block block = Block.mint(repository, parentBlockData, generator); - block.addTransaction(transactionData); - block.sign(); - return block; - } - -} \ No newline at end of file diff --git a/src/test/java/org/qora/test/api/AssetsApiTests.java b/src/test/java/org/qora/test/api/AssetsApiTests.java index 97776356..0061e699 100644 --- a/src/test/java/org/qora/test/api/AssetsApiTests.java +++ b/src/test/java/org/qora/test/api/AssetsApiTests.java @@ -86,7 +86,7 @@ public class AssetsApiTests extends ApiCommon { @Test public void testGetAssetInfo() { assertNotNull(this.assetsResource.getAssetInfo((int) 0L, null)); - assertNotNull(this.assetsResource.getAssetInfo(null, "QORA")); + assertNotNull(this.assetsResource.getAssetInfo(null, "QORT")); } @Test diff --git a/src/test/java/org/qora/test/common/AssetUtils.java b/src/test/java/org/qora/test/common/AssetUtils.java index 18488e67..99fa30da 100644 --- a/src/test/java/org/qora/test/common/AssetUtils.java +++ b/src/test/java/org/qora/test/common/AssetUtils.java @@ -28,9 +28,10 @@ public class AssetUtils { public static final int txGroupId = Group.NO_GROUP; public static final BigDecimal fee = BigDecimal.ONE.setScale(8); - public static final long testAssetId = 1L; // Owned by Alice - public static final long otherAssetId = 2L; // Owned by Bob - public static final long goldAssetId = 3L; // Owned by Alice + // QORT: 0, LEGACY_QORA: 1, QORT_FROM_QORA: 2 + public static final long testAssetId = 3L; // Owned by Alice + public static final long otherAssetId = 4L; // Owned by Bob + public static final long goldAssetId = 5L; // Owned by Alice public static long issueAsset(Repository repository, String issuerAccountName, String assetName, long quantity, boolean isDivisible) throws DataException { PrivateKeyAccount account = Common.getTestAccount(repository, issuerAccountName); diff --git a/src/test/java/org/qora/test/common/Common.java b/src/test/java/org/qora/test/common/Common.java index 172bff69..ee17ebb6 100644 --- a/src/test/java/org/qora/test/common/Common.java +++ b/src/test/java/org/qora/test/common/Common.java @@ -146,13 +146,13 @@ public class Common { } List remainingAssets = repository.getAssetRepository().getAllAssets(); - checkOrphanedLists("asset", initialAssets, remainingAssets, AssetData::getAssetId); + checkOrphanedLists("asset", initialAssets, remainingAssets, AssetData::getAssetId, AssetData::getAssetId); List remainingGroups = repository.getGroupRepository().getAllGroups(); - checkOrphanedLists("group", initialGroups, remainingGroups, GroupData::getGroupId); + checkOrphanedLists("group", initialGroups, remainingGroups, GroupData::getGroupId, GroupData::getGroupId); List remainingBalances = repository.getAccountRepository().getAssetBalances(Collections.emptyList(), Collections.emptyList(), BalanceOrdering.ASSET_ACCOUNT, false, null, null, null); - checkOrphanedLists("account balance", initialBalances, remainingBalances, entry -> entry.getAssetName() + "-" + entry.getAddress()); + checkOrphanedLists("account balance", initialBalances, remainingBalances, entry -> entry.getAddress() + " [" + entry.getAssetName() + "]", entry -> entry.getBalance().toPlainString()); assertEquals("remainingBalances is different size", initialBalances.size(), remainingBalances.size()); // Actually compare balances @@ -168,7 +168,7 @@ public class Common { } } - private static void checkOrphanedLists(String typeName, List initial, List remaining, Function keyExtractor) { + private static void checkOrphanedLists(String typeName, List initial, List remaining, Function keyExtractor, Function valueExtractor) { Predicate isInitial = entry -> initial.stream().anyMatch(initialEntry -> keyExtractor.apply(initialEntry).equals(keyExtractor.apply(entry))); Predicate isRemaining = entry -> remaining.stream().anyMatch(remainingEntry -> keyExtractor.apply(remainingEntry).equals(keyExtractor.apply(entry))); @@ -181,7 +181,7 @@ public class Common { remainingClone.removeIf(isInitial); for (T remainingEntry : remainingClone) - LOGGER.info(String.format("Non-genesis remaining entry: %s", keyExtractor.apply(remainingEntry))); + LOGGER.info(String.format("Non-genesis remaining entry: %s = %s", keyExtractor.apply(remainingEntry), valueExtractor.apply(remainingEntry))); assertTrue(String.format("Non-genesis %s remains", typeName), remainingClone.isEmpty()); } diff --git a/src/test/java/org/qora/test/common/TransactionUtils.java b/src/test/java/org/qora/test/common/TransactionUtils.java index 0dd17b47..2c3c93c2 100644 --- a/src/test/java/org/qora/test/common/TransactionUtils.java +++ b/src/test/java/org/qora/test/common/TransactionUtils.java @@ -25,7 +25,7 @@ public class TransactionUtils { // Add to unconfirmed assertTrue("Transaction's signature should be valid", transaction.isSignatureValid()); - // We might need to wait until transaction's timestamp is valid for the block we're about to generate + // We might need to wait until transaction's timestamp is valid for the block we're about to mint try { Thread.sleep(1L); } catch (InterruptedException e) { diff --git a/src/test/java/org/qora/test/naming/OrphaningTests.java b/src/test/java/org/qora/test/naming/BuySellTests.java similarity index 89% rename from src/test/java/org/qora/test/naming/OrphaningTests.java rename to src/test/java/org/qora/test/naming/BuySellTests.java index f6e33ca1..ecd577cd 100644 --- a/src/test/java/org/qora/test/naming/OrphaningTests.java +++ b/src/test/java/org/qora/test/naming/BuySellTests.java @@ -11,6 +11,7 @@ import org.junit.Test; import org.qora.account.PrivateKeyAccount; import org.qora.data.naming.NameData; import org.qora.data.transaction.BuyNameTransactionData; +import org.qora.data.transaction.CancelSellNameTransactionData; import org.qora.data.transaction.RegisterNameTransactionData; import org.qora.data.transaction.SellNameTransactionData; import org.qora.repository.DataException; @@ -21,7 +22,7 @@ import org.qora.test.common.Common; import org.qora.test.common.TransactionUtils; import org.qora.test.common.transaction.TestTransaction; -public class OrphaningTests extends Common { +public class BuySellTests extends Common { protected static final Random random = new Random(); @@ -136,6 +137,31 @@ public class OrphaningTests extends Common { assertEqualBigDecimals("price incorrect", price, nameData.getSalePrice()); } + @Test + public void testCancelSellName() throws DataException { + // Register-name and sell-name + testSellName(); + + // Cancel Sell-name + CancelSellNameTransactionData transactionData = new CancelSellNameTransactionData(TestTransaction.generateBase(alice), name); + TransactionUtils.signAndMint(repository, transactionData, alice); + + NameData nameData; + + // Check name is no longer for sale + nameData = repository.getNameRepository().fromName(name); + assertFalse(nameData.getIsForSale()); + // Not concerned about price + + // Orphan cancel sell-name + BlockUtils.orphanLastBlock(repository); + + // Check name is for sale + nameData = repository.getNameRepository().fromName(name); + assertTrue(nameData.getIsForSale()); + assertEqualBigDecimals("price incorrect", price, nameData.getSalePrice()); + } + @Test public void testBuyName() throws DataException { // Register-name and sell-name diff --git a/src/test/resources/test-chain-old-asset.json b/src/test/resources/test-chain-old-asset.json index bc7dab6c..08bb6a6a 100644 --- a/src/test/resources/test-chain-old-asset.json +++ b/src/test/resources/test-chain-old-asset.json @@ -8,6 +8,7 @@ "requireGroupForApproval": false, "minAccountLevelToRewardShare": 5, "maxRewardSharesPerMintingAccount": 20, + "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "rewardsByHeight": [ @@ -23,6 +24,7 @@ { "levels": [ 9, 10 ], "share": 0.25 } ], "qoraHoldersShare": 0.20, + "qoraPerQortReward": 250, "blocksNeededByLevel": [ 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 ], "blockTimingsByHeight": [ { "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 } @@ -42,14 +44,19 @@ "version": 4, "timestamp": 0, "transactions": [ - { "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORA", "description": "QORA native coin", "data": "", "quantity": 10000000000, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" }, + { "type": "ISSUE_ASSET", "owner": "QUwGVHPPxJNJ2dq95abQNe79EyBN2K26zM", "assetName": "QORT", "description": "QORT native coin", "data": "", "quantity": 0, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" }, + { "type": "ISSUE_ASSET", "owner": "QUwGVHPPxJNJ2dq95abQNe79EyBN2K26zM", "assetName": "Legacy-QORA", "description": "Representative legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + { "type": "ISSUE_ASSET", "owner": "QUwGVHPPxJNJ2dq95abQNe79EyBN2K26zM", "assetName": "QORT-from-QORA", "description": "QORT gained from holding legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + { "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "1000000000", "fee": 0 }, { "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000", "fee": 0 }, { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "1000000", "fee": 0 }, { "type": "GENESIS", "recipient": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "amount": "1000000", "fee": 0 }, + { "type": "ISSUE_ASSET", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "assetName": "TEST", "description": "test asset", "data": "", "quantity": 1000000, "isDivisible": true, "fee": 0 }, { "type": "ISSUE_ASSET", "owner": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "assetName": "OTHER", "description": "other test asset", "data": "", "quantity": 1000000, "isDivisible": true, "fee": 0 }, { "type": "ISSUE_ASSET", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "assetName": "GOLD", "description": "gold test asset", "data": "", "quantity": 1000000, "isDivisible": true, "fee": 0 }, + { "type": "ACCOUNT_FLAGS", "target": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "andMask": -1, "orMask": 1, "xorMask": 0 }, { "type": "REWARD_SHARE", "minterPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "rewardSharePublicKey": "7PpfnvLSG7y4HPh8hE7KoqAjLCkv7Ui6xw4mKAkbZtox", "sharePercent": 100 } ] diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index ee8e4315..498365d3 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -44,7 +44,7 @@ "version": 4, "timestamp": 0, "transactions": [ - { "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORT", "description": "QORT native coin", "data": "", "quantity": 0, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" }, + { "type": "ISSUE_ASSET", "owner": "QUwGVHPPxJNJ2dq95abQNe79EyBN2K26zM", "assetName": "QORT", "description": "QORT native coin", "data": "", "quantity": 0, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" }, { "type": "ISSUE_ASSET", "owner": "QUwGVHPPxJNJ2dq95abQNe79EyBN2K26zM", "assetName": "Legacy-QORA", "description": "Representative legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, { "type": "ISSUE_ASSET", "owner": "QUwGVHPPxJNJ2dq95abQNe79EyBN2K26zM", "assetName": "QORT-from-QORA", "description": "QORT gained from holding legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true },