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": {