From 789b3119848040d688301e6ec5fbc5efd878263c Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 28 Mar 2019 16:28:31 +0000 Subject: [PATCH 1/4] Interim commit with newer asset order "price" arg + unit test + newer unit test harness but still needs: BlockChain config support for activating newer "price" arg New unit test to check old "price" arg usage Rework existing asset-related unit tests Check API inputs/output pre/post "price" arg crossover --- src/main/java/org/qora/asset/Order.java | 63 ++++++++++++++++-- .../java/org/qora/block/GenesisBlock.java | 1 + .../CreateAssetOrderTransactionData.java | 2 +- .../CreateAssetOrderTransaction.java | 13 +++- src/test/java/org/qora/test/Common.java | 66 ++++++++++++++++++- .../org/qora/test/assets/TradingTests.java | 54 +++++++++++++++ .../java/org/qora/test/common/AssetUtils.java | 46 +++++++++++++ .../org/qora/test/common/TestAccount.java | 10 +++ src/test/resources/test-v2qorachain.json | 9 +-- 9 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 src/test/java/org/qora/test/assets/TradingTests.java create mode 100644 src/test/java/org/qora/test/common/AssetUtils.java create mode 100644 src/test/java/org/qora/test/common/TestAccount.java diff --git a/src/main/java/org/qora/asset/Order.java b/src/main/java/org/qora/asset/Order.java index 5ebcb1c3..ee7d1c3a 100644 --- a/src/main/java/org/qora/asset/Order.java +++ b/src/main/java/org/qora/asset/Order.java @@ -10,6 +10,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qora.account.Account; import org.qora.account.PublicKeyAccount; +import org.qora.block.BlockChain; import org.qora.data.asset.AssetData; import org.qora.data.asset.OrderData; import org.qora.data.asset.TradeData; @@ -110,10 +111,16 @@ public class Order { // Save this order into repository so it's available for matching, possibly by itself this.repository.getAssetRepository().save(this.orderData); + boolean isOurOrderV2 = this.orderData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp(); + // Attempt to match orders LOGGER.debug("Processing our order " + HashCode.fromBytes(this.orderData.getOrderId()).toString()); LOGGER.trace("We have: " + this.orderData.getAmount().toPlainString() + " " + haveAssetData.getName()); - LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName()); + + if (isOurOrderV2) + LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName()); + else + LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName()); // Fetch corresponding open orders that might potentially match, hence reversed want/have assetId args. // Returned orders are sorted with lowest "price" first. @@ -121,7 +128,7 @@ public class Order { LOGGER.trace("Open orders fetched from repository: " + orders.size()); /* - * Our order example: + * Our order example (V1): * * haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), price=0.002 * @@ -130,17 +137,38 @@ public class Order { * So if our order matched, we'd end up with 10,000 * 0.002 = 20 QORA, essentially costing 1/0.002 = 500 GOLD each. * * So 500 GOLD [each] is our "buyingPrice". + * + * Our order example (V2): + * + * haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), price=20 + * + * This translates to "we have 10,000 GOLD and want to buy 20 QORA" + * + * So if our order matched, we'd end up with 20 QORA, essentially costing 10,000 / 20 = 500 GOLD each. + * + * So 500 GOLD [each] is our "buyingPrice". */ - BigDecimal ourPrice = this.orderData.getPrice(); + BigDecimal ourAmount = this.orderData.getAmount(); + BigDecimal ourPrice; + if (isOurOrderV2) + ourPrice = ourAmount.divide(this.orderData.getPrice(), RoundingMode.DOWN); + else + ourPrice = this.orderData.getPrice(); for (OrderData theirOrderData : orders) { LOGGER.trace("Considering order " + HashCode.fromBytes(theirOrderData.getOrderId()).toString()); // Note swapped use of have/want asset data as this is from 'their' perspective. LOGGER.trace("They have: " + theirOrderData.getAmount().toPlainString() + " " + wantAssetData.getName()); - LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName() + " per " + wantAssetData.getName()); + + boolean isTheirOrderV2 = theirOrderData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp(); + + if (isTheirOrderV2) + LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName()); + else + LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName() + " per " + wantAssetData.getName()); /* - * Potential matching order example: + * Potential matching order example (V1): * * haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=486 * @@ -149,10 +177,25 @@ public class Order { * So if their order matched, they'd end up with 40 * 486 = 19,440 GOLD, essentially costing 1/486 = 0.00205761 QORA each. * * So 0.00205761 QORA [each] is their "buyingPrice". + * + * Potential matching order example (V2): + * + * haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=19,440 + * + * This translates to "we have 40 QORA and want to buy 19,440 GOLD" + * + * So if their order matched, they'd end up with 19,440 GOLD, essentially costing 40 / 19,440 = 0.00205761 QORA each. + * + * So 0.00205761 QORA [each] is their "buyingPrice". */ // Round down otherwise their buyingPrice would be better than advertised and cause issues - BigDecimal theirBuyingPrice = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN); + BigDecimal theirBuyingPrice; + + if (isTheirOrderV2) + theirBuyingPrice = theirOrderData.getAmount().divide(theirOrderData.getPrice(), RoundingMode.DOWN); + else + theirBuyingPrice = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN); LOGGER.trace("theirBuyingPrice: " + theirBuyingPrice.toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName()); // If their buyingPrice is less than what we're willing to pay then we're done as prices only get worse as we iterate through list of orders @@ -162,9 +205,11 @@ public class Order { // Calculate how many want-asset we could buy at their price BigDecimal ourAmountLeft = this.getAmountLeft().multiply(theirBuyingPrice).setScale(8, RoundingMode.DOWN); LOGGER.trace("ourAmountLeft (max we could buy at their price): " + ourAmountLeft.toPlainString() + " " + wantAssetData.getName()); + // How many want-asset is remaining available in this order BigDecimal theirAmountLeft = Order.getAmountLeft(theirOrderData); LOGGER.trace("theirAmountLeft (max amount remaining in order): " + theirAmountLeft.toPlainString() + " " + wantAssetData.getName()); + // So matchable want-asset amount is the minimum of above two values BigDecimal matchedAmount = ourAmountLeft.min(theirAmountLeft); LOGGER.trace("matchedAmount: " + matchedAmount.toPlainString() + " " + wantAssetData.getName()); @@ -186,7 +231,11 @@ public class Order { // Trade can go ahead! // Calculate the total cost to us, in have-asset, based on their price - BigDecimal tradePrice = matchedAmount.multiply(theirOrderData.getPrice()).setScale(8); + BigDecimal tradePrice; + if (isTheirOrderV2) + tradePrice = matchedAmount.divide(theirBuyingPrice).setScale(8); // XXX is this right? + else + tradePrice = matchedAmount.multiply(theirOrderData.getPrice()).setScale(8); LOGGER.trace("tradePrice ('want' trade agreed): " + tradePrice.toPlainString() + " " + haveAssetData.getName()); // Construct trade diff --git a/src/main/java/org/qora/block/GenesisBlock.java b/src/main/java/org/qora/block/GenesisBlock.java index 81b45eaa..cb47ee42 100644 --- a/src/main/java/org/qora/block/GenesisBlock.java +++ b/src/main/java/org/qora/block/GenesisBlock.java @@ -310,6 +310,7 @@ public class GenesisBlock extends Block { } transaction.process(); + creator.setLastReference(transactionData.getSignature()); } } catch (TransformationException e) { throw new RuntimeException("Can't process genesis block transaction", e); diff --git a/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java b/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java index 5b9e148f..e826c454 100644 --- a/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java +++ b/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java @@ -22,7 +22,7 @@ public class CreateAssetOrderTransactionData extends TransactionData { private long wantAssetId; @Schema(description = "amount of \"have\" asset to trade") private BigDecimal amount; - @Schema(description = "amount of \"want\" asset to receive per unit of \"have\" asset traded") + @Schema(description = "amount of \"want\" asset to receive") private BigDecimal price; // Constructors diff --git a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java index 619f0321..cf6192d7 100644 --- a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java +++ b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java @@ -130,9 +130,16 @@ public class CreateAssetOrderTransaction extends Transaction { return ValidationResult.INVALID_AMOUNT; // Check total return from fulfilled order would be integer if "want" asset is not divisible - if (!wantAssetData.getIsDivisible() - && createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()).stripTrailingZeros().scale() > 0) - return ValidationResult.INVALID_RETURN; + if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp()) { + // v2 + if (!wantAssetData.getIsDivisible() && createOrderTransactionData.getPrice().stripTrailingZeros().scale() > 0) + return ValidationResult.INVALID_RETURN; + } else { + // v1 + if (!wantAssetData.getIsDivisible() + && createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()).stripTrailingZeros().scale() > 0) + return ValidationResult.INVALID_RETURN; + } return ValidationResult.OK; } diff --git a/src/test/java/org/qora/test/Common.java b/src/test/java/org/qora/test/Common.java index 93a0ee3c..38d41ff1 100644 --- a/src/test/java/org/qora/test/Common.java +++ b/src/test/java/org/qora/test/Common.java @@ -1,24 +1,38 @@ package org.qora.test; -import static org.junit.Assert.assertEquals; +import static org.junit.Assert.*; +import java.net.URL; import java.security.Security; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.bitcoinj.core.Base58; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.AfterClass; import org.junit.BeforeClass; +import org.qora.account.PrivateKeyAccount; +import org.qora.api.resource.TransactionsResource.ConfirmationStatus; +import org.qora.block.BlockChain; +import org.qora.block.BlockGenerator; +import org.qora.data.transaction.TransactionData; import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryFactory; import org.qora.repository.RepositoryManager; import org.qora.repository.hsqldb.HSQLDBRepositoryFactory; import org.qora.settings.Settings; +import org.qora.test.common.TestAccount; +import org.qora.transaction.Transaction; +import org.qora.transaction.Transaction.ValidationResult; public class Common { public static final String testConnectionUrl = "jdbc:hsqldb:mem:testdb"; + // public static final String testConnectionUrl = "jdbc:hsqldb:file:testdb/blockchain;create=true"; + public static final String testSettingsFilename = "test-settings.json"; public static final byte[] v2testPrivateKey = Base58.decode("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6"); @@ -33,7 +47,53 @@ public class Common { Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); // Load/check settings, which potentially sets up blockchain config, etc. - Settings.fileInstance(testSettingsFilename); + URL testSettingsUrl = Common.class.getClassLoader().getResource(testSettingsFilename); + assertNotNull("Test settings JSON file not found", testSettingsUrl); + Settings.fileInstance(testSettingsUrl.getPath()); + } + + public static Map lastTransactionByAddress; + public static Map testAccountsByName = new HashMap<>(); + static { + testAccountsByName.put("main", new TestAccount("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6")); + testAccountsByName.put("dummy", new TestAccount("AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot")); + } + + public static PrivateKeyAccount getTestAccount(Repository repository, String name) { + return new PrivateKeyAccount(repository, testAccountsByName.get(name).getSeed()); + } + + public static void resetBlockchain() throws DataException { + BlockChain.validate(); + lastTransactionByAddress = new HashMap<>(); + + try (Repository repository = RepositoryManager.getRepository()) { + for (TestAccount account : testAccountsByName.values()) { + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, account.getAddress(), ConfirmationStatus.BOTH, 1, null, true); + assertFalse("Test account should have existing transaction", signatures.isEmpty()); + + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signatures.get(0)); + lastTransactionByAddress.put(account.getAddress(), transactionData); + } + } + } + + public static void signAndForge(Repository repository, TransactionData transactionData, PrivateKeyAccount signingAccount) throws DataException { + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + // Add to unconfirmed + assertTrue("Transaction's signature should be valid", transaction.isSignatureValid()); + + ValidationResult result = transaction.isValidUnconfirmed(); + assertEquals("Transaction invalid", ValidationResult.OK, result); + + repository.getTransactionRepository().save(transactionData); + repository.getTransactionRepository().unconfirmTransaction(transactionData); + repository.saveChanges(); + + // Generate block + BlockGenerator.generateTestingBlock(repository, signingAccount); } @BeforeClass @@ -47,7 +107,7 @@ public class Common { RepositoryManager.closeRepositoryFactory(); } - public static void assetEmptyBlockchain(Repository repository) throws DataException { + public static void assertEmptyBlockchain(Repository repository) throws DataException { assertEquals("Blockchain should be empty for this test", 0, repository.getBlockRepository().getBlockchainHeight()); } diff --git a/src/test/java/org/qora/test/assets/TradingTests.java b/src/test/java/org/qora/test/assets/TradingTests.java new file mode 100644 index 00000000..bcf00664 --- /dev/null +++ b/src/test/java/org/qora/test/assets/TradingTests.java @@ -0,0 +1,54 @@ +package org.qora.test.assets; + +import org.junit.Before; +import org.junit.Test; +import org.qora.asset.Asset; +import org.qora.repository.DataException; +import org.qora.repository.Repository; +import org.qora.repository.RepositoryManager; +import org.qora.test.Common; +import org.qora.test.common.AssetUtils; + +import static org.junit.Assert.*; + +import java.math.BigDecimal; + +public class TradingTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.resetBlockchain(); + } + + /* + * Check full matching of orders with prices that + * can't be represented in floating binary. + * + * For example, sell 1 GOLD for 12 QORA so + * price is 1/12 or 0.083... + */ + @Test + public void testNonExactFraction() throws DataException { + final long qoraAmount = 24L; + final long otherAmount = 2L; + + final long transferAmount = 100L; + + try (Repository repository = RepositoryManager.getRepository()) { + // Create initial order + AssetUtils.createOrder(repository, "main", Asset.QORA, AssetUtils.testAssetId, qoraAmount, otherAmount); + + // Give 100 asset to other account so they can create order + AssetUtils.transferAsset(repository, "main", "dummy", AssetUtils.testAssetId, transferAmount); + + // Create matching order + AssetUtils.createOrder(repository, "dummy", AssetUtils.testAssetId, Asset.QORA, otherAmount, qoraAmount); + + // Check balances to check expected outcome + BigDecimal actualAmount = Common.getTestAccount(repository, "dummy").getConfirmedBalance(AssetUtils.testAssetId); + BigDecimal expectedAmount = BigDecimal.valueOf(transferAmount - otherAmount).setScale(8); + assertTrue("dummy account's asset balance incorrect", actualAmount.compareTo(expectedAmount) == 0); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/qora/test/common/AssetUtils.java b/src/test/java/org/qora/test/common/AssetUtils.java new file mode 100644 index 00000000..efb9ee05 --- /dev/null +++ b/src/test/java/org/qora/test/common/AssetUtils.java @@ -0,0 +1,46 @@ +package org.qora.test.common; + +import java.math.BigDecimal; + +import org.qora.account.PrivateKeyAccount; +import org.qora.data.transaction.CreateAssetOrderTransactionData; +import org.qora.data.transaction.TransactionData; +import org.qora.data.transaction.TransferAssetTransactionData; +import org.qora.group.Group; +import org.qora.repository.DataException; +import org.qora.repository.Repository; +import org.qora.test.Common; + +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; + + public static void transferAsset(Repository repository, String fromAccountName, String toAccountName, long assetId, long amount) throws DataException { + PrivateKeyAccount fromAccount = Common.getTestAccount(repository, fromAccountName); + PrivateKeyAccount toAccount = Common.getTestAccount(repository, toAccountName); + + byte[] reference = fromAccount.getLastReference(); + long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000; + + TransactionData transferAssetTransactionData = new TransferAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, fromAccount.getPublicKey(), toAccount.getAddress(), BigDecimal.valueOf(amount), assetId, AssetUtils.fee); + + Common.signAndForge(repository, transferAssetTransactionData, fromAccount); + } + + public static void createOrder(Repository repository, String accountName, long haveAssetId, long wantAssetId, long haveAmount, long wantAmount) throws DataException { + PrivateKeyAccount account = Common.getTestAccount(repository, accountName); + + byte[] reference = account.getLastReference(); + long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000; + BigDecimal amount = BigDecimal.valueOf(haveAmount); + BigDecimal price = BigDecimal.valueOf(wantAmount); + + // Note: "price" is not the same in V2 as in V1 + TransactionData initialOrderTransactionData = new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, account.getPublicKey(), haveAssetId, wantAssetId, amount, price, fee); + + Common.signAndForge(repository, initialOrderTransactionData, account); + } + +} diff --git a/src/test/java/org/qora/test/common/TestAccount.java b/src/test/java/org/qora/test/common/TestAccount.java new file mode 100644 index 00000000..45aa5796 --- /dev/null +++ b/src/test/java/org/qora/test/common/TestAccount.java @@ -0,0 +1,10 @@ +package org.qora.test.common; + +import org.qora.account.PrivateKeyAccount; +import org.qora.utils.Base58; + +public class TestAccount extends PrivateKeyAccount { + public TestAccount(String privateKey) { + super(null, Base58.decode(privateKey)); + } +} diff --git a/src/test/resources/test-v2qorachain.json b/src/test/resources/test-v2qorachain.json index d402810d..420a3ead 100644 --- a/src/test/resources/test-v2qorachain.json +++ b/src/test/resources/test-v2qorachain.json @@ -7,16 +7,17 @@ "blockTimestampMargin": 500, "maxBytesPerUnitFee": 1024, "unitFee": "0.1", - "requireGroupForApproval": true, - "defaultGroupId": 2, + "requireGroupForApproval": false, "genesisInfo": { "version": 4, "timestamp": 0, "generatingBalance": "10000000", "transactions": [ - { "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORA", "description": "QORA native coin", "quantity": 10000000000, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" }, + { "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORA", "description": "QORA native coin", "data": "", "quantity": 10000000000, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" }, { "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "9876543210.12345678", "fee": 0 }, - { "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 } + { "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000", "fee": 0 }, + { "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 }, + { "type": "ISSUE_ASSET", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "assetName": "test", "description": "test asset", "data": "", "quantity": 1000, "isDivisible": true, "fee": 0 } ] }, "featureTriggers": { From 031657878ec26ad731cbe9d402390482fba993bb Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 29 Mar 2019 10:56:46 +0000 Subject: [PATCH 2/4] Added safety feature to prevent negative balances --- src/main/java/org/qora/account/Account.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/org/qora/account/Account.java b/src/main/java/org/qora/account/Account.java index a3b64f54..d907629e 100644 --- a/src/main/java/org/qora/account/Account.java +++ b/src/main/java/org/qora/account/Account.java @@ -130,6 +130,13 @@ public class Account { } public void setConfirmedBalance(long assetId, BigDecimal balance) throws DataException { + // Safety feature! + if (balance.compareTo(BigDecimal.ZERO) < 0) { + String message = String.format("Refusing to set negative balance %s [assetId %d] for %s", balance.toPlainString(), assetId, this.address); + LOGGER.error(message); + throw new DataException(message); + } + // Can't have a balance without an account - make sure it exists! this.repository.getAccountRepository().ensureAccount(this.buildAccountData()); From 60e562566eefda3133723065a115c50d5213d657 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 2 Apr 2019 21:10:16 +0100 Subject: [PATCH 3/4] Interim commit on new asset trading schema Better order matching, especially in situations where inexact fractional representations (e.g. 1/12) or rounding issues might occur. Also better matching with indivisible assets. Essentially change ordering from have-amount & price to have-amount and want-return, leaving unit price to be calculated internally to a finer degree (in some cases to 48 decimal points). Corresponding unit tests to cover both legacy and new scenarios. Support for tests to switch between blockchain configs. "New" pricing schema is its own 'feature trigger' independent from general qorav2 switch. Safety checks added during trading process. HSQLDB schema changes (will probably need careful conflict resolution on merge). Still to do: API changes etc. --- .../org/qora/api/model/AggregatedOrder.java | 6 +- src/main/java/org/qora/asset/Asset.java | 2 +- src/main/java/org/qora/asset/Order.java | 284 ++++++++----- src/main/java/org/qora/block/BlockChain.java | 7 +- .../java/org/qora/data/asset/OrderData.java | 27 +- .../org/qora/repository/AssetRepository.java | 2 +- .../hsqldb/HSQLDBAssetRepository.java | 76 ++-- .../hsqldb/HSQLDBDatabaseUpdates.java | 25 ++ src/main/java/org/qora/settings/Settings.java | 1 - .../CreateAssetOrderTransaction.java | 22 +- ...reateAssetOrderTransactionTransformer.java | 4 +- src/test/java/org/qora/test/Common.java | 39 +- .../java/org/qora/test/TransactionTests.java | 2 +- .../org/qora/test/assets/TradingTests.java | 389 +++++++++++++++++- .../org/qora/test/common/AccountUtils.java | 33 ++ .../java/org/qora/test/common/AssetUtils.java | 30 +- .../org/qora/test/common/TestAccount.java | 13 +- src/test/resources/log4j2-test.properties | 92 +++++ src/test/resources/test-chain-old-asset.json | 34 ++ ...st-v2qorachain.json => test-chain-v2.json} | 7 +- .../resources/test-settings-old-asset.json | 6 + ...st-settings.json => test-settings-v2.json} | 2 +- 22 files changed, 909 insertions(+), 194 deletions(-) create mode 100644 src/test/java/org/qora/test/common/AccountUtils.java create mode 100644 src/test/resources/log4j2-test.properties create mode 100644 src/test/resources/test-chain-old-asset.json rename src/test/resources/{test-v2qorachain.json => test-chain-v2.json} (81%) create mode 100644 src/test/resources/test-settings-old-asset.json rename src/test/resources/{test-settings.json => test-settings-v2.json} (55%) diff --git a/src/main/java/org/qora/api/model/AggregatedOrder.java b/src/main/java/org/qora/api/model/AggregatedOrder.java index 29865361..3a56acfd 100644 --- a/src/main/java/org/qora/api/model/AggregatedOrder.java +++ b/src/main/java/org/qora/api/model/AggregatedOrder.java @@ -20,9 +20,9 @@ public class AggregatedOrder { this.orderData = orderData; } - @XmlElement(name = "price") - public BigDecimal getPrice() { - return this.orderData.getPrice(); + @XmlElement(name = "unitPrice") + public BigDecimal getUnitPrice() { + return this.orderData.getUnitPrice(); } @XmlElement(name = "unfulfilled") diff --git a/src/main/java/org/qora/asset/Asset.java b/src/main/java/org/qora/asset/Asset.java index b509d605..11a2137c 100644 --- a/src/main/java/org/qora/asset/Asset.java +++ b/src/main/java/org/qora/asset/Asset.java @@ -20,7 +20,7 @@ public class Asset { public static final int MAX_DESCRIPTION_SIZE = 4000; public static final int MAX_DATA_SIZE = 400000; - public static final long MAX_DIVISIBLE_QUANTITY = 10_000_000_000L; + public static final long MAX_DIVISIBLE_QUANTITY = 10_000_000_000L; // but also to 8 decimal places public static final long MAX_INDIVISIBLE_QUANTITY = 1_000_000_000_000_000_000L; // Properties diff --git a/src/main/java/org/qora/asset/Order.java b/src/main/java/org/qora/asset/Order.java index ee7d1c3a..0f169206 100644 --- a/src/main/java/org/qora/asset/Order.java +++ b/src/main/java/org/qora/asset/Order.java @@ -6,6 +6,7 @@ import java.math.RoundingMode; import java.util.Arrays; import java.util.List; +import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qora.account.Account; @@ -22,6 +23,11 @@ import com.google.common.hash.HashCode; public class Order { + /** BigDecimal scale for representing unit price in asset orders. */ + public static final int BD_PRICE_SCALE = 38; + /** BigDecimal scale for representing unit price in asset orders in storage context. */ + public static final int BD_PRICE_STORAGE_SCALE = BD_PRICE_SCALE + 10; + private static final Logger LOGGER = LogManager.getLogger(Order.class); // Properties @@ -59,33 +65,41 @@ public class Order { return Order.isFulfilled(this.orderData); } - public BigDecimal calculateAmountGranularity(AssetData haveAssetData, AssetData wantAssetData, OrderData theirOrderData) { - // 100 million to scale BigDecimal.setScale(8) fractional amounts into integers, essentially 1e8 - BigInteger multiplier = BigInteger.valueOf(100_000_000L); + /** + * Returns want-asset granularity/unit-size given price. + *

+ * @param theirPrice + * @return unit price of want asset + */ + public static BigDecimal calculateAmountGranularity(AssetData haveAssetData, AssetData wantAssetData, BigDecimal theirPrice) { + // Multiplier to scale BigDecimal.setScale(8) fractional amounts into integers, essentially 1e8 + BigInteger multiplier = BigInteger.valueOf(1_0000_0000L); // Calculate the minimum increment at which I can buy using greatest-common-divisor - BigInteger haveAmount = BigInteger.ONE.multiply(multiplier); - BigInteger priceAmount = theirOrderData.getPrice().multiply(new BigDecimal(multiplier)).toBigInteger(); - BigInteger gcd = haveAmount.gcd(priceAmount); + BigInteger haveAmount = multiplier; // 1 unit (* multiplier) + //BigInteger wantAmount = BigDecimal.valueOf(100_000_000L).setScale(Asset.BD_SCALE).divide(theirOrderData.getUnitPrice(), RoundingMode.DOWN).toBigInteger(); + BigInteger wantAmount = theirPrice.movePointRight(8).toBigInteger(); + + BigInteger gcd = haveAmount.gcd(wantAmount); haveAmount = haveAmount.divide(gcd); - priceAmount = priceAmount.divide(gcd); + wantAmount = wantAmount.divide(gcd); // Calculate GCD in combination with divisibility if (wantAssetData.getIsDivisible()) haveAmount = haveAmount.multiply(multiplier); if (haveAssetData.getIsDivisible()) - priceAmount = priceAmount.multiply(multiplier); + wantAmount = wantAmount.multiply(multiplier); - gcd = haveAmount.gcd(priceAmount); + gcd = haveAmount.gcd(wantAmount); - // Calculate the increment at which we have to buy - BigDecimal increment = new BigDecimal(haveAmount.divide(gcd)); + // Calculate the granularity at which we have to buy + BigDecimal granularity = new BigDecimal(haveAmount.divide(gcd)); if (wantAssetData.getIsDivisible()) - increment = increment.divide(new BigDecimal(multiplier)); + granularity = granularity.movePointLeft(8); // Return - return increment; + return granularity; } // Navigation @@ -96,6 +110,32 @@ public class Order { // Processing + private void logOrder(String orderPrefix, boolean isMatchingNotInitial, OrderData orderData) throws DataException { + // Avoid calculations if possible + if (LOGGER.getLevel().isMoreSpecificThan(Level.DEBUG)) + return; + + final boolean isNewPricing = orderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp(); + final String weThey = isMatchingNotInitial ? "They" : "We"; + + AssetData haveAssetData = this.repository.getAssetRepository().fromAssetId(orderData.getHaveAssetId()); + AssetData wantAssetData = this.repository.getAssetRepository().fromAssetId(orderData.getWantAssetId()); + + LOGGER.debug(String.format("%s %s", orderPrefix, HashCode.fromBytes(orderData.getOrderId()).toString())); + + LOGGER.trace(String.format("%s have: %s %s", weThey, orderData.getAmount().stripTrailingZeros().toPlainString(), haveAssetData.getName())); + + if (isNewPricing) { + LOGGER.trace(String.format("%s want: %s %s (@ %s %s each)", weThey, + orderData.getWantAmount().stripTrailingZeros().toPlainString(), wantAssetData.getName(), + orderData.getUnitPrice().toPlainString(), haveAssetData.getName())); + } else { + LOGGER.trace(String.format("%s want at least %s %s per %s (minimum %s %s total)", weThey, + orderData.getUnitPrice().toPlainString(), wantAssetData.getName(), haveAssetData.getName(), + orderData.getWantAmount().stripTrailingZeros().toPlainString(), wantAssetData.getName())); + } + } + public void process() throws DataException { AssetRepository assetRepository = this.repository.getAssetRepository(); @@ -111,64 +151,57 @@ public class Order { // Save this order into repository so it's available for matching, possibly by itself this.repository.getAssetRepository().save(this.orderData); - boolean isOurOrderV2 = this.orderData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp(); + /* + * Our order example ("old"): + * + * haveAssetId=[GOLD], amount=10,000, wantAssetId=[QORA], price=0.002 + * + * This translates to "we have 10,000 GOLD and want to buy QORA at a price of 0.002 QORA per GOLD" + * + * So if our order matched, we'd end up with 10,000 * 0.002 = 20 QORA, essentially costing 1/0.002 = 500 GOLD each. + * + * So 500 GOLD [each] is our (selling) unit price and want-amount is 20 QORA. + * + * Another example (showing representation error and hence move to "new" pricing): + * haveAssetId=[QORA], amount=24, wantAssetId=[GOLD], price=0.08333333 + * unit price: 12.00000048 GOLD, want-amount: 1.9999992 GOLD + */ - // Attempt to match orders - LOGGER.debug("Processing our order " + HashCode.fromBytes(this.orderData.getOrderId()).toString()); - LOGGER.trace("We have: " + this.orderData.getAmount().toPlainString() + " " + haveAssetData.getName()); - - if (isOurOrderV2) - LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName()); - else - LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName()); + /* + * Our order example ("new"): + * + * haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), want-amount=20 + * + * This translates to "we have 10,000 GOLD and want to buy 20 QORA" + * + * So if our order matched, we'd end up with 20 QORA, essentially costing 10,000 / 20 = 500 GOLD each. + * + * So 500 GOLD [each] is our (selling) unit price and want-amount is 20 QORA. + * + * Another example: + * haveAssetId=[QORA], amount=24, wantAssetId=[GOLD], want-amount=2 + * unit price: 12.00000000 GOLD, want-amount: 2.00000000 GOLD + */ + logOrder("Processing our order", false, this.orderData); // Fetch corresponding open orders that might potentially match, hence reversed want/have assetId args. // Returned orders are sorted with lowest "price" first. List orders = assetRepository.getOpenOrders(wantAssetId, haveAssetId); LOGGER.trace("Open orders fetched from repository: " + orders.size()); - /* - * Our order example (V1): - * - * haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), price=0.002 - * - * This translates to "we have 10,000 GOLD and want to buy QORA at a price of 0.002 QORA per GOLD" - * - * So if our order matched, we'd end up with 10,000 * 0.002 = 20 QORA, essentially costing 1/0.002 = 500 GOLD each. - * - * So 500 GOLD [each] is our "buyingPrice". - * - * Our order example (V2): - * - * haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), price=20 - * - * This translates to "we have 10,000 GOLD and want to buy 20 QORA" - * - * So if our order matched, we'd end up with 20 QORA, essentially costing 10,000 / 20 = 500 GOLD each. - * - * So 500 GOLD [each] is our "buyingPrice". - */ - BigDecimal ourAmount = this.orderData.getAmount(); - BigDecimal ourPrice; - if (isOurOrderV2) - ourPrice = ourAmount.divide(this.orderData.getPrice(), RoundingMode.DOWN); - else - ourPrice = this.orderData.getPrice(); + if (orders.isEmpty()) + return; + + // Attempt to match orders + + BigDecimal ourUnitPrice = this.orderData.getUnitPrice(); + LOGGER.trace(String.format("Our minimum price: %s %s per %s", ourUnitPrice.toPlainString(), wantAssetData.getName(), haveAssetData.getName())); for (OrderData theirOrderData : orders) { - LOGGER.trace("Considering order " + HashCode.fromBytes(theirOrderData.getOrderId()).toString()); - // Note swapped use of have/want asset data as this is from 'their' perspective. - LOGGER.trace("They have: " + theirOrderData.getAmount().toPlainString() + " " + wantAssetData.getName()); - - boolean isTheirOrderV2 = theirOrderData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp(); - - if (isTheirOrderV2) - LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName()); - else - LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName() + " per " + wantAssetData.getName()); + logOrder("Considering order", true, theirOrderData); /* - * Potential matching order example (V1): + * Potential matching order example ("old"): * * haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=486 * @@ -176,9 +209,11 @@ public class Order { * * So if their order matched, they'd end up with 40 * 486 = 19,440 GOLD, essentially costing 1/486 = 0.00205761 QORA each. * - * So 0.00205761 QORA [each] is their "buyingPrice". - * - * Potential matching order example (V2): + * So 0.00205761 QORA [each] is their unit price and maximum amount is 19,440 GOLD. + */ + + /* + * Potential matching order example ("new"): * * haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=19,440 * @@ -186,69 +221,124 @@ public class Order { * * So if their order matched, they'd end up with 19,440 GOLD, essentially costing 40 / 19,440 = 0.00205761 QORA each. * - * So 0.00205761 QORA [each] is their "buyingPrice". + * So 0.00205761 QORA [each] is their unit price and maximum amount is 19,440 GOLD. */ - // Round down otherwise their buyingPrice would be better than advertised and cause issues - BigDecimal theirBuyingPrice; + boolean isTheirOrderNewAssetPricing = theirOrderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp(); - if (isTheirOrderV2) - theirBuyingPrice = theirOrderData.getAmount().divide(theirOrderData.getPrice(), RoundingMode.DOWN); - else - theirBuyingPrice = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN); - LOGGER.trace("theirBuyingPrice: " + theirBuyingPrice.toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName()); + BigDecimal theirBuyingPrice = BigDecimal.ONE.setScale(isTheirOrderNewAssetPricing ? Order.BD_PRICE_STORAGE_SCALE : 8).divide(theirOrderData.getUnitPrice(), RoundingMode.DOWN); + LOGGER.trace(String.format("Their price: %s %s per %s", theirBuyingPrice.toPlainString(), wantAssetData.getName(), haveAssetData.getName())); - // If their buyingPrice is less than what we're willing to pay then we're done as prices only get worse as we iterate through list of orders - if (theirBuyingPrice.compareTo(ourPrice) < 0) + // If their buyingPrice is less than what we're willing to accept then we're done as prices only get worse as we iterate through list of orders + if (theirBuyingPrice.compareTo(ourUnitPrice) < 0) break; // Calculate how many want-asset we could buy at their price - BigDecimal ourAmountLeft = this.getAmountLeft().multiply(theirBuyingPrice).setScale(8, RoundingMode.DOWN); - LOGGER.trace("ourAmountLeft (max we could buy at their price): " + ourAmountLeft.toPlainString() + " " + wantAssetData.getName()); + BigDecimal ourMaxWantAmount = this.getAmountLeft().multiply(theirBuyingPrice).setScale(8, RoundingMode.DOWN); + LOGGER.trace("ourMaxWantAmount (max we could buy at their price): " + ourMaxWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); - // How many want-asset is remaining available in this order - BigDecimal theirAmountLeft = Order.getAmountLeft(theirOrderData); - LOGGER.trace("theirAmountLeft (max amount remaining in order): " + theirAmountLeft.toPlainString() + " " + wantAssetData.getName()); + if (isTheirOrderNewAssetPricing) { + ourMaxWantAmount = ourMaxWantAmount.max(this.getAmountLeft().divide(theirOrderData.getUnitPrice(), RoundingMode.DOWN).setScale(8, RoundingMode.DOWN)); + LOGGER.trace("ourMaxWantAmount (max we could buy at their price) using inverted calculation: " + ourMaxWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); + } + + // How many want-asset is remaining available in their order. (have-asset amount from their perspective). + BigDecimal theirWantAmountLeft = Order.getAmountLeft(theirOrderData); + LOGGER.trace("theirWantAmountLeft (max amount remaining in their order): " + theirWantAmountLeft.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); // So matchable want-asset amount is the minimum of above two values - BigDecimal matchedAmount = ourAmountLeft.min(theirAmountLeft); - LOGGER.trace("matchedAmount: " + matchedAmount.toPlainString() + " " + wantAssetData.getName()); + BigDecimal matchedWantAmount = ourMaxWantAmount.min(theirWantAmountLeft); + LOGGER.trace("matchedWantAmount: " + matchedWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); // If we can't buy anything then try another order - if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0) + if (matchedWantAmount.compareTo(BigDecimal.ZERO) <= 0) continue; - // Calculate amount granularity based on both assets' divisibility - BigDecimal increment = this.calculateAmountGranularity(haveAssetData, wantAssetData, theirOrderData); - LOGGER.trace("increment (want-asset amount granularity): " + increment.toPlainString() + " " + wantAssetData.getName()); - matchedAmount = matchedAmount.subtract(matchedAmount.remainder(increment)); - LOGGER.trace("matchedAmount adjusted for granularity: " + matchedAmount.toPlainString() + " " + wantAssetData.getName()); + // We can skip granularity if theirWantAmountLeft is an [integer] multiple of matchedWantAmount as that obviously fits + if (!isTheirOrderNewAssetPricing || theirWantAmountLeft.remainder(matchedWantAmount).compareTo(BigDecimal.ZERO) > 0) { + // Not an integer multiple so do granularity check + + // Calculate amount granularity based on both assets' divisibility + BigDecimal wantGranularity = calculateAmountGranularity(haveAssetData, wantAssetData, theirOrderData.getUnitPrice()); + LOGGER.trace("wantGranularity (want-asset amount granularity): " + wantGranularity.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); + + // Reduce matched amount (if need be) to fit granularity + matchedWantAmount = matchedWantAmount.subtract(matchedWantAmount.remainder(wantGranularity)); + LOGGER.trace("matchedWantAmount adjusted for granularity: " + matchedWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); + } // If we can't buy anything then try another order - if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0) + if (matchedWantAmount.compareTo(BigDecimal.ZERO) <= 0) continue; + // Safety checks + if (matchedWantAmount.compareTo(Order.getAmountLeft(theirOrderData)) > 0) { + Account participant = new PublicKeyAccount(this.repository, theirOrderData.getCreatorPublicKey()); + + String message = String.format("Refusing to trade more %s then requested %s [assetId %d] for %s", + matchedWantAmount.toPlainString(), Order.getAmountLeft(theirOrderData).toPlainString(), wantAssetId, participant.getAddress()); + LOGGER.error(message); + throw new DataException(message); + } + + if (!wantAssetData.getIsDivisible() && matchedWantAmount.stripTrailingZeros().scale() > 0) { + Account participant = new PublicKeyAccount(this.repository, theirOrderData.getCreatorPublicKey()); + + String message = String.format("Refusing to trade fractional %s [indivisible assetId %d] for %s", + matchedWantAmount.toPlainString(), wantAssetId, participant.getAddress()); + LOGGER.error(message); + throw new DataException(message); + } + // Trade can go ahead! // Calculate the total cost to us, in have-asset, based on their price - BigDecimal tradePrice; - if (isTheirOrderV2) - tradePrice = matchedAmount.divide(theirBuyingPrice).setScale(8); // XXX is this right? - else - tradePrice = matchedAmount.multiply(theirOrderData.getPrice()).setScale(8); - LOGGER.trace("tradePrice ('want' trade agreed): " + tradePrice.toPlainString() + " " + haveAssetData.getName()); + BigDecimal haveAmountTraded; + + if (isTheirOrderNewAssetPricing) { + BigDecimal theirTruncatedPrice = theirBuyingPrice.setScale(Order.BD_PRICE_SCALE, RoundingMode.DOWN); + BigDecimal ourTruncatedPrice = ourUnitPrice.setScale(Order.BD_PRICE_SCALE, RoundingMode.DOWN); + + // Safety check + if (theirTruncatedPrice.compareTo(ourTruncatedPrice) < 0) { + String message = String.format("Refusing to trade at worse price %s than our minimum of %s", + theirTruncatedPrice.toPlainString(), ourTruncatedPrice.toPlainString(), creator.getAddress()); + LOGGER.error(message); + throw new DataException(message); + } + + haveAmountTraded = matchedWantAmount.divide(theirTruncatedPrice, RoundingMode.DOWN).setScale(8, RoundingMode.DOWN); + } else { + haveAmountTraded = matchedWantAmount.multiply(theirOrderData.getUnitPrice()).setScale(8, RoundingMode.DOWN); + } + LOGGER.trace("haveAmountTraded: " + haveAmountTraded.stripTrailingZeros().toPlainString() + " " + haveAssetData.getName()); + + // Safety checks + if (haveAmountTraded.compareTo(this.getAmountLeft()) > 0) { + String message = String.format("Refusing to trade more %s then requested %s [assetId %d] for %s", + haveAmountTraded.toPlainString(), this.getAmountLeft().toPlainString(), haveAssetId, creator.getAddress()); + LOGGER.error(message); + throw new DataException(message); + } + + if (!haveAssetData.getIsDivisible() && haveAmountTraded.stripTrailingZeros().scale() > 0) { + String message = String.format("Refusing to trade fractional %s [indivisible assetId %d] for %s", + haveAmountTraded.toPlainString(), haveAssetId, creator.getAddress()); + LOGGER.error(message); + throw new DataException(message); + } // Construct trade - TradeData tradeData = new TradeData(this.orderData.getOrderId(), theirOrderData.getOrderId(), matchedAmount, tradePrice, + TradeData tradeData = new TradeData(this.orderData.getOrderId(), theirOrderData.getOrderId(), matchedWantAmount, haveAmountTraded, this.orderData.getTimestamp()); // Process trade, updating corresponding orders in repository Trade trade = new Trade(this.repository, tradeData); trade.process(); // Update our order in terms of fulfilment, etc. but do not save into repository as that's handled by Trade above - this.orderData.setFulfilled(this.orderData.getFulfilled().add(tradePrice)); - LOGGER.trace("Updated our order's fulfilled amount to: " + this.orderData.getFulfilled().toPlainString() + " " + haveAssetData.getName()); - LOGGER.trace("Our order's amount remaining: " + this.getAmountLeft().toPlainString() + " " + haveAssetData.getName()); + this.orderData.setFulfilled(this.orderData.getFulfilled().add(haveAmountTraded)); + LOGGER.trace("Updated our order's fulfilled amount to: " + this.orderData.getFulfilled().stripTrailingZeros().toPlainString() + " " + haveAssetData.getName()); + LOGGER.trace("Our order's amount remaining: " + this.getAmountLeft().stripTrailingZeros().toPlainString() + " " + haveAssetData.getName()); // Continue on to process other open orders if we still have amount left to match if (this.getAmountLeft().compareTo(BigDecimal.ZERO) <= 0) diff --git a/src/main/java/org/qora/block/BlockChain.java b/src/main/java/org/qora/block/BlockChain.java index a6e0b664..a858b07a 100644 --- a/src/main/java/org/qora/block/BlockChain.java +++ b/src/main/java/org/qora/block/BlockChain.java @@ -74,7 +74,8 @@ public class BlockChain { votingTimestamp, arbitraryTimestamp, powfixTimestamp, - v2Timestamp; + v2Timestamp, + newAssetPricingTimestamp; } /** Map of which blockchain features are enabled when (height/timestamp) */ @@ -251,6 +252,10 @@ public class BlockChain { return featureTriggers.get("v2Timestamp"); } + public long getNewAssetPricingTimestamp() { + return featureTriggers.get("newAssetPricingTimestamp"); + } + /** Validate blockchain config read from JSON */ private void validateConfig() { if (this.genesisInfo == null) { diff --git a/src/main/java/org/qora/data/asset/OrderData.java b/src/main/java/org/qora/data/asset/OrderData.java index b5797078..4794c1f3 100644 --- a/src/main/java/org/qora/data/asset/OrderData.java +++ b/src/main/java/org/qora/data/asset/OrderData.java @@ -24,8 +24,11 @@ public class OrderData implements Comparable { @Schema(description = "amount of \"have\" asset to trade") private BigDecimal amount; + @Schema(description = "amount of \"want\" asset to receive") + private BigDecimal wantAmount; + @Schema(description = "amount of \"want\" asset to receive per unit of \"have\" asset traded") - private BigDecimal price; + private BigDecimal unitPrice; @Schema(description = "how much \"have\" asset has traded") private BigDecimal fulfilled; @@ -44,22 +47,24 @@ public class OrderData implements Comparable { protected OrderData() { } - public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal fulfilled, BigDecimal price, - long timestamp, boolean isClosed, boolean isFulfilled) { + public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal fulfilled, BigDecimal wantAmount, + BigDecimal unitPrice, long timestamp, boolean isClosed, boolean isFulfilled) { this.orderId = orderId; this.creatorPublicKey = creatorPublicKey; this.haveAssetId = haveAssetId; this.wantAssetId = wantAssetId; this.amount = amount; this.fulfilled = fulfilled; - this.price = price; + this.wantAmount = wantAmount; + this.unitPrice = unitPrice; this.timestamp = timestamp; this.isClosed = isClosed; this.isFulfilled = isFulfilled; } - public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal price, long timestamp) { - this(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, BigDecimal.ZERO.setScale(8), price, timestamp, false, false); + /** Constructs OrderData using typical deserialized network data */ + public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal wantAmount, BigDecimal unitPrice, long timestamp) { + this(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, BigDecimal.ZERO.setScale(8), wantAmount, unitPrice, timestamp, false, false); } // Getters/setters @@ -92,8 +97,12 @@ public class OrderData implements Comparable { this.fulfilled = fulfilled; } - public BigDecimal getPrice() { - return this.price; + public BigDecimal getWantAmount() { + return this.wantAmount; + } + + public BigDecimal getUnitPrice() { + return this.unitPrice; } public long getTimestamp() { @@ -119,7 +128,7 @@ public class OrderData implements Comparable { @Override public int compareTo(OrderData orderData) { // Compare using prices - return this.price.compareTo(orderData.getPrice()); + return this.unitPrice.compareTo(orderData.getUnitPrice()); } } diff --git a/src/main/java/org/qora/repository/AssetRepository.java b/src/main/java/org/qora/repository/AssetRepository.java index 60328d30..b97b03fa 100644 --- a/src/main/java/org/qora/repository/AssetRepository.java +++ b/src/main/java/org/qora/repository/AssetRepository.java @@ -39,7 +39,7 @@ public interface AssetRepository { public List getOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException; - // Internal, non-API use + /** Returns open orders, ordered by ascending unit price (i.e. best price first), for use by order matching logic. */ public default List getOpenOrders(long haveAssetId, long wantAssetId) throws DataException { return getOpenOrders(haveAssetId, wantAssetId, null, null, null); } diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java index 4d5fd243..6d6a8174 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java @@ -192,7 +192,7 @@ public class HSQLDBAssetRepository implements AssetRepository { @Override public OrderData fromOrderId(byte[] orderId) throws DataException { try (ResultSet resultSet = this.repository.checkedExecute( - "SELECT creator, have_asset_id, want_asset_id, amount, fulfilled, price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE asset_order_id = ?", + "SELECT creator, have_asset_id, want_asset_id, amount, fulfilled, want_amount, unit_price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE asset_order_id = ?", orderId)) { if (resultSet == null) return null; @@ -202,13 +202,13 @@ public class HSQLDBAssetRepository implements AssetRepository { long wantAssetId = resultSet.getLong(3); BigDecimal amount = resultSet.getBigDecimal(4); BigDecimal fulfilled = resultSet.getBigDecimal(5); - BigDecimal price = resultSet.getBigDecimal(6); - long timestamp = resultSet.getTimestamp(7, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); - boolean isClosed = resultSet.getBoolean(8); - boolean isFulfilled = resultSet.getBoolean(9); + BigDecimal wantAmount = resultSet.getBigDecimal(6); + BigDecimal unitPrice = resultSet.getBigDecimal(7); + long timestamp = resultSet.getTimestamp(8, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + boolean isClosed = resultSet.getBoolean(9); + boolean isFulfilled = resultSet.getBoolean(10); - return new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, price, - timestamp, isClosed, isFulfilled); + return new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, wantAmount, unitPrice, timestamp, isClosed, isFulfilled); } catch (SQLException e) { throw new DataException("Unable to fetch asset order from repository", e); } @@ -217,8 +217,8 @@ public class HSQLDBAssetRepository implements AssetRepository { @Override public List getOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException { - String sql = "SELECT creator, asset_order_id, amount, fulfilled, price, ordered FROM AssetOrders " - + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE ORDER BY price"; + String sql = "SELECT creator, asset_order_id, amount, fulfilled, want_amount, unit_price, ordered FROM AssetOrders " + + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE ORDER BY unit_price"; if (reverse != null && reverse) sql += " DESC"; sql += ", ordered"; @@ -237,13 +237,14 @@ public class HSQLDBAssetRepository implements AssetRepository { byte[] orderId = resultSet.getBytes(2); BigDecimal amount = resultSet.getBigDecimal(3); BigDecimal fulfilled = resultSet.getBigDecimal(4); - BigDecimal price = resultSet.getBigDecimal(5); - long timestamp = resultSet.getTimestamp(6, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + BigDecimal wantAmount = resultSet.getBigDecimal(5); + BigDecimal unitPrice = resultSet.getBigDecimal(6); + long timestamp = resultSet.getTimestamp(7, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); boolean isClosed = false; boolean isFulfilled = false; OrderData order = new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, - price, timestamp, isClosed, isFulfilled); + wantAmount, unitPrice, timestamp, isClosed, isFulfilled); orders.add(order); } while (resultSet.next()); @@ -256,8 +257,8 @@ public class HSQLDBAssetRepository implements AssetRepository { @Override public List getAggregatedOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException { - String sql = "SELECT price, SUM(amount - fulfilled), MAX(ordered) FROM AssetOrders " - + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE GROUP BY price ORDER BY price"; + String sql = "SELECT unit_price, SUM(amount - fulfilled), MAX(ordered) FROM AssetOrders " + + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE GROUP BY unit_price ORDER BY unit_price"; if (reverse != null && reverse) sql += " DESC"; sql += HSQLDBRepository.limitOffsetSql(limit, offset); @@ -269,12 +270,12 @@ public class HSQLDBAssetRepository implements AssetRepository { return orders; do { - BigDecimal price = resultSet.getBigDecimal(1); + BigDecimal unitPrice = resultSet.getBigDecimal(1); BigDecimal totalUnfulfilled = resultSet.getBigDecimal(2); long timestamp = resultSet.getTimestamp(3).getTime(); OrderData order = new OrderData(null, null, haveAssetId, wantAssetId, totalUnfulfilled, BigDecimal.ZERO, - price, timestamp, false, false); + BigDecimal.ZERO, unitPrice, timestamp, false, false); orders.add(order); } while (resultSet.next()); @@ -287,7 +288,8 @@ public class HSQLDBAssetRepository implements AssetRepository { @Override public List getAccountsOrders(byte[] publicKey, Boolean optIsClosed, Boolean optIsFulfilled, Integer limit, Integer offset, Boolean reverse) throws DataException { - String sql = "SELECT asset_order_id, have_asset_id, want_asset_id, amount, fulfilled, price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE creator = ?"; + String sql = "SELECT asset_order_id, have_asset_id, want_asset_id, amount, fulfilled, want_amount, unit_price, ordered, is_closed, is_fulfilled " + + "FROM AssetOrders WHERE creator = ?"; if (optIsClosed != null) sql += " AND is_closed = " + (optIsClosed ? "TRUE" : "FALSE"); if (optIsFulfilled != null) @@ -309,13 +311,14 @@ public class HSQLDBAssetRepository implements AssetRepository { long wantAssetId = resultSet.getLong(3); BigDecimal amount = resultSet.getBigDecimal(4); BigDecimal fulfilled = resultSet.getBigDecimal(5); - BigDecimal price = resultSet.getBigDecimal(6); - long timestamp = resultSet.getTimestamp(7, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); - boolean isClosed = resultSet.getBoolean(8); - boolean isFulfilled = resultSet.getBoolean(9); + BigDecimal wantAmount = resultSet.getBigDecimal(6); + BigDecimal unitPrice = resultSet.getBigDecimal(7); + long timestamp = resultSet.getTimestamp(8, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + boolean isClosed = resultSet.getBoolean(9); + boolean isFulfilled = resultSet.getBoolean(10); - OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, price, - timestamp, isClosed, isFulfilled); + OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, wantAmount, + unitPrice, timestamp, isClosed, isFulfilled); orders.add(order); } while (resultSet.next()); @@ -328,7 +331,8 @@ public class HSQLDBAssetRepository implements AssetRepository { @Override public List getAccountsOrders(byte[] publicKey, long haveAssetId, long wantAssetId, Boolean optIsClosed, Boolean optIsFulfilled, Integer limit, Integer offset, Boolean reverse) throws DataException { - String sql = "SELECT asset_order_id, amount, fulfilled, price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE creator = ? AND have_asset_id = ? AND want_asset_id = ?"; + String sql = "SELECT asset_order_id, amount, fulfilled, want_amount, unit_price, ordered, is_closed, is_fulfilled " + + "FROM AssetOrders WHERE creator = ? AND have_asset_id = ? AND want_asset_id = ?"; if (optIsClosed != null) sql += " AND is_closed = " + (optIsClosed ? "TRUE" : "FALSE"); if (optIsFulfilled != null) @@ -348,13 +352,14 @@ public class HSQLDBAssetRepository implements AssetRepository { byte[] orderId = resultSet.getBytes(1); BigDecimal amount = resultSet.getBigDecimal(2); BigDecimal fulfilled = resultSet.getBigDecimal(3); - BigDecimal price = resultSet.getBigDecimal(4); - long timestamp = resultSet.getTimestamp(5, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); - boolean isClosed = resultSet.getBoolean(6); - boolean isFulfilled = resultSet.getBoolean(7); + BigDecimal wantAmount = resultSet.getBigDecimal(4); + BigDecimal unitPrice = resultSet.getBigDecimal(5); + long timestamp = resultSet.getTimestamp(6, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + boolean isClosed = resultSet.getBoolean(7); + boolean isFulfilled = resultSet.getBoolean(8); - OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, price, - timestamp, isClosed, isFulfilled); + OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, wantAmount, + unitPrice, timestamp, isClosed, isFulfilled); orders.add(order); } while (resultSet.next()); @@ -371,7 +376,8 @@ public class HSQLDBAssetRepository implements AssetRepository { saveHelper.bind("asset_order_id", orderData.getOrderId()).bind("creator", orderData.getCreatorPublicKey()) .bind("have_asset_id", orderData.getHaveAssetId()).bind("want_asset_id", orderData.getWantAssetId()) .bind("amount", orderData.getAmount()).bind("fulfilled", orderData.getFulfilled()) - .bind("price", orderData.getPrice()).bind("ordered", new Timestamp(orderData.getTimestamp())) + .bind("want_amount", orderData.getWantAmount()).bind("unit_price", orderData.getUnitPrice()) + .bind("ordered", new Timestamp(orderData.getTimestamp())) .bind("is_closed", orderData.getIsClosed()).bind("is_fulfilled", orderData.getIsFulfilled()); try { @@ -395,8 +401,9 @@ public class HSQLDBAssetRepository implements AssetRepository { @Override public List getTrades(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException { - String sql = "SELECT initiating_order_id, target_order_id, AssetTrades.target_amount, AssetTrades.initiator_amount, traded FROM AssetOrders JOIN AssetTrades ON initiating_order_id = asset_order_id " - + "WHERE have_asset_id = ? AND want_asset_id = ? ORDER BY traded"; + String sql = "SELECT initiating_order_id, target_order_id, AssetTrades.target_amount, AssetTrades.initiator_amount, traded " + + "FROM AssetOrders JOIN AssetTrades ON initiating_order_id = asset_order_id " + + "WHERE have_asset_id = ? AND want_asset_id = ? ORDER BY traded"; if (reverse != null && reverse) sql += " DESC"; sql += HSQLDBRepository.limitOffsetSql(limit, offset); @@ -497,7 +504,8 @@ public class HSQLDBAssetRepository implements AssetRepository { @Override public List getOrdersTrades(byte[] orderId, Integer limit, Integer offset, Boolean reverse) throws DataException { - String sql = "SELECT initiating_order_id, target_order_id, target_amount, initiator_amount, traded FROM AssetTrades WHERE initiating_order_id = ? OR target_order_id = ? ORDER BY traded"; + String sql = "SELECT initiating_order_id, target_order_id, target_amount, initiator_amount, traded " + + "FROM AssetTrades WHERE initiating_order_id = ? OR target_order_id = ? ORDER BY traded"; if (reverse != null && reverse) sql += " DESC"; sql += HSQLDBRepository.limitOffsetSql(limit, offset); diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java index a25193d4..75f8cc1f 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -630,6 +630,31 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE UpdateAssetTransactions ALTER COLUMN new_data AssetDataLob"); break; + case 41: + // New asset pricing + /* + * We store "unit price" for asset orders but need enough precision to accurately + * represent fractional values without loss. + * Asset quantities can be up to either 1_000_000_000_000_000_000 (19 digits) if indivisible, + * or 10_000_000_000.00000000 (11+8 = 19 digits) if divisible. + * Two 19-digit numbers need 38 integer and 38 fractional to cover extremes of unit price. + * However, we use another 10 more fractional digits to avoid rounding issues. + * 38 integer + 48 fractional gives 86, so: DECIMAL (86, 48) + */ + // Rename price to unit_price to preserve indexes + stmt.execute("ALTER TABLE AssetOrders ALTER COLUMN price RENAME TO unit_price"); + // Adjust precision + stmt.execute("ALTER TABLE AssetOrders ALTER COLUMN unit_price DECIMAL(76,48)"); + // Add want-amount column + stmt.execute("ALTER TABLE AssetOrders ADD want_amount QoraAmount BEFORE unit_price"); + // Calculate want-amount values + stmt.execute("UPDATE AssetOrders set want_amount = amount * unit_price"); + // want-amounts all set, so disallow NULL + stmt.execute("ALTER TABLE AssetOrders ALTER COLUMN want_amount SET NOT NULL"); + // Convert old "price" into buying unit price + stmt.execute("UPDATE AssetOrders set unit_price = 1 / unit_price"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qora/settings/Settings.java b/src/main/java/org/qora/settings/Settings.java index e975cf7a..266adfe4 100644 --- a/src/main/java/org/qora/settings/Settings.java +++ b/src/main/java/org/qora/settings/Settings.java @@ -93,7 +93,6 @@ public class Settings { // Tell unmarshaller that there's no JSON root element in the JSON input unmarshaller.setProperty(UnmarshallerProperties.JSON_INCLUDE_ROOT, false); - } catch (JAXBException e) { LOGGER.error("Unable to process settings file", e); throw new RuntimeException("Unable to process settings file", e); diff --git a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java index cf6192d7..04d1017b 100644 --- a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java +++ b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java @@ -1,6 +1,7 @@ package org.qora.transaction; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -130,12 +131,12 @@ public class CreateAssetOrderTransaction extends Transaction { return ValidationResult.INVALID_AMOUNT; // Check total return from fulfilled order would be integer if "want" asset is not divisible - if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp()) { - // v2 + if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp()) { + // "new" asset pricing if (!wantAssetData.getIsDivisible() && createOrderTransactionData.getPrice().stripTrailingZeros().scale() > 0) return ValidationResult.INVALID_RETURN; } else { - // v1 + // "old" asset pricing if (!wantAssetData.getIsDivisible() && createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()).stripTrailingZeros().scale() > 0) return ValidationResult.INVALID_RETURN; @@ -160,9 +161,22 @@ public class CreateAssetOrderTransaction extends Transaction { // Order Id is transaction's signature byte[] orderId = createOrderTransactionData.getSignature(); + BigDecimal wantAmount; + BigDecimal unitPrice; + + if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp()) { + // "new" asset pricing: want-amount provided, unit price to be calculated + wantAmount = createOrderTransactionData.getPrice(); + unitPrice = wantAmount.setScale(Order.BD_PRICE_STORAGE_SCALE).divide(createOrderTransactionData.getAmount().setScale(Order.BD_PRICE_STORAGE_SCALE), RoundingMode.DOWN); + } else { + // "old" asset pricing: selling unit price provided, want-amount to be calculated + wantAmount = createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()); + unitPrice = createOrderTransactionData.getPrice(); + } + // Process the order itself OrderData orderData = new OrderData(orderId, createOrderTransactionData.getCreatorPublicKey(), createOrderTransactionData.getHaveAssetId(), - createOrderTransactionData.getWantAssetId(), createOrderTransactionData.getAmount(), createOrderTransactionData.getPrice(), + createOrderTransactionData.getWantAssetId(), createOrderTransactionData.getAmount(), wantAmount, unitPrice, createOrderTransactionData.getTimestamp()); new Order(this.repository, orderData).process(); diff --git a/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java b/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java index 2cb28685..8597e2af 100644 --- a/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java +++ b/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java @@ -35,7 +35,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform layout.add("ID of asset of offer", TransformationType.LONG); layout.add("ID of asset wanted", TransformationType.LONG); layout.add("amount of asset on offer", TransformationType.ASSET_QUANTITY); - layout.add("amount of asset wanted per offered asset", TransformationType.ASSET_QUANTITY); + layout.add("amount of wanted asset", TransformationType.ASSET_QUANTITY); layout.add("fee", TransformationType.AMOUNT); layout.add("signature", TransformationType.SIGNATURE); } @@ -58,6 +58,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform BigDecimal amount = Serialization.deserializeBigDecimal(byteBuffer, AMOUNT_LENGTH); + // Under "new" asset pricing, this is actually the want-amount BigDecimal price = Serialization.deserializeBigDecimal(byteBuffer, AMOUNT_LENGTH); BigDecimal fee = Serialization.deserializeBigDecimal(byteBuffer); @@ -86,6 +87,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getAmount(), AMOUNT_LENGTH); + // Under "new" asset pricing, this is actually the want-amount Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getPrice(), AMOUNT_LENGTH); Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getFee()); diff --git a/src/test/java/org/qora/test/Common.java b/src/test/java/org/qora/test/Common.java index 38d41ff1..fd14f9f8 100644 --- a/src/test/java/org/qora/test/Common.java +++ b/src/test/java/org/qora/test/Common.java @@ -7,6 +7,7 @@ import java.security.Security; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.bitcoinj.core.Base58; import org.bouncycastle.jce.provider.BouncyCastleProvider; @@ -33,7 +34,7 @@ public class Common { public static final String testConnectionUrl = "jdbc:hsqldb:mem:testdb"; // public static final String testConnectionUrl = "jdbc:hsqldb:file:testdb/blockchain;create=true"; - public static final String testSettingsFilename = "test-settings.json"; + public static final String testSettingsFilename = "test-settings-v2.json"; public static final byte[] v2testPrivateKey = Base58.decode("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6"); public static final byte[] v2testPublicKey = Base58.decode("2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP"); @@ -53,24 +54,48 @@ public class Common { } public static Map lastTransactionByAddress; - public static Map testAccountsByName = new HashMap<>(); + private static Map testAccountsByName = new HashMap<>(); static { - testAccountsByName.put("main", new TestAccount("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6")); - testAccountsByName.put("dummy", new TestAccount("AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot")); + testAccountsByName.put("alice", new TestAccount(null, "alice", "A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6")); + testAccountsByName.put("bob", new TestAccount(null, "bob", "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot")); + testAccountsByName.put("chloe", new TestAccount(null, "chloe", "HqVngdE1AmEyDpfwTZqUdFHB13o4bCmpoTNAKEqki66K")); + testAccountsByName.put("dilbert", new TestAccount(null, "dilbert", "Gakhh6Ln4vtBFM88nE9JmDaLBDtUBg51aVFpWfSkyVw5")); } - public static PrivateKeyAccount getTestAccount(Repository repository, String name) { - return new PrivateKeyAccount(repository, testAccountsByName.get(name).getSeed()); + public static TestAccount getTestAccount(Repository repository, String name) { + return new TestAccount(repository, name, testAccountsByName.get(name).getSeed()); + } + + public static List getTestAccounts(Repository repository) { + return testAccountsByName.values().stream().map(account -> new TestAccount(repository, account.accountName, account.getSeed())).collect(Collectors.toList()); + } + + public static void useSettings(String settingsFilename) throws DataException { + closeRepository(); + + // Load/check settings, which potentially sets up blockchain config, etc. + URL testSettingsUrl = Common.class.getClassLoader().getResource(settingsFilename); + assertNotNull("Test settings JSON file not found", testSettingsUrl); + Settings.fileInstance(testSettingsUrl.getPath()); + + setRepository(); + + resetBlockchain(); + } + + public static void useDefaultSettings() throws DataException { + useSettings(testSettingsFilename); } public static void resetBlockchain() throws DataException { BlockChain.validate(); + lastTransactionByAddress = new HashMap<>(); try (Repository repository = RepositoryManager.getRepository()) { for (TestAccount account : testAccountsByName.values()) { List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, account.getAddress(), ConfirmationStatus.BOTH, 1, null, true); - assertFalse("Test account should have existing transaction", signatures.isEmpty()); + assertFalse(String.format("Test account '%s' should have existing transaction", account.accountName), signatures.isEmpty()); TransactionData transactionData = repository.getTransactionRepository().fromSignature(signatures.get(0)); lastTransactionByAddress.put(account.getAddress(), transactionData); diff --git a/src/test/java/org/qora/test/TransactionTests.java b/src/test/java/org/qora/test/TransactionTests.java index 495e295b..e209d05f 100644 --- a/src/test/java/org/qora/test/TransactionTests.java +++ b/src/test/java/org/qora/test/TransactionTests.java @@ -989,7 +989,7 @@ public class TransactionTests extends Common { TradeData tradeData = trades.get(0); // Check trade has correct values - BigDecimal expectedAmount = amount.divide(originalOrderData.getPrice()).setScale(8); + BigDecimal expectedAmount = amount.divide(originalOrderData.getUnitPrice()).setScale(8); BigDecimal actualAmount = tradeData.getTargetAmount(); assertTrue(expectedAmount.compareTo(actualAmount) == 0); diff --git a/src/test/java/org/qora/test/assets/TradingTests.java b/src/test/java/org/qora/test/assets/TradingTests.java index bcf00664..f34c53b9 100644 --- a/src/test/java/org/qora/test/assets/TradingTests.java +++ b/src/test/java/org/qora/test/assets/TradingTests.java @@ -1,5 +1,6 @@ package org.qora.test.assets; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.qora.asset.Asset; @@ -7,48 +8,394 @@ import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryManager; import org.qora.test.Common; +import org.qora.test.common.AccountUtils; import org.qora.test.common.AssetUtils; import static org.junit.Assert.*; import java.math.BigDecimal; +import java.util.Map; public class TradingTests extends Common { @Before public void beforeTest() throws DataException { - Common.resetBlockchain(); + Common.useDefaultSettings(); } - /* - * Check full matching of orders with prices that - * can't be represented in floating binary. - * - * For example, sell 1 GOLD for 12 QORA so - * price is 1/12 or 0.083... + @After + public void afterTest() throws DataException { + } + + /** + * Check matching of indivisible amounts. + *

+ * New pricing scheme allows two attempts are calculating matched amount + * to reduce partial-match issues caused by rounding and recurring fractional digits: + *

+ *

    + *
  1. amount * round_down(1 / unit price)
  2. + *
  3. round_down(amount / unit price)
  4. + *
+ * Alice's price is 12 QORA per ATNL so the ATNL per QORA unit price is 0.08333333...
+ * Bob wants to spend 24 QORA so: + *

+ *

    + *
  1. 24 QORA * (1 / 0.0833333...) = 1.99999999 ATNL
  2. + *
  3. 24 QORA / 0.08333333.... = 2 ATNL
  4. + *
+ * The second result is obviously more intuitive as is critical where assets are not divisible, + * like ATNL in this test case. + *

+ * @see TradingTests#testOldNonExactFraction + * @see TradingTests#testNonExactFraction + * @throws DataException */ @Test - public void testNonExactFraction() throws DataException { - final long qoraAmount = 24L; - final long otherAmount = 2L; + public void testMixedDivisibility() throws DataException { + // Issue indivisible asset + long atnlAssetId; + try (Repository repository = RepositoryManager.getRepository()) { + // Issue indivisible asset + atnlAssetId = AssetUtils.issueAsset(repository, "alice", "ATNL", 100000000L, false); + } - final long transferAmount = 100L; + final BigDecimal atnlAmount = BigDecimal.valueOf(2L).setScale(8); + final BigDecimal qoraAmount = BigDecimal.valueOf(24L).setScale(8); + + genericTradeTest(atnlAssetId, Asset.QORA, atnlAmount, qoraAmount, qoraAmount, atnlAmount, atnlAmount, qoraAmount); + } + + /** + * Check matching of indivisible amounts (new pricing). + *

+ * Alice is selling twice as much as Bob wants, + * but at the same [calculated] unit price, + * so Bob's order should fully match. + *

+ * However, in legacy/"old" mode, the granularity checks + * would prevent this trade. + */ + @Test + public void testIndivisible() throws DataException { + // Issue some indivisible assets + long ragsAssetId; + long richesAssetId; + try (Repository repository = RepositoryManager.getRepository()) { + // Issue indivisble asset + ragsAssetId = AssetUtils.issueAsset(repository, "alice", "rags", 12345678L, false); + + // Issue another indivisble asset + richesAssetId = AssetUtils.issueAsset(repository, "bob", "riches", 87654321L, false); + } + + final BigDecimal ragsAmount = BigDecimal.valueOf(50301L).setScale(8); + final BigDecimal richesAmount = BigDecimal.valueOf(123L).setScale(8); + + final BigDecimal two = BigDecimal.valueOf(2L); + + genericTradeTest(ragsAssetId, richesAssetId, ragsAmount.multiply(two), richesAmount.multiply(two), richesAmount, ragsAmount, ragsAmount, richesAmount); + } + + /** + * Check matching of indivisible amounts. + *

+ * We use orders similar to some found in legacy qora1 blockchain + * to test for expected results with indivisible assets. + *

+ * In addition, although the 3rd "further" order would match up to 999 RUB.iPLZ, + * granularity at that price reduces matched amount to 493 RUB.iPLZ. + */ + @Test + public void testOldIndivisible() throws DataException { + Common.useSettings("test-settings-old-asset.json"); + + // Issue some indivisible assets + long asset112Id; + long asset113Id; + try (Repository repository = RepositoryManager.getRepository()) { + // Issue indivisble asset + asset112Id = AssetUtils.issueAsset(repository, "alice", "RUB.iPLZ", 999999999999L, false); + + // Issue another indivisble asset + asset113Id = AssetUtils.issueAsset(repository, "bob", "RU.GZP.V123", 10000L, false); + } + + // Transfer some assets so orders can be created + try (Repository repository = RepositoryManager.getRepository()) { + AssetUtils.transferAsset(repository, "alice", "bob", asset112Id, BigDecimal.valueOf(5000L).setScale(8)); + AssetUtils.transferAsset(repository, "bob", "alice", asset113Id, BigDecimal.valueOf(5000L).setScale(8)); + } + + final BigDecimal asset113Amount = new BigDecimal("1000").setScale(8); + final BigDecimal asset112Price = new BigDecimal("1.00000000").setScale(8); + + final BigDecimal asset112Amount = new BigDecimal("2000").setScale(8); + final BigDecimal asset113Price = new BigDecimal("0.98600000").setScale(8); + + final BigDecimal asset112Matched = new BigDecimal("1000").setScale(8); + final BigDecimal asset113Matched = new BigDecimal("1000").setScale(8); + + genericTradeTest(asset113Id, asset112Id, asset113Amount, asset112Price, asset112Amount, asset113Price, asset113Matched, asset112Matched); + + // Further trade + final BigDecimal asset113Amount2 = new BigDecimal("986").setScale(8); + final BigDecimal asset112Price2 = new BigDecimal("1.00000000").setScale(8); + + final BigDecimal asset112Matched2 = new BigDecimal("500").setScale(8); + final BigDecimal asset113Matched2 = new BigDecimal("493").setScale(8); try (Repository repository = RepositoryManager.getRepository()) { - // Create initial order - AssetUtils.createOrder(repository, "main", Asset.QORA, AssetUtils.testAssetId, qoraAmount, otherAmount); + Map> initialBalances = AccountUtils.getBalances(repository, asset112Id, asset113Id); - // Give 100 asset to other account so they can create order - AssetUtils.transferAsset(repository, "main", "dummy", AssetUtils.testAssetId, transferAmount); - - // Create matching order - AssetUtils.createOrder(repository, "dummy", AssetUtils.testAssetId, Asset.QORA, otherAmount, qoraAmount); + // Create further order + byte[] furtherOrderId = AssetUtils.createOrder(repository, "alice", asset113Id, asset112Id, asset113Amount2, asset112Price2); // Check balances to check expected outcome - BigDecimal actualAmount = Common.getTestAccount(repository, "dummy").getConfirmedBalance(AssetUtils.testAssetId); - BigDecimal expectedAmount = BigDecimal.valueOf(transferAmount - otherAmount).setScale(8); - assertTrue("dummy account's asset balance incorrect", actualAmount.compareTo(expectedAmount) == 0); + BigDecimal expectedBalance; + + // Alice asset 113 + expectedBalance = initialBalances.get("alice").get(asset113Id).subtract(asset113Amount2); + assertBalance(repository, "alice", asset113Id, expectedBalance); + + // Alice asset 112 + expectedBalance = initialBalances.get("alice").get(asset112Id).add(asset112Matched2); + assertBalance(repository, "alice", asset112Id, expectedBalance); + + BigDecimal expectedFulfilled = asset113Matched2; + BigDecimal actualFulfilled = repository.getAssetRepository().fromOrderId(furtherOrderId).getFulfilled(); + assertTrue(String.format("Order fulfilled incorrect: expected %s, actual %s", expectedFulfilled.toPlainString(), actualFulfilled.toPlainString()), + actualFulfilled.compareTo(expectedFulfilled) == 0); } } + /** + * Check full matching of orders with prices that + * can't be represented in floating binary. + *

+ * For example, sell 1 GOLD for 12 QORA so + * price is 1/12 or 0.08333333..., which could + * lead to rounding issues or inexact match amounts, + * but we counter this using the technique described in + * {@link #testMixedDivisibility()} + */ + @Test + public void testNonExactFraction() throws DataException { + final BigDecimal otherAmount = BigDecimal.valueOf(24L).setScale(8); + final BigDecimal qoraAmount = BigDecimal.valueOf(2L).setScale(8); + + genericTradeTest(AssetUtils.testAssetId, Asset.QORA, otherAmount, qoraAmount, qoraAmount, otherAmount, otherAmount, qoraAmount); + } + + /** + * Check legacy partial matching of orders with prices that + * can't be represented in floating binary. + *

+ * For example, sell 2 TEST for 24 QORA so + * unit price is 2 / 24 or 0.08333333. + *

+ * This inexactness causes the match amount to be + * only 1.99999992 instead of the expected 2.00000000. + *

+ * However this behaviour is "grandfathered" in legacy/"old" + * mode so we need to test. + */ + @Test + public void testOldNonExactFraction() throws DataException { + Common.useSettings("test-settings-old-asset.json"); + + final BigDecimal initialAmount = new BigDecimal("24.00000000").setScale(8); + final BigDecimal initialPrice = new BigDecimal("0.08333333").setScale(8); + + final BigDecimal matchedAmount = new BigDecimal("2.00000000").setScale(8); + final BigDecimal matchedPrice = new BigDecimal("12.00000000").setScale(8); + + // Due to rounding these are the expected traded amounts. + final BigDecimal tradedQoraAmount = new BigDecimal("24.00000000").setScale(8); + final BigDecimal tradedOtherAmount = new BigDecimal("1.99999992").setScale(8); + + genericTradeTest(AssetUtils.testAssetId, Asset.QORA, initialAmount, initialPrice, matchedAmount, matchedPrice, tradedQoraAmount, tradedOtherAmount); + } + + /** + * Check that better prices are used in preference when matching orders. + */ + @Test + public void testPriceImprovement() throws DataException { + final BigDecimal qoraAmount = BigDecimal.valueOf(24L).setScale(8); + final BigDecimal betterQoraAmount = BigDecimal.valueOf(25L).setScale(8); + final BigDecimal bestQoraAmount = BigDecimal.valueOf(31L).setScale(8); + + final BigDecimal otherAmount = BigDecimal.valueOf(2L).setScale(8); + + try (Repository repository = RepositoryManager.getRepository()) { + Map> initialBalances = AccountUtils.getBalances(repository, Asset.QORA, AssetUtils.testAssetId); + + // Create best initial order + AssetUtils.createOrder(repository, "bob", Asset.QORA, AssetUtils.testAssetId, qoraAmount, otherAmount); + + // Create initial order better than first + AssetUtils.createOrder(repository, "chloe", Asset.QORA, AssetUtils.testAssetId, bestQoraAmount, otherAmount); + + // Create initial order + AssetUtils.createOrder(repository, "dilbert", Asset.QORA, AssetUtils.testAssetId, betterQoraAmount, otherAmount); + + // Create matching order + AssetUtils.createOrder(repository, "alice", AssetUtils.testAssetId, Asset.QORA, otherAmount, qoraAmount); + + // Check balances to check expected outcome + BigDecimal expectedBalance; + + // We're expecting Alice's order to match with Chloe's order (as Bob's and Dilberts's orders have worse prices) + + // Alice Qora + expectedBalance = initialBalances.get("alice").get(Asset.QORA).add(bestQoraAmount); + assertBalance(repository, "alice", Asset.QORA, expectedBalance); + + // Alice test asset + expectedBalance = initialBalances.get("alice").get(AssetUtils.testAssetId).subtract(otherAmount); + assertBalance(repository, "alice", AssetUtils.testAssetId, expectedBalance); + + // Bob Qora + expectedBalance = initialBalances.get("bob").get(Asset.QORA).subtract(qoraAmount); + assertBalance(repository, "bob", Asset.QORA, expectedBalance); + + // Bob test asset + expectedBalance = initialBalances.get("bob").get(AssetUtils.testAssetId); + assertBalance(repository, "bob", AssetUtils.testAssetId, expectedBalance); + + // Chloe Qora + expectedBalance = initialBalances.get("chloe").get(Asset.QORA).subtract(bestQoraAmount); + assertBalance(repository, "chloe", Asset.QORA, expectedBalance); + + // Chloe test asset + expectedBalance = initialBalances.get("chloe").get(AssetUtils.testAssetId).add(otherAmount); + assertBalance(repository, "chloe", AssetUtils.testAssetId, expectedBalance); + + // Dilbert Qora + expectedBalance = initialBalances.get("dilbert").get(Asset.QORA).subtract(betterQoraAmount); + assertBalance(repository, "dilbert", Asset.QORA, expectedBalance); + + // Dilbert test asset + expectedBalance = initialBalances.get("dilbert").get(AssetUtils.testAssetId); + assertBalance(repository, "dilbert", AssetUtils.testAssetId, expectedBalance); + } + } + + /** + * Check legacy qora1 blockchain matching behaviour. + */ + @Test + public void testQora1Compat() throws DataException { + // Asset 61 [ATFunding] was issued by QYsLsfwMRBPnunmuWmFkM4hvGsfooY8ssU with 250,000,000 quantity and was divisible. + + // Initial order 2jMinWSBjxaLnQvhcEoWGs2JSdX7qbwxMTZenQXXhjGYDHCJDL6EjXPz5VXYuUfZM5LvRNNbcaeBbM6Xhb4tN53g + // Creator was QZyuTa3ygjThaPRhrCp1BW4R5Sed6uAGN8 at 2014-10-23 11:14:42.525000+0:00 + // Have: 150000 [ATFunding], Price: 1.7000000 QORA + + // Matching order 3Ufqi52nDL3Gi7KqVXpgebVN5FmLrdq2XyUJ11BwSV4byxQ2z96Q5CQeawGyanhpXS4XkYAaJTrNxsDDDxyxwbMN + // Creator was QMRoD3RS5vJ4DVNBhBgGtQG4KT3PhkNALH at 2015-03-27 12:24:02.945000+0:00 + // Have: 2 QORA, Price: 0.58 [ATFunding] + + // Trade: 1.17647050 [ATFunding] for 1.99999985 QORA + + // Load/check settings, which potentially sets up blockchain config, etc. + Common.useSettings("test-settings-old-asset.json"); + + // Transfer some test asset to bob + try (Repository repository = RepositoryManager.getRepository()) { + AssetUtils.transferAsset(repository, "alice", "bob", AssetUtils.testAssetId, BigDecimal.valueOf(200000L).setScale(8)); + } + + final BigDecimal initialAmount = new BigDecimal("150000").setScale(8); + final BigDecimal initialPrice = new BigDecimal("1.70000000").setScale(8); + + final BigDecimal matchingAmount = new BigDecimal("2.00000000").setScale(8); + final BigDecimal matchingPrice = new BigDecimal("0.58000000").setScale(8); + + final BigDecimal tradedOtherAmount = new BigDecimal("1.17647050").setScale(8); + final BigDecimal tradedQoraAmount = new BigDecimal("1.99999985").setScale(8); + + genericTradeTest(AssetUtils.testAssetId, Asset.QORA, initialAmount, initialPrice, matchingAmount, matchingPrice, tradedOtherAmount, tradedQoraAmount); + } + + /** + * Check legacy qora1 blockchain matching behaviour. + */ + @Test + public void testQora1Compat2() throws DataException { + // Asset 95 [Bitcoin] was issued by QiGx93L9rNHSNWCY1bJnQTPwB3nhxYTCUj with 21000000 quantity and was divisible. + // Asset 96 [BitBTC] was issued by QiGx93L9rNHSNWCY1bJnQTPwB3nhxYTCUj with 21000000 quantity and was divisible. + + // Initial order 3jinKPHEak9xrjeYtCaE1PawwRZeRkhYA6q4A7sqej7f3jio8WwXwXpfLWVZkPQ3h6cVdwPhcDFNgbbrBXcipHee + // Creator was QiGx93L9rNHSNWCY1bJnQTPwB3nhxYTCUj at 2015-06-10 20:31:44.840000+0:00 + // Have: 1000000 [BitBTC], Price: 0.90000000 [Bitcoin] + + // Matching order Jw1UfgspZ344waF8qLhGJanJXVa32FBoVvMW5ByFkyHvZEumF4fPqbaGMa76ba1imC4WX5t3Roa7r23Ys6rhKAA + // Creator was QiGx93L9rNHSNWCY1bJnQTPwB3nhxYTCUj at 2015-06-14 17:49:41.410000+0:00 + // Have: 73251 [Bitcoin], Price: 1.01 [BitBTC] + + // Trade: 81389.99991860 [BitBTC] for 73250.99992674 [Bitcoin] + + // Load/check settings, which potentially sets up blockchain config, etc. + Common.useSettings("test-settings-old-asset.json"); + + // Transfer some test asset to bob + try (Repository repository = RepositoryManager.getRepository()) { + AssetUtils.transferAsset(repository, "alice", "bob", AssetUtils.testAssetId, BigDecimal.valueOf(200000L).setScale(8)); + } + + final BigDecimal initialAmount = new BigDecimal("1000000").setScale(8); + final BigDecimal initialPrice = new BigDecimal("0.90000000").setScale(8); + + final BigDecimal matchingAmount = new BigDecimal("73251").setScale(8); + final BigDecimal matchingPrice = new BigDecimal("1.01000000").setScale(8); + + final BigDecimal tradedHaveAmount = new BigDecimal("81389.99991860").setScale(8); + final BigDecimal tradedWantAmount = new BigDecimal("73250.99992674").setScale(8); + + genericTradeTest(Asset.QORA, AssetUtils.testAssetId, initialAmount, initialPrice, matchingAmount, matchingPrice, tradedHaveAmount, tradedWantAmount); + } + + private void genericTradeTest(long haveAssetId, long wantAssetId, + BigDecimal initialAmount, BigDecimal initialPrice, + BigDecimal matchingAmount, BigDecimal matchingPrice, + BigDecimal tradedHaveAmount, BigDecimal tradedWantAmount) throws DataException { + try (Repository repository = RepositoryManager.getRepository()) { + Map> initialBalances = AccountUtils.getBalances(repository, haveAssetId, wantAssetId); + + // Create initial order + AssetUtils.createOrder(repository, "alice", haveAssetId, wantAssetId, initialAmount, initialPrice); + + // Create matching order + AssetUtils.createOrder(repository, "bob", wantAssetId, haveAssetId, matchingAmount, matchingPrice); + + // Check balances to check expected outcome + BigDecimal expectedBalance; + + // Alice have asset + expectedBalance = initialBalances.get("alice").get(haveAssetId).subtract(initialAmount); + assertBalance(repository, "alice", haveAssetId, expectedBalance); + + // Alice want asset + expectedBalance = initialBalances.get("alice").get(wantAssetId).add(tradedWantAmount); + assertBalance(repository, "alice", wantAssetId, expectedBalance); + + // Bob want asset + expectedBalance = initialBalances.get("bob").get(wantAssetId).subtract(matchingAmount); + assertBalance(repository, "bob", wantAssetId, expectedBalance); + + // Bob have asset + expectedBalance = initialBalances.get("bob").get(haveAssetId).add(tradedHaveAmount); + assertBalance(repository, "bob", haveAssetId, expectedBalance); + } + } + + private static void assertBalance(Repository repository, String accountName, long assetId, BigDecimal expectedBalance) throws DataException { + BigDecimal actualBalance = Common.getTestAccount(repository, accountName).getConfirmedBalance(assetId); + + assertTrue(String.format("Test account '%s' asset %d balance incorrect: expected %s, actual %s", accountName, assetId, expectedBalance.toPlainString(), actualBalance.toPlainString()), + actualBalance.compareTo(expectedBalance) == 0); + } + } \ No newline at end of file diff --git a/src/test/java/org/qora/test/common/AccountUtils.java b/src/test/java/org/qora/test/common/AccountUtils.java new file mode 100644 index 00000000..cba82127 --- /dev/null +++ b/src/test/java/org/qora/test/common/AccountUtils.java @@ -0,0 +1,33 @@ +package org.qora.test.common; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +import org.qora.repository.DataException; +import org.qora.repository.Repository; +import org.qora.test.Common; + +public class AccountUtils { + + public static Map> getBalances(Repository repository, long... assetIds) throws DataException { + Map> balances = new HashMap<>(); + + for (TestAccount account : Common.getTestAccounts(repository)) + for (Long assetId : assetIds) { + BigDecimal balance = account.getConfirmedBalance(assetId); + + balances.compute(account.accountName, (key, value) -> { + if (value == null) + value = new HashMap(); + + value.put(assetId, balance); + + return value; + }); + } + + return balances; + } + +} diff --git a/src/test/java/org/qora/test/common/AssetUtils.java b/src/test/java/org/qora/test/common/AssetUtils.java index efb9ee05..12cc91f8 100644 --- a/src/test/java/org/qora/test/common/AssetUtils.java +++ b/src/test/java/org/qora/test/common/AssetUtils.java @@ -4,6 +4,7 @@ import java.math.BigDecimal; import org.qora.account.PrivateKeyAccount; import org.qora.data.transaction.CreateAssetOrderTransactionData; +import org.qora.data.transaction.IssueAssetTransactionData; import org.qora.data.transaction.TransactionData; import org.qora.data.transaction.TransferAssetTransactionData; import org.qora.group.Group; @@ -17,30 +18,43 @@ public class AssetUtils { public static final BigDecimal fee = BigDecimal.ONE.setScale(8); public static final long testAssetId = 1L; - public static void transferAsset(Repository repository, String fromAccountName, String toAccountName, long assetId, long amount) throws DataException { + public static long issueAsset(Repository repository, String issuerAccountName, String assetName, long quantity, boolean isDivisible) throws DataException { + PrivateKeyAccount account = Common.getTestAccount(repository, issuerAccountName); + + byte[] reference = account.getLastReference(); + long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000; + + TransactionData transactionData = new IssueAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, account.getPublicKey(), account.getAddress(), assetName, "desc", quantity, isDivisible, "{}", AssetUtils.fee); + + Common.signAndForge(repository, transactionData, account); + + return repository.getAssetRepository().fromAssetName(assetName).getAssetId(); + } + + public static void transferAsset(Repository repository, String fromAccountName, String toAccountName, long assetId, BigDecimal amount) throws DataException { PrivateKeyAccount fromAccount = Common.getTestAccount(repository, fromAccountName); PrivateKeyAccount toAccount = Common.getTestAccount(repository, toAccountName); byte[] reference = fromAccount.getLastReference(); long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000; - TransactionData transferAssetTransactionData = new TransferAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, fromAccount.getPublicKey(), toAccount.getAddress(), BigDecimal.valueOf(amount), assetId, AssetUtils.fee); + TransactionData transactionData = new TransferAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, fromAccount.getPublicKey(), toAccount.getAddress(), amount, assetId, AssetUtils.fee); - Common.signAndForge(repository, transferAssetTransactionData, fromAccount); + Common.signAndForge(repository, transactionData, fromAccount); } - public static void createOrder(Repository repository, String accountName, long haveAssetId, long wantAssetId, long haveAmount, long wantAmount) throws DataException { + public static byte[] createOrder(Repository repository, String accountName, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal wantAmount) throws DataException { PrivateKeyAccount account = Common.getTestAccount(repository, accountName); byte[] reference = account.getLastReference(); long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000; - BigDecimal amount = BigDecimal.valueOf(haveAmount); - BigDecimal price = BigDecimal.valueOf(wantAmount); // Note: "price" is not the same in V2 as in V1 - TransactionData initialOrderTransactionData = new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, account.getPublicKey(), haveAssetId, wantAssetId, amount, price, fee); + TransactionData transactionData = new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, account.getPublicKey(), haveAssetId, wantAssetId, amount, wantAmount, fee); - Common.signAndForge(repository, initialOrderTransactionData, account); + Common.signAndForge(repository, transactionData, account); + + return repository.getAssetRepository().getAccountsOrders(account.getPublicKey(), null, null, null, null, true).get(0).getOrderId(); } } diff --git a/src/test/java/org/qora/test/common/TestAccount.java b/src/test/java/org/qora/test/common/TestAccount.java index 45aa5796..48269ce2 100644 --- a/src/test/java/org/qora/test/common/TestAccount.java +++ b/src/test/java/org/qora/test/common/TestAccount.java @@ -1,10 +1,19 @@ package org.qora.test.common; import org.qora.account.PrivateKeyAccount; +import org.qora.repository.Repository; import org.qora.utils.Base58; public class TestAccount extends PrivateKeyAccount { - public TestAccount(String privateKey) { - super(null, Base58.decode(privateKey)); + public final String accountName; + + public TestAccount(Repository repository, String accountName, byte[] privateKey) { + super(repository, privateKey); + + this.accountName = accountName; + } + + public TestAccount(Repository repository, String accountName, String privateKey) { + this(repository, accountName, Base58.decode(privateKey)); } } diff --git a/src/test/resources/log4j2-test.properties b/src/test/resources/log4j2-test.properties new file mode 100644 index 00000000..a74d53ed --- /dev/null +++ b/src/test/resources/log4j2-test.properties @@ -0,0 +1,92 @@ +rootLogger.level = info +# On Windows, this might be rewritten as: +# property.filename = ${sys:user.home}\\AppData\\Roaming\\Qora\\log.txt +property.filename = log.txt + +rootLogger.appenderRef.console.ref = stdout +rootLogger.appenderRef.rolling.ref = FILE + +# Override HSQLDB logging level to "warn" as too much is logged at "info" +logger.hsqldb.name = hsqldb.db +logger.hsqldb.level = warn + +# Support optional, per-session HSQLDB debugging +logger.hsqldbDebug.name = org.qora.repository.hsqldb.HSQLDBRepository +logger.hsqldbDebug.level = debug + +# Suppress extraneous Jersey warning +logger.jerseyInject.name = org.glassfish.jersey.internal.inject.Providers +logger.jerseyInject.level = error + +# Suppress extraneous Jetty entries +# 2019-02-14 11:46:27 INFO ContextHandler:851 - Started o.e.j.s.ServletContextHandler@6949e948{/,null,AVAILABLE} +# 2019-02-14 11:46:27 INFO AbstractConnector:289 - Started ServerConnector@50ad322b{HTTP/1.1,[http/1.1]}{0.0.0.0:9085} +# 2019-02-14 11:46:27 INFO Server:374 - jetty-9.4.11.v20180605; built: 2018-06-05T18:24:03.829Z; git: d5fc0523cfa96bfebfbda19606cad384d772f04c; jvm 1.8.0_181-b13 +# 2019-02-14 11:46:27 INFO Server:411 - Started @2539ms +logger.oejsSCH.name = org.eclipse.jetty +logger.oejsSCH.level = warn + +# Suppress extraneous slf4j entries +# 2019-02-14 11:46:27 INFO log:193 - Logging initialized @1636ms to org.eclipse.jetty.util.log.Slf4jLog +logger.slf4j.name = org.slf4j +logger.slf4j.level = warn + +# Suppress extraneous Reflections entry +# 2019-02-27 10:45:25 WARN Reflections:179 - given scan urls are empty. set urls in the configuration +logger.reflections.name = org.reflections.Reflections +logger.reflections.level = error + +# Debugging transactions +logger.transactions.name = org.qora.transaction +logger.transactions.level = debug + +# Debugging transformers +logger.transformers.name = org.qora.transform.transaction +logger.transformers.level = debug + +# Debugging transaction searches +logger.txSearch.name = org.qora.repository.hsqldb.transaction.HSQLDBTransactionRepository +logger.txSearch.level = trace + +# Debug block generator +logger.blockgen.name = org.qora.block.BlockGenerator +logger.blockgen.level = trace + +# Debug synchronization +logger.sync.name = org.qora.controller.Synchronizer +logger.sync.level = trace + +# Debug networking +logger.network.name = org.qora.network.Network +logger.network.level = trace +logger.peer.name = org.qora.network.Peer +logger.peer.level = trace +logger.controller.name = org.qora.controller.Controller +logger.controller.level = trace + +# Debug defaultGroupId +logger.defgrp.name = org.qora.account.Account +logger.defgrp.level = trace + +# Debug asset trades +logger.assettrades.name = org.qora.asset.Order +logger.assettrades.level = trace + +appender.console.type = Console +appender.console.name = stdout +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n +appender.console.filter.threshold.type = ThresholdFilter +appender.console.filter.threshold.level = error + +appender.rolling.type = RollingFile +appender.rolling.name = FILE +appender.rolling.layout.type = PatternLayout +appender.rolling.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n +appender.rolling.filePattern = ${filename}.%i +appender.rolling.policy.type = SizeBasedTriggeringPolicy +appender.rolling.policy.size = 4MB +# Set the immediate flush to true (default) +# appender.rolling.immediateFlush = true +# Set the append to true (default), should not overwrite +# appender.rolling.append=true diff --git a/src/test/resources/test-chain-old-asset.json b/src/test/resources/test-chain-old-asset.json new file mode 100644 index 00000000..f38b3b09 --- /dev/null +++ b/src/test/resources/test-chain-old-asset.json @@ -0,0 +1,34 @@ +{ + "isTestNet": true, + "maxBalance": "10000000000", + "blockDifficultyInterval": 10, + "minBlockTime": 30, + "maxBlockTime": 60, + "blockTimestampMargin": 500, + "maxBytesPerUnitFee": 1024, + "unitFee": "0.1", + "requireGroupForApproval": false, + "genesisInfo": { + "version": 4, + "timestamp": 0, + "generatingBalance": "10000000", + "transactions": [ + { "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORA", "description": "QORA native coin", "data": "", "quantity": 10000000000, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" }, + { "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "9876543210.12345678", "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 } + ] + }, + "featureTriggers": { + "messageHeight": 0, + "atHeight": 0, + "assetsTimestamp": 0, + "votingTimestamp": 0, + "arbitraryTimestamp": 0, + "powfixTimestamp": 0, + "v2Timestamp": 0, + "newAssetPricingTimestamp": 1600000000000 + } +} diff --git a/src/test/resources/test-v2qorachain.json b/src/test/resources/test-chain-v2.json similarity index 81% rename from src/test/resources/test-v2qorachain.json rename to src/test/resources/test-chain-v2.json index 420a3ead..94d2730d 100644 --- a/src/test/resources/test-v2qorachain.json +++ b/src/test/resources/test-chain-v2.json @@ -16,8 +16,10 @@ { "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORA", "description": "QORA native coin", "data": "", "quantity": 10000000000, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" }, { "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "9876543210.12345678", "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": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 }, - { "type": "ISSUE_ASSET", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "assetName": "test", "description": "test asset", "data": "", "quantity": 1000, "isDivisible": true, "fee": 0 } + { "type": "ISSUE_ASSET", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "assetName": "test", "description": "test asset", "data": "", "quantity": 1000000, "isDivisible": true, "fee": 0 } ] }, "featureTriggers": { @@ -27,6 +29,7 @@ "votingTimestamp": 0, "arbitraryTimestamp": 0, "powfixTimestamp": 0, - "v2Timestamp": 0 + "v2Timestamp": 0, + "newAssetPricingTimestamp": 0 } } diff --git a/src/test/resources/test-settings-old-asset.json b/src/test/resources/test-settings-old-asset.json new file mode 100644 index 00000000..587a880c --- /dev/null +++ b/src/test/resources/test-settings-old-asset.json @@ -0,0 +1,6 @@ +{ + "restrictedApi": false, + "blockchainConfig": "src/test/resources/test-chain-old-asset.json", + "wipeUnconfirmedOnStart": false, + "minPeers": 0 +} diff --git a/src/test/resources/test-settings.json b/src/test/resources/test-settings-v2.json similarity index 55% rename from src/test/resources/test-settings.json rename to src/test/resources/test-settings-v2.json index 4fe80523..31fc2672 100644 --- a/src/test/resources/test-settings.json +++ b/src/test/resources/test-settings-v2.json @@ -1,6 +1,6 @@ { "restrictedApi": false, - "blockchainConfig": "src/test/resources/test-v2qorachain.json", + "blockchainConfig": "src/test/resources/test-chain-v2.json", "wipeUnconfirmedOnStart": false, "minPeers": 0 } From 26e3adb92bfa0826d00175ac2a58e47ddfc125b8 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 3 Apr 2019 18:00:20 +0100 Subject: [PATCH 4/4] Completing work on new asset trading changes Changed API call GET /assets to NOT return asset "data" fields as they can be huge. If need be, call GET /assets/info to fetch a specific asset's data field. Improve asset trade amount granularity, especially for indivisible assets, under "new" pricing scheme only. Added corresponding tests for granularity adjustments. Fix/unify asset order logging text under "old" and "new" pricing schemes. Change asset order related API data models so that old "price" is now "unitPrice" and add new "return" as in amount of want-asset to receive if have-asset "amount" was fully matched. (Affects OrderData, CreateAssetOrderTransactionData) Some changes to the HSQLDB tables. Don't forget to add "newAssetPricingTimestamp" to your blockchain config's "featureTriggers" map. --- .../org/qora/api/resource/AssetsResource.java | 8 +- src/main/java/org/qora/asset/Order.java | 48 +++++----- .../java/org/qora/data/asset/OrderData.java | 4 +- .../CreateAssetOrderTransactionData.java | 17 ++-- .../hsqldb/HSQLDBDatabaseUpdates.java | 4 +- ...CreateAssetOrderTransactionRepository.java | 8 +- .../CreateAssetOrderTransaction.java | 12 +-- ...reateAssetOrderTransactionTransformer.java | 4 +- src/test/java/org/qora/test/ATTests.java | 1 + src/test/java/org/qora/test/BlockTests.java | 1 + .../java/org/qora/test/BlockchainTests.java | 1 + src/test/java/org/qora/test/CryptoTests.java | 1 + src/test/java/org/qora/test/GenesisTests.java | 1 + .../org/qora/test/GroupApprovalTests.java | 1 + src/test/java/org/qora/test/LoadTests.java | 1 + .../java/org/qora/test/NavigationTests.java | 1 + .../java/org/qora/test/RepositoryTests.java | 1 + src/test/java/org/qora/test/SaveTests.java | 1 + .../org/qora/test/SerializationTests.java | 1 + .../java/org/qora/test/SignatureTests.java | 1 + .../java/org/qora/test/TransactionTests.java | 1 + .../org/qora/test/assets/TradingTests.java | 54 +++++++++-- .../org/qora/test/common/AccountUtils.java | 1 - .../java/org/qora/test/common/AssetUtils.java | 7 +- .../org/qora/test/{ => common}/Common.java | 90 ++++++++++++------- .../qora/test/common/TransactionUtils.java | 34 +++++++ 26 files changed, 211 insertions(+), 93 deletions(-) rename src/test/java/org/qora/test/{ => common}/Common.java (52%) create mode 100644 src/test/java/org/qora/test/common/TransactionUtils.java diff --git a/src/main/java/org/qora/api/resource/AssetsResource.java b/src/main/java/org/qora/api/resource/AssetsResource.java index 1bea0c2e..9a0b14e7 100644 --- a/src/main/java/org/qora/api/resource/AssetsResource.java +++ b/src/main/java/org/qora/api/resource/AssetsResource.java @@ -75,7 +75,7 @@ public class AssetsResource { @GET @Operation( - summary = "List all known assets", + summary = "List all known assets (without data field)", responses = { @ApiResponse( description = "asset info", @@ -100,7 +100,11 @@ public class AssetsResource { ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getAssetRepository().getAllAssets(limit, offset, reverse); + List assets = repository.getAssetRepository().getAllAssets(limit, offset, reverse); + + assets.forEach(asset -> asset.setData(null)); + + return assets; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } diff --git a/src/main/java/org/qora/asset/Order.java b/src/main/java/org/qora/asset/Order.java index 0f169206..44f4c31c 100644 --- a/src/main/java/org/qora/asset/Order.java +++ b/src/main/java/org/qora/asset/Order.java @@ -71,14 +71,22 @@ public class Order { * @param theirPrice * @return unit price of want asset */ - public static BigDecimal calculateAmountGranularity(AssetData haveAssetData, AssetData wantAssetData, BigDecimal theirPrice) { - // Multiplier to scale BigDecimal.setScale(8) fractional amounts into integers, essentially 1e8 + public static BigDecimal calculateAmountGranularity(AssetData haveAssetData, AssetData wantAssetData, OrderData theirOrderData) { + // Multiplier to scale BigDecimal fractional amounts into integer domain BigInteger multiplier = BigInteger.valueOf(1_0000_0000L); // Calculate the minimum increment at which I can buy using greatest-common-divisor - BigInteger haveAmount = multiplier; // 1 unit (* multiplier) - //BigInteger wantAmount = BigDecimal.valueOf(100_000_000L).setScale(Asset.BD_SCALE).divide(theirOrderData.getUnitPrice(), RoundingMode.DOWN).toBigInteger(); - BigInteger wantAmount = theirPrice.movePointRight(8).toBigInteger(); + BigInteger haveAmount; + BigInteger wantAmount; + if (theirOrderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp()) { + // "new" pricing scheme + haveAmount = theirOrderData.getAmount().movePointRight(8).toBigInteger(); + wantAmount = theirOrderData.getWantAmount().movePointRight(8).toBigInteger(); + } else { + // legacy "old" behaviour + haveAmount = multiplier; // 1 unit (* multiplier) + wantAmount = theirOrderData.getUnitPrice().movePointRight(8).toBigInteger(); + } BigInteger gcd = haveAmount.gcd(wantAmount); haveAmount = haveAmount.divide(gcd); @@ -115,7 +123,6 @@ public class Order { if (LOGGER.getLevel().isMoreSpecificThan(Level.DEBUG)) return; - final boolean isNewPricing = orderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp(); final String weThey = isMatchingNotInitial ? "They" : "We"; AssetData haveAssetData = this.repository.getAssetRepository().fromAssetId(orderData.getHaveAssetId()); @@ -125,15 +132,9 @@ public class Order { LOGGER.trace(String.format("%s have: %s %s", weThey, orderData.getAmount().stripTrailingZeros().toPlainString(), haveAssetData.getName())); - if (isNewPricing) { - LOGGER.trace(String.format("%s want: %s %s (@ %s %s each)", weThey, - orderData.getWantAmount().stripTrailingZeros().toPlainString(), wantAssetData.getName(), - orderData.getUnitPrice().toPlainString(), haveAssetData.getName())); - } else { - LOGGER.trace(String.format("%s want at least %s %s per %s (minimum %s %s total)", weThey, - orderData.getUnitPrice().toPlainString(), wantAssetData.getName(), haveAssetData.getName(), - orderData.getWantAmount().stripTrailingZeros().toPlainString(), wantAssetData.getName())); - } + LOGGER.trace(String.format("%s want at least %s %s per %s (minimum %s %s total)", weThey, + orderData.getUnitPrice().toPlainString(), wantAssetData.getName(), haveAssetData.getName(), + orderData.getWantAmount().stripTrailingZeros().toPlainString(), wantAssetData.getName())); } public void process() throws DataException { @@ -254,18 +255,13 @@ public class Order { if (matchedWantAmount.compareTo(BigDecimal.ZERO) <= 0) continue; - // We can skip granularity if theirWantAmountLeft is an [integer] multiple of matchedWantAmount as that obviously fits - if (!isTheirOrderNewAssetPricing || theirWantAmountLeft.remainder(matchedWantAmount).compareTo(BigDecimal.ZERO) > 0) { - // Not an integer multiple so do granularity check + // Calculate want-amount granularity, based on price and both assets' divisibility, so that have-amount traded is a valid amount (integer or to 8 d.p.) + BigDecimal wantGranularity = calculateAmountGranularity(haveAssetData, wantAssetData, theirOrderData); + LOGGER.trace("wantGranularity (want-asset amount granularity): " + wantGranularity.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); - // Calculate amount granularity based on both assets' divisibility - BigDecimal wantGranularity = calculateAmountGranularity(haveAssetData, wantAssetData, theirOrderData.getUnitPrice()); - LOGGER.trace("wantGranularity (want-asset amount granularity): " + wantGranularity.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); - - // Reduce matched amount (if need be) to fit granularity - matchedWantAmount = matchedWantAmount.subtract(matchedWantAmount.remainder(wantGranularity)); - LOGGER.trace("matchedWantAmount adjusted for granularity: " + matchedWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); - } + // Reduce matched amount (if need be) to fit granularity + matchedWantAmount = matchedWantAmount.subtract(matchedWantAmount.remainder(wantGranularity)); + LOGGER.trace("matchedWantAmount adjusted for granularity: " + matchedWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); // If we can't buy anything then try another order if (matchedWantAmount.compareTo(BigDecimal.ZERO) <= 0) diff --git a/src/main/java/org/qora/data/asset/OrderData.java b/src/main/java/org/qora/data/asset/OrderData.java index 4794c1f3..c12903d4 100644 --- a/src/main/java/org/qora/data/asset/OrderData.java +++ b/src/main/java/org/qora/data/asset/OrderData.java @@ -4,6 +4,7 @@ import java.math.BigDecimal; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; import io.swagger.v3.oas.annotations.media.Schema; @@ -24,7 +25,8 @@ public class OrderData implements Comparable { @Schema(description = "amount of \"have\" asset to trade") private BigDecimal amount; - @Schema(description = "amount of \"want\" asset to receive") + @Schema(name = "return", description = "amount of \"want\" asset to receive") + @XmlElement(name = "return") private BigDecimal wantAmount; @Schema(description = "amount of \"want\" asset to receive per unit of \"have\" asset traded") diff --git a/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java b/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java index e826c454..208b1489 100644 --- a/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java +++ b/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java @@ -22,8 +22,9 @@ public class CreateAssetOrderTransactionData extends TransactionData { private long wantAssetId; @Schema(description = "amount of \"have\" asset to trade") private BigDecimal amount; - @Schema(description = "amount of \"want\" asset to receive") - private BigDecimal price; + @Schema(name = "return", description = "amount of \"want\" asset to receive") + @XmlElement(name = "return") + private BigDecimal wantAmount; // Constructors @@ -33,18 +34,18 @@ public class CreateAssetOrderTransactionData extends TransactionData { } public CreateAssetOrderTransactionData(long timestamp, int txGroupId, byte[] reference, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, - BigDecimal amount, BigDecimal price, BigDecimal fee, byte[] signature) { + BigDecimal amount, BigDecimal wantAmount, BigDecimal fee, byte[] signature) { super(TransactionType.CREATE_ASSET_ORDER, timestamp, txGroupId, reference, creatorPublicKey, fee, signature); this.haveAssetId = haveAssetId; this.wantAssetId = wantAssetId; this.amount = amount; - this.price = price; + this.wantAmount = wantAmount; } public CreateAssetOrderTransactionData(long timestamp, int txGroupId, byte[] reference, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, - BigDecimal amount, BigDecimal price, BigDecimal fee) { - this(timestamp, txGroupId, reference, creatorPublicKey, haveAssetId, wantAssetId, amount, price, fee, null); + BigDecimal amount, BigDecimal wantAmount, BigDecimal fee) { + this(timestamp, txGroupId, reference, creatorPublicKey, haveAssetId, wantAssetId, amount, wantAmount, fee, null); } // Getters/Setters @@ -61,8 +62,8 @@ public class CreateAssetOrderTransactionData extends TransactionData { return this.amount; } - public BigDecimal getPrice() { - return this.price; + public BigDecimal getWantAmount() { + return this.wantAmount; } // Re-expose creatorPublicKey for this transaction type for JAXB diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java index 75f8cc1f..80ad244a 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -651,8 +651,8 @@ public class HSQLDBDatabaseUpdates { stmt.execute("UPDATE AssetOrders set want_amount = amount * unit_price"); // want-amounts all set, so disallow NULL stmt.execute("ALTER TABLE AssetOrders ALTER COLUMN want_amount SET NOT NULL"); - // Convert old "price" into buying unit price - stmt.execute("UPDATE AssetOrders set unit_price = 1 / unit_price"); + // Rename corresponding column in CreateAssetOrderTransactions + stmt.execute("ALTER TABLE CreateAssetOrderTransactions ALTER COLUMN price RENAME TO want_amount"); break; default: diff --git a/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBCreateAssetOrderTransactionRepository.java b/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBCreateAssetOrderTransactionRepository.java index 1dc663a8..517691a9 100644 --- a/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBCreateAssetOrderTransactionRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBCreateAssetOrderTransactionRepository.java @@ -18,16 +18,16 @@ public class HSQLDBCreateAssetOrderTransactionRepository extends HSQLDBTransacti TransactionData fromBase(long timestamp, int txGroupId, byte[] reference, byte[] creatorPublicKey, BigDecimal fee, byte[] signature) throws DataException { try (ResultSet resultSet = this.repository - .checkedExecute("SELECT have_asset_id, amount, want_asset_id, price FROM CreateAssetOrderTransactions WHERE signature = ?", signature)) { + .checkedExecute("SELECT have_asset_id, amount, want_asset_id, want_amount FROM CreateAssetOrderTransactions WHERE signature = ?", signature)) { if (resultSet == null) return null; long haveAssetId = resultSet.getLong(1); BigDecimal amount = resultSet.getBigDecimal(2); long wantAssetId = resultSet.getLong(3); - BigDecimal price = resultSet.getBigDecimal(4); + BigDecimal wantAmount = resultSet.getBigDecimal(4); - return new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, creatorPublicKey, haveAssetId, wantAssetId, amount, price, fee, signature); + return new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, creatorPublicKey, haveAssetId, wantAssetId, amount, wantAmount, fee, signature); } catch (SQLException e) { throw new DataException("Unable to fetch create order transaction from repository", e); } @@ -41,7 +41,7 @@ public class HSQLDBCreateAssetOrderTransactionRepository extends HSQLDBTransacti saveHelper.bind("signature", createOrderTransactionData.getSignature()).bind("creator", createOrderTransactionData.getCreatorPublicKey()) .bind("have_asset_id", createOrderTransactionData.getHaveAssetId()).bind("amount", createOrderTransactionData.getAmount()) - .bind("want_asset_id", createOrderTransactionData.getWantAssetId()).bind("price", createOrderTransactionData.getPrice()); + .bind("want_asset_id", createOrderTransactionData.getWantAssetId()).bind("want_amount", createOrderTransactionData.getWantAmount()); try { saveHelper.execute(this.repository); diff --git a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java index 04d1017b..fac31c54 100644 --- a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java +++ b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java @@ -83,7 +83,7 @@ public class CreateAssetOrderTransaction extends Transaction { return ValidationResult.NEGATIVE_AMOUNT; // Check price is positive - if (createOrderTransactionData.getPrice().compareTo(BigDecimal.ZERO) <= 0) + if (createOrderTransactionData.getWantAmount().compareTo(BigDecimal.ZERO) <= 0) return ValidationResult.NEGATIVE_PRICE; // Check fee is positive @@ -133,12 +133,12 @@ public class CreateAssetOrderTransaction extends Transaction { // Check total return from fulfilled order would be integer if "want" asset is not divisible if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp()) { // "new" asset pricing - if (!wantAssetData.getIsDivisible() && createOrderTransactionData.getPrice().stripTrailingZeros().scale() > 0) + if (!wantAssetData.getIsDivisible() && createOrderTransactionData.getWantAmount().stripTrailingZeros().scale() > 0) return ValidationResult.INVALID_RETURN; } else { // "old" asset pricing if (!wantAssetData.getIsDivisible() - && createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()).stripTrailingZeros().scale() > 0) + && createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getWantAmount()).stripTrailingZeros().scale() > 0) return ValidationResult.INVALID_RETURN; } @@ -166,12 +166,12 @@ public class CreateAssetOrderTransaction extends Transaction { if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp()) { // "new" asset pricing: want-amount provided, unit price to be calculated - wantAmount = createOrderTransactionData.getPrice(); + wantAmount = createOrderTransactionData.getWantAmount(); unitPrice = wantAmount.setScale(Order.BD_PRICE_STORAGE_SCALE).divide(createOrderTransactionData.getAmount().setScale(Order.BD_PRICE_STORAGE_SCALE), RoundingMode.DOWN); } else { // "old" asset pricing: selling unit price provided, want-amount to be calculated - wantAmount = createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()); - unitPrice = createOrderTransactionData.getPrice(); + wantAmount = createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getWantAmount()); + unitPrice = createOrderTransactionData.getWantAmount(); // getWantAmount() was getPrice() in the "old" pricing scheme } // Process the order itself diff --git a/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java b/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java index 8597e2af..def4303e 100644 --- a/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java +++ b/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java @@ -88,7 +88,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getAmount(), AMOUNT_LENGTH); // Under "new" asset pricing, this is actually the want-amount - Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getPrice(), AMOUNT_LENGTH); + Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getWantAmount(), AMOUNT_LENGTH); Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getFee()); @@ -128,7 +128,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getAmount(), AMOUNT_LENGTH); // This is the crucial difference - Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getPrice(), FEE_LENGTH); + Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getWantAmount(), FEE_LENGTH); Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getFee()); diff --git a/src/test/java/org/qora/test/ATTests.java b/src/test/java/org/qora/test/ATTests.java index e759a928..b13388f3 100644 --- a/src/test/java/org/qora/test/ATTests.java +++ b/src/test/java/org/qora/test/ATTests.java @@ -10,6 +10,7 @@ import org.qora.group.Group; 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.DeployAtTransaction; import org.qora.transform.TransformationException; import org.qora.utils.Base58; diff --git a/src/test/java/org/qora/test/BlockTests.java b/src/test/java/org/qora/test/BlockTests.java index 6487aaa5..b228feca 100644 --- a/src/test/java/org/qora/test/BlockTests.java +++ b/src/test/java/org/qora/test/BlockTests.java @@ -11,6 +11,7 @@ import org.qora.data.transaction.TransactionData; 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.Transaction; import org.qora.transform.TransformationException; import org.qora.transform.block.BlockTransformer; diff --git a/src/test/java/org/qora/test/BlockchainTests.java b/src/test/java/org/qora/test/BlockchainTests.java index f80965bb..c6e45138 100644 --- a/src/test/java/org/qora/test/BlockchainTests.java +++ b/src/test/java/org/qora/test/BlockchainTests.java @@ -3,6 +3,7 @@ package org.qora.test; import org.junit.Test; import org.qora.block.BlockChain; import org.qora.repository.DataException; +import org.qora.test.common.Common; public class BlockchainTests extends Common { diff --git a/src/test/java/org/qora/test/CryptoTests.java b/src/test/java/org/qora/test/CryptoTests.java index c8947c82..d81b5df9 100644 --- a/src/test/java/org/qora/test/CryptoTests.java +++ b/src/test/java/org/qora/test/CryptoTests.java @@ -3,6 +3,7 @@ package org.qora.test; import org.junit.Test; import org.qora.block.BlockChain; import org.qora.crypto.Crypto; +import org.qora.test.common.Common; import static org.junit.Assert.*; diff --git a/src/test/java/org/qora/test/GenesisTests.java b/src/test/java/org/qora/test/GenesisTests.java index 59df1090..65c1d8b5 100644 --- a/src/test/java/org/qora/test/GenesisTests.java +++ b/src/test/java/org/qora/test/GenesisTests.java @@ -10,6 +10,7 @@ import org.qora.data.transaction.TransactionData; 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.Transaction; import static org.junit.Assert.*; diff --git a/src/test/java/org/qora/test/GroupApprovalTests.java b/src/test/java/org/qora/test/GroupApprovalTests.java index 695a2163..76745de5 100644 --- a/src/test/java/org/qora/test/GroupApprovalTests.java +++ b/src/test/java/org/qora/test/GroupApprovalTests.java @@ -12,6 +12,7 @@ import org.qora.group.Group.ApprovalThreshold; 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.CreateGroupTransaction; import org.qora.transaction.PaymentTransaction; import org.qora.transaction.Transaction; diff --git a/src/test/java/org/qora/test/LoadTests.java b/src/test/java/org/qora/test/LoadTests.java index 63b764a5..c911aa6d 100644 --- a/src/test/java/org/qora/test/LoadTests.java +++ b/src/test/java/org/qora/test/LoadTests.java @@ -8,6 +8,7 @@ import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryManager; import org.qora.repository.TransactionRepository; +import org.qora.test.common.Common; import org.qora.transaction.Transaction.TransactionType; import org.qora.utils.Base58; diff --git a/src/test/java/org/qora/test/NavigationTests.java b/src/test/java/org/qora/test/NavigationTests.java index 64db0a22..fba2ecf6 100644 --- a/src/test/java/org/qora/test/NavigationTests.java +++ b/src/test/java/org/qora/test/NavigationTests.java @@ -7,6 +7,7 @@ import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryManager; import org.qora.repository.TransactionRepository; +import org.qora.test.common.Common; import org.qora.transaction.Transaction.TransactionType; import org.qora.utils.Base58; diff --git a/src/test/java/org/qora/test/RepositoryTests.java b/src/test/java/org/qora/test/RepositoryTests.java index 5847086f..b7866ae0 100644 --- a/src/test/java/org/qora/test/RepositoryTests.java +++ b/src/test/java/org/qora/test/RepositoryTests.java @@ -4,6 +4,7 @@ import org.junit.Test; import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryManager; +import org.qora.test.common.Common; import static org.junit.Assert.*; diff --git a/src/test/java/org/qora/test/SaveTests.java b/src/test/java/org/qora/test/SaveTests.java index ce6eb556..c9898b8d 100644 --- a/src/test/java/org/qora/test/SaveTests.java +++ b/src/test/java/org/qora/test/SaveTests.java @@ -10,6 +10,7 @@ import org.qora.group.Group; import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryManager; +import org.qora.test.common.Common; import org.qora.utils.Base58; public class SaveTests extends Common { diff --git a/src/test/java/org/qora/test/SerializationTests.java b/src/test/java/org/qora/test/SerializationTests.java index 8741e011..045d313e 100644 --- a/src/test/java/org/qora/test/SerializationTests.java +++ b/src/test/java/org/qora/test/SerializationTests.java @@ -9,6 +9,7 @@ import org.qora.data.transaction.TransactionData; 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.GenesisTransaction; import org.qora.transaction.Transaction; import org.qora.transaction.Transaction.TransactionType; diff --git a/src/test/java/org/qora/test/SignatureTests.java b/src/test/java/org/qora/test/SignatureTests.java index 033b87bc..f667c241 100644 --- a/src/test/java/org/qora/test/SignatureTests.java +++ b/src/test/java/org/qora/test/SignatureTests.java @@ -8,6 +8,7 @@ import org.qora.data.block.BlockData; import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryManager; +import org.qora.test.common.Common; import org.qora.utils.Base58; import org.qora.utils.NTP; diff --git a/src/test/java/org/qora/test/TransactionTests.java b/src/test/java/org/qora/test/TransactionTests.java index e209d05f..efe6b47c 100644 --- a/src/test/java/org/qora/test/TransactionTests.java +++ b/src/test/java/org/qora/test/TransactionTests.java @@ -39,6 +39,7 @@ 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; diff --git a/src/test/java/org/qora/test/assets/TradingTests.java b/src/test/java/org/qora/test/assets/TradingTests.java index f34c53b9..7662ecfa 100644 --- a/src/test/java/org/qora/test/assets/TradingTests.java +++ b/src/test/java/org/qora/test/assets/TradingTests.java @@ -4,14 +4,16 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.qora.asset.Asset; +import org.qora.asset.Order; +import org.qora.block.BlockChain; +import org.qora.data.asset.AssetData; +import org.qora.data.asset.OrderData; import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryManager; -import org.qora.test.Common; import org.qora.test.common.AccountUtils; import org.qora.test.common.AssetUtils; - -import static org.junit.Assert.*; +import org.qora.test.common.Common; import java.math.BigDecimal; import java.util.Map; @@ -25,6 +27,46 @@ public class TradingTests extends Common { @After public void afterTest() throws DataException { + Common.orphanCheck(); + } + + /** + * Check granularity adjustment values. + *

+ * If trading at a price of 12 eggs for 1 coin + * then trades can only happen at multiples of + * 0.000000001 or 0.00000012 depending on direction. + */ + @Test + public void testDivisibleGranularities() { + testGranularity(true, true, "12", "1", "0.00000012"); + testGranularity(true, true, "1", "12", "0.00000001"); + } + + /** + * Check granularity adjustment values. + *

+ * If trading at a price of 123 riches per 50301 rags, + * then the GCD(123, 50301) is 3 and so trades can only + * happen at multiples of (50301/3) = 16767 rags or + * (123/3) = 41 riches. + */ + @Test + public void testIndivisibleGranularities() { + testGranularity(false, false, "50301", "123", "16767"); + testGranularity(false, false, "123", "50301", "41"); + } + + private void testGranularity(boolean isOurHaveDivisible, boolean isOurWantDivisible, String theirHaveAmount, String theirWantAmount, String expectedGranularity) { + final long newPricingTimestamp = BlockChain.getInstance().getNewAssetPricingTimestamp() + 1; + + final AssetData ourHaveAssetData = new AssetData(null, null, null, 0, isOurHaveDivisible, null, 0, null); + final AssetData ourWantAssetData = new AssetData(null, null, null, 0, isOurWantDivisible, null, 0, null); + + OrderData theirOrderData = new OrderData(null, null, 0, 0, new BigDecimal(theirHaveAmount), new BigDecimal(theirWantAmount), null, newPricingTimestamp); + + BigDecimal granularity = Order.calculateAmountGranularity(ourHaveAssetData, ourWantAssetData, theirOrderData); + assertEqualBigDecimals("Granularity incorrect", new BigDecimal(expectedGranularity), granularity); } /** @@ -164,8 +206,7 @@ public class TradingTests extends Common { BigDecimal expectedFulfilled = asset113Matched2; BigDecimal actualFulfilled = repository.getAssetRepository().fromOrderId(furtherOrderId).getFulfilled(); - assertTrue(String.format("Order fulfilled incorrect: expected %s, actual %s", expectedFulfilled.toPlainString(), actualFulfilled.toPlainString()), - actualFulfilled.compareTo(expectedFulfilled) == 0); + assertEqualBigDecimals("Order fulfilled incorrect", expectedFulfilled, actualFulfilled); } } @@ -394,8 +435,7 @@ public class TradingTests extends Common { private static void assertBalance(Repository repository, String accountName, long assetId, BigDecimal expectedBalance) throws DataException { BigDecimal actualBalance = Common.getTestAccount(repository, accountName).getConfirmedBalance(assetId); - assertTrue(String.format("Test account '%s' asset %d balance incorrect: expected %s, actual %s", accountName, assetId, expectedBalance.toPlainString(), actualBalance.toPlainString()), - actualBalance.compareTo(expectedBalance) == 0); + assertEqualBigDecimals(String.format("Test account '%s' asset %d balance incorrect", accountName, assetId), expectedBalance, actualBalance); } } \ No newline at end of file diff --git a/src/test/java/org/qora/test/common/AccountUtils.java b/src/test/java/org/qora/test/common/AccountUtils.java index cba82127..63e6012a 100644 --- a/src/test/java/org/qora/test/common/AccountUtils.java +++ b/src/test/java/org/qora/test/common/AccountUtils.java @@ -6,7 +6,6 @@ import java.util.Map; import org.qora.repository.DataException; import org.qora.repository.Repository; -import org.qora.test.Common; public class AccountUtils { diff --git a/src/test/java/org/qora/test/common/AssetUtils.java b/src/test/java/org/qora/test/common/AssetUtils.java index 12cc91f8..9cca4eb1 100644 --- a/src/test/java/org/qora/test/common/AssetUtils.java +++ b/src/test/java/org/qora/test/common/AssetUtils.java @@ -10,7 +10,6 @@ import org.qora.data.transaction.TransferAssetTransactionData; import org.qora.group.Group; import org.qora.repository.DataException; import org.qora.repository.Repository; -import org.qora.test.Common; public class AssetUtils { @@ -26,7 +25,7 @@ public class AssetUtils { TransactionData transactionData = new IssueAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, account.getPublicKey(), account.getAddress(), assetName, "desc", quantity, isDivisible, "{}", AssetUtils.fee); - Common.signAndForge(repository, transactionData, account); + TransactionUtils.signAndForge(repository, transactionData, account); return repository.getAssetRepository().fromAssetName(assetName).getAssetId(); } @@ -40,7 +39,7 @@ public class AssetUtils { TransactionData transactionData = new TransferAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, fromAccount.getPublicKey(), toAccount.getAddress(), amount, assetId, AssetUtils.fee); - Common.signAndForge(repository, transactionData, fromAccount); + TransactionUtils.signAndForge(repository, transactionData, fromAccount); } public static byte[] createOrder(Repository repository, String accountName, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal wantAmount) throws DataException { @@ -52,7 +51,7 @@ public class AssetUtils { // Note: "price" is not the same in V2 as in V1 TransactionData transactionData = new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, account.getPublicKey(), haveAssetId, wantAssetId, amount, wantAmount, fee); - Common.signAndForge(repository, transactionData, account); + TransactionUtils.signAndForge(repository, transactionData, account); return repository.getAssetRepository().getAccountsOrders(account.getPublicKey(), null, null, null, null, true).get(0).getOrderId(); } diff --git a/src/test/java/org/qora/test/Common.java b/src/test/java/org/qora/test/common/Common.java similarity index 52% rename from src/test/java/org/qora/test/Common.java rename to src/test/java/org/qora/test/common/Common.java index fd14f9f8..578e4ddf 100644 --- a/src/test/java/org/qora/test/Common.java +++ b/src/test/java/org/qora/test/common/Common.java @@ -1,12 +1,16 @@ -package org.qora.test; +package org.qora.test.common; import static org.junit.Assert.*; +import java.math.BigDecimal; import java.net.URL; import java.security.Security; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import org.bitcoinj.core.Base58; @@ -14,28 +18,33 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.AfterClass; import org.junit.BeforeClass; -import org.qora.account.PrivateKeyAccount; -import org.qora.api.resource.TransactionsResource.ConfirmationStatus; +import org.qora.block.Block; import org.qora.block.BlockChain; -import org.qora.block.BlockGenerator; -import org.qora.data.transaction.TransactionData; +import org.qora.data.account.AccountBalanceData; +import org.qora.data.asset.AssetData; +import org.qora.data.block.BlockData; +import org.qora.data.group.GroupData; +import org.qora.repository.AccountRepository.BalanceOrdering; import org.qora.repository.DataException; import org.qora.repository.Repository; import org.qora.repository.RepositoryFactory; import org.qora.repository.RepositoryManager; import org.qora.repository.hsqldb.HSQLDBRepositoryFactory; import org.qora.settings.Settings; -import org.qora.test.common.TestAccount; -import org.qora.transaction.Transaction; -import org.qora.transaction.Transaction.ValidationResult; public class Common { public static final String testConnectionUrl = "jdbc:hsqldb:mem:testdb"; + // For debugging, use this instead to write DB to disk for examination: // public static final String testConnectionUrl = "jdbc:hsqldb:file:testdb/blockchain;create=true"; public static final String testSettingsFilename = "test-settings-v2.json"; + private static List initialAssets; + private static List initialGroups; + private static List initialBalances; + + // TODO: converts users of these constants to TestAccount schema public static final byte[] v2testPrivateKey = Base58.decode("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6"); public static final byte[] v2testPublicKey = Base58.decode("2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP"); public static final String v2testAddress = "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v"; @@ -53,7 +62,6 @@ public class Common { Settings.fileInstance(testSettingsUrl.getPath()); } - public static Map lastTransactionByAddress; private static Map testAccountsByName = new HashMap<>(); static { testAccountsByName.put("alice", new TestAccount(null, "alice", "A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6")); @@ -90,35 +98,52 @@ public class Common { public static void resetBlockchain() throws DataException { BlockChain.validate(); - lastTransactionByAddress = new HashMap<>(); + try (final Repository repository = RepositoryManager.getRepository()) { + // Build snapshot of initial state in case we want to compare with post-test orphaning + initialAssets = repository.getAssetRepository().getAllAssets(); + initialGroups = repository.getGroupRepository().getAllGroups(); + initialBalances = repository.getAccountRepository().getAssetBalances(Collections.emptyList(), Collections.emptyList(), BalanceOrdering.ASSET_ACCOUNT, null, null, null); - try (Repository repository = RepositoryManager.getRepository()) { - for (TestAccount account : testAccountsByName.values()) { - List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, account.getAddress(), ConfirmationStatus.BOTH, 1, null, true); - assertFalse(String.format("Test account '%s' should have existing transaction", account.accountName), signatures.isEmpty()); - - TransactionData transactionData = repository.getTransactionRepository().fromSignature(signatures.get(0)); - lastTransactionByAddress.put(account.getAddress(), transactionData); - } + // Check that each test account can fetch their last reference + for (TestAccount testAccount : getTestAccounts(repository)) + assertNotNull(String.format("Test account '%s' should have existing transaction", testAccount.accountName), testAccount.getLastReference()); } } - public static void signAndForge(Repository repository, TransactionData transactionData, PrivateKeyAccount signingAccount) throws DataException { - Transaction transaction = Transaction.fromData(repository, transactionData); - transaction.sign(signingAccount); + /** Orphan back to genesis block and compare initial snapshot. */ + public static void orphanCheck() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Orphan back to genesis block + while (repository.getBlockRepository().getBlockchainHeight() > 1) { + BlockData blockData = repository.getBlockRepository().getLastBlock(); + Block block = new Block(repository, blockData); + block.orphan(); + repository.saveChanges(); + } - // Add to unconfirmed - assertTrue("Transaction's signature should be valid", transaction.isSignatureValid()); + List remainingAssets = repository.getAssetRepository().getAllAssets(); + checkOrphanedLists("asset", initialAssets, remainingAssets, AssetData::getAssetId); - ValidationResult result = transaction.isValidUnconfirmed(); - assertEquals("Transaction invalid", ValidationResult.OK, result); + List remainingGroups = repository.getGroupRepository().getAllGroups(); + checkOrphanedLists("group", initialGroups, remainingGroups, GroupData::getGroupId); - repository.getTransactionRepository().save(transactionData); - repository.getTransactionRepository().unconfirmTransaction(transactionData); - repository.saveChanges(); + List remainingBalances = repository.getAccountRepository().getAssetBalances(Collections.emptyList(), Collections.emptyList(), BalanceOrdering.ASSET_ACCOUNT, null, null, null); + checkOrphanedLists("account balance", initialBalances, remainingBalances, entry -> entry.getAssetName() + "-" + entry.getAddress()); + } + } - // Generate block - BlockGenerator.generateTestingBlock(repository, signingAccount); + private static void checkOrphanedLists(String typeName, List initial, List remaining, Function keyExtractor) { + 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))); + + // Check all initial entries remain + for (T initialEntry : initial) + assertTrue(String.format("Genesis %s %s missing", typeName, keyExtractor.apply(initialEntry)), isRemaining.test(initialEntry)); + + // Remove initial entries from remaining to see there are any leftover + remaining.removeIf(isInitial); + + assertTrue(String.format("Non-genesis %s remains", typeName), remaining.isEmpty()); } @BeforeClass @@ -136,4 +161,9 @@ public class Common { assertEquals("Blockchain should be empty for this test", 0, repository.getBlockRepository().getBlockchainHeight()); } + public static void assertEqualBigDecimals(String message, BigDecimal expected, BigDecimal actual) { + assertTrue(String.format("%s: expected %s, actual %s", message, expected.toPlainString(), actual.toPlainString()), + actual.compareTo(expected) == 0); + } + } diff --git a/src/test/java/org/qora/test/common/TransactionUtils.java b/src/test/java/org/qora/test/common/TransactionUtils.java new file mode 100644 index 00000000..59fefccf --- /dev/null +++ b/src/test/java/org/qora/test/common/TransactionUtils.java @@ -0,0 +1,34 @@ +package org.qora.test.common; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.qora.account.PrivateKeyAccount; +import org.qora.block.BlockGenerator; +import org.qora.data.transaction.TransactionData; +import org.qora.repository.DataException; +import org.qora.repository.Repository; +import org.qora.transaction.Transaction; +import org.qora.transaction.Transaction.ValidationResult; + +public class TransactionUtils { + + public static void signAndForge(Repository repository, TransactionData transactionData, PrivateKeyAccount signingAccount) throws DataException { + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + // Add to unconfirmed + assertTrue("Transaction's signature should be valid", transaction.isSignatureValid()); + + ValidationResult result = transaction.isValidUnconfirmed(); + assertEquals("Transaction invalid", ValidationResult.OK, result); + + repository.getTransactionRepository().save(transactionData); + repository.getTransactionRepository().unconfirmTransaction(transactionData); + repository.saveChanges(); + + // Generate block + BlockGenerator.generateTestingBlock(repository, signingAccount); + } + +}