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()); 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/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/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 5ebcb1c3..44f4c31c 100644 --- a/src/main/java/org/qora/asset/Order.java +++ b/src/main/java/org/qora/asset/Order.java @@ -6,10 +6,12 @@ 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; 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; @@ -21,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 @@ -58,33 +65,49 @@ 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, 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 = BigInteger.ONE.multiply(multiplier); - BigInteger priceAmount = theirOrderData.getPrice().multiply(new BigDecimal(multiplier)).toBigInteger(); - BigInteger gcd = haveAmount.gcd(priceAmount); + 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); - 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 @@ -95,6 +118,25 @@ 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 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())); + + 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(); @@ -110,37 +152,57 @@ public class Order { // Save this order into repository so it's available for matching, possibly by itself this.repository.getAssetRepository().save(this.orderData); - // 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()); + /* + * 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 + */ + + /* + * 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: - * - * 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". - */ - BigDecimal 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()); - LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName() + " per " + wantAssetData.getName()); + logOrder("Considering order", true, theirOrderData); /* - * Potential matching order example: + * Potential matching order example ("old"): * * haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=486 * @@ -148,58 +210,131 @@ 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". + * 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 = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN); - LOGGER.trace("theirBuyingPrice: " + theirBuyingPrice.toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName()); + /* + * Potential matching order example ("new"): + * + * 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 unit price and maximum amount is 19,440 GOLD. + */ - // 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) + boolean isTheirOrderNewAssetPricing = theirOrderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp(); + + 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 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()); - // 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()); + 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()); + + 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()); + // 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()); + + // 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 = 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/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/asset/OrderData.java b/src/main/java/org/qora/data/asset/OrderData.java index b5797078..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,8 +25,12 @@ public class OrderData implements Comparable { @Schema(description = "amount of \"have\" asset to trade") private BigDecimal amount; + @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") - private BigDecimal price; + private BigDecimal unitPrice; @Schema(description = "how much \"have\" asset has traded") private BigDecimal fulfilled; @@ -44,22 +49,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 +99,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 +130,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/data/transaction/CreateAssetOrderTransactionData.java b/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java index 5b9e148f..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 per unit of \"have\" asset traded") - 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/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..80ad244a 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"); + // Rename corresponding column in CreateAssetOrderTransactions + stmt.execute("ALTER TABLE CreateAssetOrderTransactions ALTER COLUMN price RENAME TO want_amount"); + break; + default: // nothing to do return false; 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/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 619f0321..fac31c54 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; @@ -82,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 @@ -130,9 +131,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().getNewAssetPricingTimestamp()) { + // "new" asset pricing + if (!wantAssetData.getIsDivisible() && createOrderTransactionData.getWantAmount().stripTrailingZeros().scale() > 0) + return ValidationResult.INVALID_RETURN; + } else { + // "old" asset pricing + if (!wantAssetData.getIsDivisible() + && createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getWantAmount()).stripTrailingZeros().scale() > 0) + return ValidationResult.INVALID_RETURN; + } return ValidationResult.OK; } @@ -153,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.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.getWantAmount()); + unitPrice = createOrderTransactionData.getWantAmount(); // getWantAmount() was getPrice() in the "old" pricing scheme + } + // 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..def4303e 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,7 +87,8 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getAmount(), AMOUNT_LENGTH); - Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getPrice(), AMOUNT_LENGTH); + // Under "new" asset pricing, this is actually the want-amount + Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getWantAmount(), AMOUNT_LENGTH); Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getFee()); @@ -126,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/Common.java b/src/test/java/org/qora/test/Common.java deleted file mode 100644 index 93a0ee3c..00000000 --- a/src/test/java/org/qora/test/Common.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.qora.test; - -import static org.junit.Assert.assertEquals; - -import java.security.Security; - -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.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; - -public class Common { - - public static final String testConnectionUrl = "jdbc:hsqldb:mem:testdb"; - public static final String testSettingsFilename = "test-settings.json"; - - public static final byte[] v2testPrivateKey = Base58.decode("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6"); - public static final byte[] v2testPublicKey = Base58.decode("2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP"); - public static final String v2testAddress = "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v"; - - static { - // This must go before any calls to LogManager/Logger - System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); - - Security.insertProviderAt(new BouncyCastleProvider(), 0); - Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); - - // Load/check settings, which potentially sets up blockchain config, etc. - Settings.fileInstance(testSettingsFilename); - } - - @BeforeClass - public static void setRepository() throws DataException { - RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(testConnectionUrl); - RepositoryManager.setRepositoryFactory(repositoryFactory); - } - - @AfterClass - public static void closeRepository() throws DataException { - RepositoryManager.closeRepositoryFactory(); - } - - public static void assetEmptyBlockchain(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/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 495e295b..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; @@ -989,7 +990,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 new file mode 100644 index 00000000..7662ecfa --- /dev/null +++ b/src/test/java/org/qora/test/assets/TradingTests.java @@ -0,0 +1,441 @@ +package org.qora.test.assets; + +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.AccountUtils; +import org.qora.test.common.AssetUtils; +import org.qora.test.common.Common; + +import java.math.BigDecimal; +import java.util.Map; + +public class TradingTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @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); + } + + /** + * 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 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 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()) { + Map> initialBalances = AccountUtils.getBalances(repository, asset112Id, asset113Id); + + // Create further order + byte[] furtherOrderId = AssetUtils.createOrder(repository, "alice", asset113Id, asset112Id, asset113Amount2, asset112Price2); + + // Check balances to check expected outcome + 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(); + assertEqualBigDecimals("Order fulfilled incorrect", expectedFulfilled, actualFulfilled); + } + } + + /** + * 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); + + 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 new file mode 100644 index 00000000..63e6012a --- /dev/null +++ b/src/test/java/org/qora/test/common/AccountUtils.java @@ -0,0 +1,32 @@ +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; + +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 new file mode 100644 index 00000000..9cca4eb1 --- /dev/null +++ b/src/test/java/org/qora/test/common/AssetUtils.java @@ -0,0 +1,59 @@ +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.IssueAssetTransactionData; +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; + +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 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); + + TransactionUtils.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 transactionData = new TransferAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, fromAccount.getPublicKey(), toAccount.getAddress(), amount, assetId, AssetUtils.fee); + + TransactionUtils.signAndForge(repository, transactionData, fromAccount); + } + + 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; + + // 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); + + 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/Common.java b/src/test/java/org/qora/test/common/Common.java new file mode 100644 index 00000000..578e4ddf --- /dev/null +++ b/src/test/java/org/qora/test/common/Common.java @@ -0,0 +1,169 @@ +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; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.qora.block.Block; +import org.qora.block.BlockChain; +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; + +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"; + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + + // Load/check settings, which potentially sets up blockchain config, etc. + URL testSettingsUrl = Common.class.getClassLoader().getResource(testSettingsFilename); + assertNotNull("Test settings JSON file not found", testSettingsUrl); + Settings.fileInstance(testSettingsUrl.getPath()); + } + + private static Map testAccountsByName = new HashMap<>(); + static { + 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 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(); + + 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); + + // 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()); + } + } + + /** 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(); + } + + List remainingAssets = repository.getAssetRepository().getAllAssets(); + checkOrphanedLists("asset", initialAssets, remainingAssets, AssetData::getAssetId); + + List remainingGroups = repository.getGroupRepository().getAllGroups(); + checkOrphanedLists("group", initialGroups, remainingGroups, GroupData::getGroupId); + + 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()); + } + } + + 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 + public static void setRepository() throws DataException { + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(testConnectionUrl); + RepositoryManager.setRepositoryFactory(repositoryFactory); + } + + @AfterClass + public static void closeRepository() throws DataException { + RepositoryManager.closeRepositoryFactory(); + } + + public static void assertEmptyBlockchain(Repository repository) throws DataException { + 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/TestAccount.java b/src/test/java/org/qora/test/common/TestAccount.java new file mode 100644 index 00000000..48269ce2 --- /dev/null +++ b/src/test/java/org/qora/test/common/TestAccount.java @@ -0,0 +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 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/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); + } + +} 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 54% rename from src/test/resources/test-v2qorachain.json rename to src/test/resources/test-chain-v2.json index d402810d..94d2730d 100644 --- a/src/test/resources/test-v2qorachain.json +++ b/src/test/resources/test-chain-v2.json @@ -7,16 +7,19 @@ "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": "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": 1000000, "isDivisible": true, "fee": 0 } ] }, "featureTriggers": { @@ -26,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 }