diff --git a/src/main/java/org/qora/api/model/AggregatedOrder.java b/src/main/java/org/qora/api/model/AggregatedOrder.java index 3a56acfd..29865361 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 = "unitPrice") - public BigDecimal getUnitPrice() { - return this.orderData.getUnitPrice(); + @XmlElement(name = "price") + public BigDecimal getPrice() { + return this.orderData.getPrice(); } @XmlElement(name = "unfulfilled") diff --git a/src/main/java/org/qora/asset/Order.java b/src/main/java/org/qora/asset/Order.java index 44f4c31c..10947dcf 100644 --- a/src/main/java/org/qora/asset/Order.java +++ b/src/main/java/org/qora/asset/Order.java @@ -18,27 +18,38 @@ import org.qora.data.asset.TradeData; import org.qora.repository.AssetRepository; import org.qora.repository.DataException; import org.qora.repository.Repository; - -import com.google.common.hash.HashCode; +import org.qora.utils.Base58; 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 private Repository repository; private OrderData orderData; + // Used quite a bit + private final boolean isOurOrderNewPricing; + private final long haveAssetId; + private final long wantAssetId; + + /** Cache of price-pair units e.g. QORA/GOLD, but use getPricePair() instead! */ + private String cachedPricePair; + + /** Cache of have-asset data - but use getHaveAsset() instead! */ + AssetData cachedHaveAssetData; + /** Cache of want-asset data - but use getWantAsset() instead! */ + AssetData cachedWantAssetData; + // Constructors public Order(Repository repository, OrderData orderData) { this.repository = repository; this.orderData = orderData; + + this.isOurOrderNewPricing = this.orderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp(); + this.haveAssetId = this.orderData.getHaveAssetId(); + this.wantAssetId = this.orderData.getWantAssetId(); } // Getters/Setters @@ -66,128 +77,200 @@ public class Order { } /** - * Returns want-asset granularity/unit-size given price. + * Returns granularity/batch-size of matched-amount, given price, so that return-amount is valid size. *

- * @param theirPrice - * @return unit price of want asset + * If matched-amount of matched-asset is traded when two orders match, + * then the corresponding return-amount of the other (return) asset needs to be either + * an integer, if return-asset is indivisible, + * or to the nearest 0.00000001 if return-asset is divisible. + *

+ * @return granularity of matched-amount */ - public static BigDecimal calculateAmountGranularity(AssetData haveAssetData, AssetData wantAssetData, OrderData theirOrderData) { + public static BigDecimal calculateAmountGranularity(boolean isAmountAssetDivisible, boolean isReturnAssetDivisible, BigDecimal price) { // 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 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(); - } + // Calculate the minimum increment for matched-amount using greatest-common-divisor + BigInteger returnAmount = multiplier; // 1 unit (* multiplier) + BigInteger matchedAmount = price.movePointRight(8).toBigInteger(); - BigInteger gcd = haveAmount.gcd(wantAmount); - haveAmount = haveAmount.divide(gcd); - wantAmount = wantAmount.divide(gcd); + BigInteger gcd = returnAmount.gcd(matchedAmount); + returnAmount = returnAmount.divide(gcd); + matchedAmount = matchedAmount.divide(gcd); // Calculate GCD in combination with divisibility - if (wantAssetData.getIsDivisible()) - haveAmount = haveAmount.multiply(multiplier); + if (isAmountAssetDivisible) + returnAmount = returnAmount.multiply(multiplier); - if (haveAssetData.getIsDivisible()) - wantAmount = wantAmount.multiply(multiplier); + if (isReturnAssetDivisible) + matchedAmount = matchedAmount.multiply(multiplier); - gcd = haveAmount.gcd(wantAmount); + gcd = returnAmount.gcd(matchedAmount); // Calculate the granularity at which we have to buy - BigDecimal granularity = new BigDecimal(haveAmount.divide(gcd)); - if (wantAssetData.getIsDivisible()) + BigDecimal granularity = new BigDecimal(returnAmount.divide(gcd)); + if (isAmountAssetDivisible) granularity = granularity.movePointLeft(8); // Return return granularity; } + /** + * Returns price-pair in string form. + *

+ * e.g. "QORA/GOLD" + */ + public String getPricePair() throws DataException { + if (cachedPricePair == null) + calcPricePair(); + + return cachedPricePair; + } + + /** Calculate price pair. (e.g. QORA/GOLD) + *

+ * Under 'new' pricing scheme, lowest-assetID asset is first, + * so if QORA has assetID 0 and GOLD has assetID 10, then + * the pricing pair is QORA/GOLD. + *

+ * This means the "amount" fields are expressed in terms + * of the higher-assetID asset. (e.g. GOLD) + */ + private void calcPricePair() throws DataException { + AssetData haveAssetData = getHaveAsset(); + AssetData wantAssetData = getWantAsset(); + + if (isOurOrderNewPricing && haveAssetId > wantAssetId) + cachedPricePair = wantAssetData.getName() + "/" + haveAssetData.getName(); + else + cachedPricePair = haveAssetData.getName() + "/" + wantAssetData.getName(); + } + + private BigDecimal calcHaveAssetCommittment() { + BigDecimal committedCost = this.orderData.getAmount(); + + // If 'new' pricing and "amount" is in want-asset then we need to convert + if (isOurOrderNewPricing && haveAssetId < wantAssetId) + committedCost = committedCost.multiply(this.orderData.getPrice()).setScale(8, RoundingMode.HALF_UP); + + return committedCost; + } + // Navigation public List getTrades() throws DataException { return this.repository.getAssetRepository().getOrdersTrades(this.orderData.getOrderId()); } + public AssetData getHaveAsset() throws DataException { + if (cachedHaveAssetData == null) + cachedHaveAssetData = this.repository.getAssetRepository().fromAssetId(haveAssetId); + + return cachedHaveAssetData; + } + + public AssetData getWantAsset() throws DataException { + if (cachedWantAssetData == null) + cachedWantAssetData = this.repository.getAssetRepository().fromAssetId(wantAssetId); + + return cachedWantAssetData; + } + + /** + * Returns AssetData for asset in effect for "amount" field. + *

+ * For 'old' pricing, this is the have-asset.
+ * For 'new' pricing, this is the asset with highest assetID. + */ + public AssetData getAmountAsset() throws DataException { + if (isOurOrderNewPricing && wantAssetId > haveAssetId) + return getWantAsset(); + else + return getHaveAsset(); + } + + /** + * Returns AssetData for other (return) asset traded. + *

+ * For 'old' pricing, this is the want-asset.
+ * For 'new' pricing, this is the asset with lowest assetID. + */ + public AssetData getReturnAsset() throws DataException { + if (isOurOrderNewPricing && haveAssetId < wantAssetId) + return getHaveAsset(); + else + return getWantAsset(); + } + // Processing - private void logOrder(String orderPrefix, boolean isMatchingNotInitial, OrderData orderData) throws DataException { + private void logOrder(String orderPrefix, boolean isOurOrder, OrderData orderData) throws DataException { // Avoid calculations if possible if (LOGGER.getLevel().isMoreSpecificThan(Level.DEBUG)) return; - final String weThey = isMatchingNotInitial ? "They" : "We"; + final String weThey = isOurOrder ? "We" : "They"; + final String ourTheir = isOurOrder ? "Our" : "Their"; - AssetData haveAssetData = this.repository.getAssetRepository().fromAssetId(orderData.getHaveAssetId()); - AssetData wantAssetData = this.repository.getAssetRepository().fromAssetId(orderData.getWantAssetId()); + // NOTE: the following values are specific to passed orderData, not the same as class instance values! - LOGGER.debug(String.format("%s %s", orderPrefix, HashCode.fromBytes(orderData.getOrderId()).toString())); + final boolean isOrderNewAssetPricing = orderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp(); - LOGGER.trace(String.format("%s have: %s %s", weThey, orderData.getAmount().stripTrailingZeros().toPlainString(), haveAssetData.getName())); + final long haveAssetId = orderData.getHaveAssetId(); + final long wantAssetId = orderData.getWantAssetId(); - 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())); + final AssetData haveAssetData = this.repository.getAssetRepository().fromAssetId(haveAssetId); + final AssetData wantAssetData = this.repository.getAssetRepository().fromAssetId(wantAssetId); + + final long amountAssetId = (isOurOrderNewPricing && wantAssetId > haveAssetId) ? wantAssetId : haveAssetId; + final long returnAssetId = (isOurOrderNewPricing && haveAssetId < wantAssetId) ? haveAssetId : wantAssetId; + + final AssetData amountAssetData = this.repository.getAssetRepository().fromAssetId(amountAssetId); + final AssetData returnAssetData = this.repository.getAssetRepository().fromAssetId(returnAssetId); + + LOGGER.debug(String.format("%s %s", orderPrefix, Base58.encode(orderData.getOrderId()))); + + LOGGER.trace(String.format("%s have %s, want %s. '%s' pricing scheme.", weThey, haveAssetData.getName(), wantAssetData.getName(), isOrderNewAssetPricing ? "new" : "old")); + + LOGGER.trace(String.format("%s amount: %s (ordered) - %s (fulfilled) = %s %s left", ourTheir, + orderData.getAmount().stripTrailingZeros().toPlainString(), + orderData.getFulfilled().stripTrailingZeros().toPlainString(), + Order.getAmountLeft(orderData).stripTrailingZeros().toPlainString(), + amountAssetData.getName())); + + BigDecimal maxReturnAmount = Order.getAmountLeft(orderData).multiply(orderData.getPrice()).setScale(8, RoundingMode.HALF_UP); + + LOGGER.trace(String.format("%s price: %s %s (%s %s tradable)", ourTheir, + orderData.getPrice().toPlainString(), getPricePair(), + maxReturnAmount.stripTrailingZeros().toPlainString(), returnAssetData.getName())); } public void process() throws DataException { AssetRepository assetRepository = this.repository.getAssetRepository(); long haveAssetId = this.orderData.getHaveAssetId(); - AssetData haveAssetData = assetRepository.fromAssetId(haveAssetId); long wantAssetId = this.orderData.getWantAssetId(); - AssetData wantAssetData = assetRepository.fromAssetId(wantAssetId); + AssetData haveAssetData = getHaveAsset(); + AssetData wantAssetData = getWantAsset(); - // Subtract asset from creator + /** The asset while working out amount that matches. */ + AssetData matchingAssetData = isOurOrderNewPricing ? getAmountAsset() : wantAssetData; + /** The return asset traded if trade completes. */ + AssetData returnAssetData = isOurOrderNewPricing ? getReturnAsset() : haveAssetData; + + // Subtract have-asset from creator Account creator = new PublicKeyAccount(this.repository, this.orderData.getCreatorPublicKey()); - creator.setConfirmedBalance(haveAssetId, creator.getConfirmedBalance(haveAssetId).subtract(this.orderData.getAmount())); + creator.setConfirmedBalance(haveAssetId, creator.getConfirmedBalance(haveAssetId).subtract(this.calcHaveAssetCommittment())); // Save this order into repository so it's available for matching, possibly by itself this.repository.getAssetRepository().save(this.orderData); - /* - * 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 - */ + logOrder("Processing our order", true, this.orderData); - /* - * 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. + // Fetch corresponding open orders that might potentially match, hence reversed want/have assetIDs. // Returned orders are sorted with lowest "price" first. - List orders = assetRepository.getOpenOrders(wantAssetId, haveAssetId); + List orders = assetRepository.getOpenOrdersForTrading(wantAssetId, haveAssetId, isOurOrderNewPricing ? this.orderData.getPrice() : null); LOGGER.trace("Open orders fetched from repository: " + orders.size()); if (orders.isEmpty()) @@ -195,146 +278,145 @@ public class Order { // 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())); + /* + * Potential matching order example ("old"): + * + * Our order: + * haveAssetId=[GOLD], wantAssetId=0 (QORA), amount=40 (GOLD), price=486 (QORA/GOLD) + * This translates to "we have 40 GOLD and want QORA at a price of 486 QORA per GOLD" + * If our order matched, we'd end up with 40 * 486 = 19,440 QORA. + * + * Their order: + * haveAssetId=0 (QORA), wantAssetId=[GOLD], amount=20,000 (QORA), price=0.00205761 (GOLD/QORA) + * This translates to "they have 20,000 QORA and want GOLD at a price of 0.00205761 GOLD per QORA" + * + * Their price, converted into 'our' units of QORA/GOLD, is: 1 / 0.00205761 = 486.00074844 QORA/GOLD. + * This is better than our requested 486 QORA/GOLD so this order matches. + * + * Using their price, we end up with 40 * 486.00074844 = 19440.02993760 QORA. They end up with 40 GOLD. + * + * If their order had 19,440 QORA left, only 19,440 * 0.00205761 = 39.99993840 GOLD would be traded. + */ + + /* + * Potential matching order example ("new"): + * + * Our order: + * haveAssetId=[GOLD], wantAssetId=0 (QORA), amount=40 (GOLD), price=486 (QORA/GOLD) + * This translates to "we have 40 GOLD and want QORA at a price of 486 QORA per GOLD" + * If our order matched, we'd end up with 19,440 QORA at a cost of 19,440 / 486 = 40 GOLD. + * + * Their order: + * haveAssetId=0 (QORA), wantAssetId=[GOLD], amount=40 (GOLD), price=486.00074844 (QORA/GOLD) + * This translates to "they have QORA and want GOLD at a price of 486.00074844 QORA per GOLD" + * + * Their price is better than our requested 486 QORA/GOLD so this order matches. + * + * Using their price, we end up with 40 * 486.00074844 = 19440.02993760 QORA. They end up with 40 GOLD. + * + * If their order only had 36 GOLD left, only 36 * 486.00074844 = 17496.02694384 QORA would be traded. + */ + + BigDecimal ourPrice = this.orderData.getPrice(); for (OrderData theirOrderData : orders) { - logOrder("Considering order", true, theirOrderData); + logOrder("Considering order", false, theirOrderData); - /* - * Potential matching order example ("old"): - * - * haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=486 - * - * This translates to "we have 40 QORA and want to buy GOLD at a price of 486 GOLD per QORA" - * - * 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 unit price and maximum amount is 19,440 GOLD. - */ + // Not used: + // boolean isTheirOrderNewAssetPricing = theirOrderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp(); - /* - * 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. - */ + // Determine their order price + BigDecimal theirPrice; - 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 (isOurOrderNewPricing) { + // Pricing units are the same way round for both orders, so no conversion needed. + // Orders under 'old' pricing have been converted during repository update. + theirPrice = theirOrderData.getPrice(); + LOGGER.trace(String.format("Their price: %s %s", theirPrice.toPlainString(), getPricePair())); + } else { + // If our order is 'old' pricing then all other existing orders must be 'old' pricing too + // Their order pricing will be inverted, so convert + theirPrice = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN); + LOGGER.trace(String.format("Their price: %s %s per %s", theirPrice.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) + if (theirPrice.compareTo(ourPrice) < 0) break; - // Calculate how many want-asset we could buy at their price - 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()); + // Calculate how much we could buy at their price. + BigDecimal ourMaxAmount; + if (isOurOrderNewPricing) + // In 'new' pricing scheme, "amount" is expressed in terms of asset with highest assetID + ourMaxAmount = this.getAmountLeft(); + else + // In 'old' pricing scheme, "amount" is expressed in terms of our want-asset. + ourMaxAmount = this.getAmountLeft().multiply(theirPrice).setScale(8, RoundingMode.DOWN); + LOGGER.trace("ourMaxAmount (max we could trade at their price): " + ourMaxAmount.stripTrailingZeros().toPlainString() + " " + matchingAssetData.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()); + // How much is remaining available in their order. + BigDecimal theirAmountLeft = Order.getAmountLeft(theirOrderData); + LOGGER.trace("theirAmountLeft (max amount remaining in their order): " + theirAmountLeft.stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName()); // So matchable want-asset amount is the minimum of above two values - BigDecimal matchedWantAmount = ourMaxWantAmount.min(theirWantAmountLeft); - LOGGER.trace("matchedWantAmount: " + matchedWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); + BigDecimal matchedAmount = ourMaxAmount.min(theirAmountLeft); + LOGGER.trace("matchedAmount: " + matchedAmount.stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName()); // If we can't buy anything then try another order - if (matchedWantAmount.compareTo(BigDecimal.ZERO) <= 0) + if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0) continue; - // Calculate want-amount granularity, based on price and both assets' divisibility, so that have-amount traded is a valid amount (integer or to 8 d.p.) - BigDecimal wantGranularity = calculateAmountGranularity(haveAssetData, wantAssetData, theirOrderData); - LOGGER.trace("wantGranularity (want-asset amount granularity): " + wantGranularity.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName()); + // Calculate amount granularity, based on price and both assets' divisibility, so that return-amount traded is a valid value (integer or to 8 d.p.) + BigDecimal granularity = calculateAmountGranularity(matchingAssetData.getIsDivisible(), returnAssetData.getIsDivisible(), theirOrderData.getPrice()); + LOGGER.trace("granularity (amount granularity): " + granularity.stripTrailingZeros().toPlainString() + " " + matchingAssetData.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()); + matchedAmount = matchedAmount.subtract(matchedAmount.remainder(granularity)); + LOGGER.trace("matchedAmount adjusted for granularity: " + matchedAmount.stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName()); // If we can't buy anything then try another order - if (matchedWantAmount.compareTo(BigDecimal.ZERO) <= 0) + if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0) continue; - // Safety checks - if (matchedWantAmount.compareTo(Order.getAmountLeft(theirOrderData)) > 0) { + // Safety check + if (!matchingAssetData.getIsDivisible() && matchedAmount.stripTrailingZeros().scale() > 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()); + String message = String.format("Refusing to trade fractional %s [indivisible assetID %d] for %s", + matchedAmount.toPlainString(), matchingAssetData.getAssetId(), 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 haveAmountTraded; + // Calculate the total cost to us, in return-asset, based on their price + BigDecimal returnAmountTraded = matchedAmount.multiply(theirOrderData.getPrice()).setScale(8, RoundingMode.DOWN); + LOGGER.trace("returnAmountTraded: " + returnAmountTraded.stripTrailingZeros().toPlainString() + " " + returnAssetData.getName()); - 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()); + // Safety check + if (!returnAssetData.getIsDivisible() && returnAmountTraded.stripTrailingZeros().scale() > 0) { + String message = String.format("Refusing to trade fractional %s [indivisible assetID %d] for %s", + returnAmountTraded.toPlainString(), returnAssetData.getAssetId(), creator.getAddress()); LOGGER.error(message); throw new DataException(message); } // Construct trade - TradeData tradeData = new TradeData(this.orderData.getOrderId(), theirOrderData.getOrderId(), matchedWantAmount, haveAmountTraded, + BigDecimal tradedWantAmount = (isOurOrderNewPricing && wantAssetId < haveAssetId) ? returnAmountTraded : matchedAmount; + BigDecimal tradedHaveAmount = (isOurOrderNewPricing && haveAssetId > wantAssetId) ? matchedAmount : returnAmountTraded; + + TradeData tradeData = new TradeData(this.orderData.getOrderId(), theirOrderData.getOrderId(), tradedWantAmount, tradedHaveAmount, 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(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()); + BigDecimal amountFulfilled = isOurOrderNewPricing ? matchedAmount : returnAmountTraded; + this.orderData.setFulfilled(this.orderData.getFulfilled().add(amountFulfilled)); + LOGGER.trace("Updated our order's fulfilled amount to: " + this.orderData.getFulfilled().stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName()); + LOGGER.trace("Our order's amount remaining: " + this.getAmountLeft().stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName()); // Continue on to process other open orders if we still have amount left to match if (this.getAmountLeft().compareTo(BigDecimal.ZERO) <= 0) @@ -355,8 +437,9 @@ public class Order { // Return asset to creator long haveAssetId = this.orderData.getHaveAssetId(); + Account creator = new PublicKeyAccount(this.repository, this.orderData.getCreatorPublicKey()); - creator.setConfirmedBalance(haveAssetId, creator.getConfirmedBalance(haveAssetId).add(this.orderData.getAmount())); + creator.setConfirmedBalance(haveAssetId, creator.getConfirmedBalance(haveAssetId).add(this.calcHaveAssetCommittment())); } // This is called by CancelOrderTransaction so that an Order can no longer trade diff --git a/src/main/java/org/qora/asset/Trade.java b/src/main/java/org/qora/asset/Trade.java index 444a8755..812f3fc7 100644 --- a/src/main/java/org/qora/asset/Trade.java +++ b/src/main/java/org/qora/asset/Trade.java @@ -1,7 +1,10 @@ package org.qora.asset; +import java.math.BigDecimal; + import org.qora.account.Account; import org.qora.account.PublicKeyAccount; +import org.qora.block.BlockChain; import org.qora.data.asset.OrderData; import org.qora.data.asset.TradeData; import org.qora.repository.AssetRepository; @@ -31,14 +34,19 @@ public class Trade { // Update corresponding Orders on both sides of trade OrderData initiatingOrder = assetRepository.fromOrderId(this.tradeData.getInitiator()); - initiatingOrder.setFulfilled(initiatingOrder.getFulfilled().add(tradeData.getInitiatorAmount())); + OrderData targetOrder = assetRepository.fromOrderId(this.tradeData.getTarget()); + + // Under 'new' pricing scheme, "amount" and "fulfilled" are the same asset for both orders + boolean isNewPricing = initiatingOrder.getTimestamp() > BlockChain.getInstance().getNewAssetPricingTimestamp(); + BigDecimal newPricingAmount = (initiatingOrder.getHaveAssetId() < initiatingOrder.getWantAssetId()) ? this.tradeData.getTargetAmount() : this.tradeData.getInitiatorAmount(); + + initiatingOrder.setFulfilled(initiatingOrder.getFulfilled().add(isNewPricing ? newPricingAmount : tradeData.getInitiatorAmount())); initiatingOrder.setIsFulfilled(Order.isFulfilled(initiatingOrder)); // Set isClosed to true if isFulfilled now true initiatingOrder.setIsClosed(initiatingOrder.getIsFulfilled()); assetRepository.save(initiatingOrder); - OrderData targetOrder = assetRepository.fromOrderId(this.tradeData.getTarget()); - targetOrder.setFulfilled(targetOrder.getFulfilled().add(tradeData.getTargetAmount())); + targetOrder.setFulfilled(targetOrder.getFulfilled().add(isNewPricing ? newPricingAmount : tradeData.getTargetAmount())); targetOrder.setIsFulfilled(Order.isFulfilled(targetOrder)); // Set isClosed to true if isFulfilled now true targetOrder.setIsClosed(targetOrder.getIsFulfilled()); @@ -59,14 +67,19 @@ public class Trade { // Revert corresponding Orders on both sides of trade OrderData initiatingOrder = assetRepository.fromOrderId(this.tradeData.getInitiator()); - initiatingOrder.setFulfilled(initiatingOrder.getFulfilled().subtract(tradeData.getInitiatorAmount())); + OrderData targetOrder = assetRepository.fromOrderId(this.tradeData.getTarget()); + + // Under 'new' pricing scheme, "amount" and "fulfilled" are the same asset for both orders + boolean isNewPricing = initiatingOrder.getTimestamp() > BlockChain.getInstance().getNewAssetPricingTimestamp(); + BigDecimal newPricingAmount = (initiatingOrder.getHaveAssetId() < initiatingOrder.getWantAssetId()) ? this.tradeData.getTargetAmount() : this.tradeData.getInitiatorAmount(); + + initiatingOrder.setFulfilled(initiatingOrder.getFulfilled().subtract(isNewPricing ? newPricingAmount : tradeData.getInitiatorAmount())); initiatingOrder.setIsFulfilled(Order.isFulfilled(initiatingOrder)); // Set isClosed to false if isFulfilled now false initiatingOrder.setIsClosed(initiatingOrder.getIsFulfilled()); assetRepository.save(initiatingOrder); - OrderData targetOrder = assetRepository.fromOrderId(this.tradeData.getTarget()); - targetOrder.setFulfilled(targetOrder.getFulfilled().subtract(tradeData.getTargetAmount())); + targetOrder.setFulfilled(targetOrder.getFulfilled().subtract(isNewPricing ? newPricingAmount : tradeData.getTargetAmount())); targetOrder.setIsFulfilled(Order.isFulfilled(targetOrder)); // Set isClosed to false if isFulfilled now false targetOrder.setIsClosed(targetOrder.getIsFulfilled()); diff --git a/src/main/java/org/qora/data/asset/OrderData.java b/src/main/java/org/qora/data/asset/OrderData.java index c12903d4..84189f2e 100644 --- a/src/main/java/org/qora/data/asset/OrderData.java +++ b/src/main/java/org/qora/data/asset/OrderData.java @@ -4,7 +4,6 @@ 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; @@ -22,17 +21,13 @@ public class OrderData implements Comparable { @Schema(description = "asset wanted to receive by order creator") private long wantAssetId; - @Schema(description = "amount of \"have\" asset to trade") + @Schema(description = "amount of highest-assetID asset to trade") private BigDecimal amount; - @Schema(name = "return", description = "amount of \"want\" asset to receive") - @XmlElement(name = "return") - private BigDecimal wantAmount; + @Schema(description = "price in lowest-assetID asset / highest-assetID asset") + private BigDecimal price; - @Schema(description = "amount of \"want\" asset to receive per unit of \"have\" asset traded") - private BigDecimal unitPrice; - - @Schema(description = "how much \"have\" asset has traded") + @Schema(description = "how much of \"amount\" has traded") private BigDecimal fulfilled; private long timestamp; @@ -49,24 +44,22 @@ public class OrderData implements Comparable { protected OrderData() { } - public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal fulfilled, BigDecimal wantAmount, - BigDecimal unitPrice, long timestamp, boolean isClosed, boolean isFulfilled) { + public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal fulfilled, BigDecimal price, 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.wantAmount = wantAmount; - this.unitPrice = unitPrice; + this.price = price; this.timestamp = timestamp; this.isClosed = isClosed; this.isFulfilled = isFulfilled; } /** 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); + 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); } // Getters/setters @@ -99,12 +92,8 @@ public class OrderData implements Comparable { this.fulfilled = fulfilled; } - public BigDecimal getWantAmount() { - return this.wantAmount; - } - - public BigDecimal getUnitPrice() { - return this.unitPrice; + public BigDecimal getPrice() { + return this.price; } public long getTimestamp() { @@ -130,7 +119,7 @@ public class OrderData implements Comparable { @Override public int compareTo(OrderData orderData) { // Compare using prices - return this.unitPrice.compareTo(orderData.getUnitPrice()); + return this.price.compareTo(orderData.getPrice()); } } diff --git a/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java b/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java index 208b1489..346c95f9 100644 --- a/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java +++ b/src/main/java/org/qora/data/transaction/CreateAssetOrderTransactionData.java @@ -20,11 +20,10 @@ public class CreateAssetOrderTransactionData extends TransactionData { private long haveAssetId; @Schema(description = "ID of asset wanted to receive by order creator", example = "0") private long wantAssetId; - @Schema(description = "amount of \"have\" asset to trade") + @Schema(description = "amount of highest-assetID asset to trade") private BigDecimal amount; - @Schema(name = "return", description = "amount of \"want\" asset to receive") - @XmlElement(name = "return") - private BigDecimal wantAmount; + @Schema(description = "price in lowest-assetID asset / highest-assetID asset") + private BigDecimal price; // Constructors @@ -34,18 +33,18 @@ public class CreateAssetOrderTransactionData extends TransactionData { } public CreateAssetOrderTransactionData(long timestamp, int txGroupId, byte[] reference, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, - BigDecimal amount, BigDecimal wantAmount, BigDecimal fee, byte[] signature) { + BigDecimal amount, BigDecimal price, 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.wantAmount = wantAmount; + this.price = price; } public CreateAssetOrderTransactionData(long timestamp, int txGroupId, byte[] reference, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, - BigDecimal amount, BigDecimal wantAmount, BigDecimal fee) { - this(timestamp, txGroupId, reference, creatorPublicKey, haveAssetId, wantAssetId, amount, wantAmount, fee, null); + BigDecimal amount, BigDecimal price, BigDecimal fee) { + this(timestamp, txGroupId, reference, creatorPublicKey, haveAssetId, wantAssetId, amount, price, fee, null); } // Getters/Setters @@ -62,8 +61,8 @@ public class CreateAssetOrderTransactionData extends TransactionData { return this.amount; } - public BigDecimal getWantAmount() { - return this.wantAmount; + public BigDecimal getPrice() { + return this.price; } // 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 b97b03fa..37bcac38 100644 --- a/src/main/java/org/qora/repository/AssetRepository.java +++ b/src/main/java/org/qora/repository/AssetRepository.java @@ -1,5 +1,6 @@ package org.qora.repository; +import java.math.BigDecimal; import java.util.List; import org.qora.data.asset.AssetData; @@ -44,6 +45,8 @@ public interface AssetRepository { return getOpenOrders(haveAssetId, wantAssetId, null, null, null); } + public List getOpenOrdersForTrading(long haveAssetId, long wantAssetId, BigDecimal minimumPrice) throws DataException; + public List getAggregatedOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException; public List getAccountsOrders(byte[] publicKey, Boolean optIsClosed, Boolean optIsFulfilled, Integer limit, Integer offset, Boolean reverse) diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java index 6d6a8174..bd23232e 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, want_amount, unit_price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE asset_order_id = ?", + "SELECT creator, have_asset_id, want_asset_id, amount, fulfilled, price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE asset_order_id = ?", orderId)) { if (resultSet == null) return null; @@ -202,13 +202,12 @@ public class HSQLDBAssetRepository implements AssetRepository { long wantAssetId = resultSet.getLong(3); BigDecimal amount = resultSet.getBigDecimal(4); BigDecimal fulfilled = resultSet.getBigDecimal(5); - 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); + 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); - return new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, wantAmount, unitPrice, timestamp, isClosed, isFulfilled); + return new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, price, timestamp, isClosed, isFulfilled); } catch (SQLException e) { throw new DataException("Unable to fetch asset order from repository", e); } @@ -217,8 +216,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, 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"; + 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"; if (reverse != null && reverse) sql += " DESC"; sql += ", ordered"; @@ -237,14 +236,13 @@ public class HSQLDBAssetRepository implements AssetRepository { byte[] orderId = resultSet.getBytes(2); BigDecimal amount = resultSet.getBigDecimal(3); BigDecimal fulfilled = resultSet.getBigDecimal(4); - BigDecimal wantAmount = resultSet.getBigDecimal(5); - BigDecimal unitPrice = resultSet.getBigDecimal(6); - long timestamp = resultSet.getTimestamp(7, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + BigDecimal price = resultSet.getBigDecimal(5); + long timestamp = resultSet.getTimestamp(6, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); boolean isClosed = false; boolean isFulfilled = false; OrderData order = new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, - wantAmount, unitPrice, timestamp, isClosed, isFulfilled); + price, timestamp, isClosed, isFulfilled); orders.add(order); } while (resultSet.next()); @@ -254,11 +252,53 @@ public class HSQLDBAssetRepository implements AssetRepository { } } + @Override + public List getOpenOrdersForTrading(long haveAssetId, long wantAssetId, BigDecimal minimumPrice) throws DataException { + Object[] bindParams; + String sql = "SELECT creator, asset_order_id, amount, fulfilled, price, ordered FROM AssetOrders " + + "WHERE have_asset_id = ? AND want_asset_id = ? AND NOT is_closed AND NOT is_fulfilled "; + + if (minimumPrice != null) { + sql += "AND price >= ? "; + bindParams = new Object[] {haveAssetId, wantAssetId, minimumPrice}; + } else { + bindParams = new Object[] {haveAssetId, wantAssetId}; + } + + sql += "ORDER BY price DESC, ordered"; + + List orders = new ArrayList(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql, bindParams)) { + if (resultSet == null) + return orders; + + do { + byte[] creatorPublicKey = resultSet.getBytes(1); + 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(); + boolean isClosed = false; + boolean isFulfilled = false; + + OrderData order = new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, + price, timestamp, isClosed, isFulfilled); + orders.add(order); + } while (resultSet.next()); + + return orders; + } catch (SQLException e) { + throw new DataException("Unable to fetch open asset orders for trading from repository", e); + } + } + @Override public List getAggregatedOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException { - 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"; + 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"; if (reverse != null && reverse) sql += " DESC"; sql += HSQLDBRepository.limitOffsetSql(limit, offset); @@ -270,12 +310,12 @@ public class HSQLDBAssetRepository implements AssetRepository { return orders; do { - BigDecimal unitPrice = resultSet.getBigDecimal(1); + BigDecimal price = 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, - BigDecimal.ZERO, unitPrice, timestamp, false, false); + price, timestamp, false, false); orders.add(order); } while (resultSet.next()); @@ -288,7 +328,7 @@ 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, want_amount, unit_price, ordered, is_closed, is_fulfilled " + String sql = "SELECT asset_order_id, have_asset_id, want_asset_id, amount, fulfilled, price, ordered, is_closed, is_fulfilled " + "FROM AssetOrders WHERE creator = ?"; if (optIsClosed != null) sql += " AND is_closed = " + (optIsClosed ? "TRUE" : "FALSE"); @@ -311,14 +351,13 @@ public class HSQLDBAssetRepository implements AssetRepository { long wantAssetId = resultSet.getLong(3); BigDecimal amount = resultSet.getBigDecimal(4); BigDecimal fulfilled = resultSet.getBigDecimal(5); - 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); + 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); - OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, wantAmount, - unitPrice, timestamp, isClosed, isFulfilled); + OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, + price, timestamp, isClosed, isFulfilled); orders.add(order); } while (resultSet.next()); @@ -331,7 +370,7 @@ 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, want_amount, unit_price, ordered, is_closed, is_fulfilled " + 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 = ?"; if (optIsClosed != null) sql += " AND is_closed = " + (optIsClosed ? "TRUE" : "FALSE"); @@ -352,14 +391,13 @@ public class HSQLDBAssetRepository implements AssetRepository { byte[] orderId = resultSet.getBytes(1); BigDecimal amount = resultSet.getBigDecimal(2); BigDecimal fulfilled = resultSet.getBigDecimal(3); - 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); + 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); - OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, wantAmount, - unitPrice, timestamp, isClosed, isFulfilled); + OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, + price, timestamp, isClosed, isFulfilled); orders.add(order); } while (resultSet.next()); @@ -376,8 +414,7 @@ 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("want_amount", orderData.getWantAmount()).bind("unit_price", orderData.getUnitPrice()) - .bind("ordered", new Timestamp(orderData.getTimestamp())) + .bind("price", orderData.getPrice()).bind("ordered", new Timestamp(orderData.getTimestamp())) .bind("is_closed", orderData.getIsClosed()).bind("is_fulfilled", orderData.getIsFulfilled()); try { diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java index 80ad244a..316fb3af 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -7,6 +7,7 @@ import java.sql.Statement; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qora.block.BlockChain; public class HSQLDBDatabaseUpdates { @@ -655,6 +656,35 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE CreateAssetOrderTransactions ALTER COLUMN price RENAME TO want_amount"); break; + case 42: + // New asset pricing #2 + /* + * Use "price" (discard want-amount) but enforce pricing units in one direction + * to avoid all the reciprocal and round issues. + */ + stmt.execute("ALTER TABLE CreateAssetOrderTransactions ALTER COLUMN want_amount RENAME TO price"); + stmt.execute("ALTER TABLE AssetOrders DROP COLUMN want_amount"); + stmt.execute("ALTER TABLE AssetOrders ALTER COLUMN unit_price RENAME TO price"); + stmt.execute("ALTER TABLE AssetOrders ALTER COLUMN price QoraAmount"); + /* + * Normalize any 'old' orders to 'new' pricing. + * We must do this so that requesting open orders can be sorted by price. + */ + // Make sure new asset pricing timestamp (used below) is UTC + stmt.execute("SET TIME ZONE INTERVAL '0:00' HOUR TO MINUTE"); + // Normalize amount/fulfilled to asset with highest assetID, BEFORE price correction + stmt.execute("UPDATE AssetOrders SET amount = amount * price, fulfilled = fulfilled * price " + + "WHERE ordered < timestamp(" + BlockChain.getInstance().getNewAssetPricingTimestamp() + ") " + + "AND have_asset_id < want_asset_id"); + // Normalize price into lowest-assetID/highest-assetID price-pair, e.g. QORA/asset100 + // Note: HSQLDB uses BigDecimal's dividend.divide(divisor, RoundingMode.DOWN) too + stmt.execute("UPDATE AssetOrders SET price = CAST(1 AS QoraAmount) / price " + + "WHERE ordered < timestamp(" + BlockChain.getInstance().getNewAssetPricingTimestamp() + ") " + + "AND have_asset_id < want_asset_id"); + // Revert time zone change above + stmt.execute("SET TIME ZONE LOCAL"); + 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 517691a9..1dc663a8 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, want_amount FROM CreateAssetOrderTransactions WHERE signature = ?", signature)) { + .checkedExecute("SELECT have_asset_id, amount, want_asset_id, price 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 wantAmount = resultSet.getBigDecimal(4); + BigDecimal price = resultSet.getBigDecimal(4); - return new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, creatorPublicKey, haveAssetId, wantAssetId, amount, wantAmount, fee, signature); + return new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, creatorPublicKey, haveAssetId, wantAssetId, amount, price, 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("want_amount", createOrderTransactionData.getWantAmount()); + .bind("want_asset_id", createOrderTransactionData.getWantAssetId()).bind("price", createOrderTransactionData.getPrice()); try { saveHelper.execute(this.repository); diff --git a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java index fac31c54..47d878b4 100644 --- a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java +++ b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java @@ -1,7 +1,6 @@ package org.qora.transaction; import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -83,7 +82,7 @@ public class CreateAssetOrderTransaction extends Transaction { return ValidationResult.NEGATIVE_AMOUNT; // Check price is positive - if (createOrderTransactionData.getWantAmount().compareTo(BigDecimal.ZERO) <= 0) + if (createOrderTransactionData.getPrice().compareTo(BigDecimal.ZERO) <= 0) return ValidationResult.NEGATIVE_PRICE; // Check fee is positive @@ -104,19 +103,63 @@ public class CreateAssetOrderTransaction extends Transaction { Account creator = getCreator(); - // Check reference is correct - if (!Arrays.equals(creator.getLastReference(), createOrderTransactionData.getReference())) - return ValidationResult.INVALID_REFERENCE; + boolean isNewPricing = createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp(); + + BigDecimal committedCost; + BigDecimal maxOtherAmount; + + if (isNewPricing) { + /* + * This is different under "new" pricing scheme as "amount" might be either have-asset or want-asset, + * whichever has the highest assetID. + * + * e.g. with assetID 11 "GOLD": + * haveAssetId: 0 (QORA), wantAssetId: 11 (GOLD), amount: 123 (GOLD), price: 400 (QORA/GOLD) + * stake 49200 QORA, return 123 GOLD + * + * haveAssetId: 11 (GOLD), wantAssetId: 0 (QORA), amount: 123 (GOLD), price: 400 (QORA/GOLD) + * stake 123 GOLD, return 49200 QORA + */ + boolean isAmountWantAsset = haveAssetId < wantAssetId; + + if (isAmountWantAsset) { + // have/commit 49200 QORA, want/return 123 GOLD + committedCost = createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()); + maxOtherAmount = createOrderTransactionData.getAmount(); + } else { + // have/commit 123 GOLD, want/return 49200 QORA + committedCost = createOrderTransactionData.getAmount(); + maxOtherAmount = createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()); + } + } else { + /* + * Under "old" pricing scheme, "amount" is always have-asset and price is always want-per-have. + * + * e.g. with assetID 11 "GOLD": + * haveAssetId: 0 (QORA), wantAssetId: 11 (GOLD), amount: 49200 (QORA), price: 0.00250000 (GOLD/QORA) + * haveAssetId: 11 (GOLD), wantAssetId: 0 (QORA), amount: 123 (GOLD), price: 400 (QORA/GOLD) + */ + committedCost = createOrderTransactionData.getAmount(); + maxOtherAmount = createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()); + } + + // Check amount is integer if amount's asset is not divisible + if (!haveAssetData.getIsDivisible() && committedCost.stripTrailingZeros().scale() > 0) + return ValidationResult.INVALID_AMOUNT; + + // Check total return from fulfilled order would be integer if return's asset is not divisible + if (!wantAssetData.getIsDivisible() && maxOtherAmount.stripTrailingZeros().scale() > 0) + return ValidationResult.INVALID_RETURN; // Check order creator has enough asset balance AFTER removing fee, in case asset is QORA // If asset is QORA then we need to check amount + fee in one go if (haveAssetId == Asset.QORA) { // Check creator has enough funds for amount + fee in QORA - if (creator.getConfirmedBalance(Asset.QORA).compareTo(createOrderTransactionData.getAmount().add(createOrderTransactionData.getFee())) < 0) + if (creator.getConfirmedBalance(Asset.QORA).compareTo(committedCost.add(createOrderTransactionData.getFee())) < 0) return ValidationResult.NO_BALANCE; } else { // Check creator has enough funds for amount in whatever asset - if (creator.getConfirmedBalance(haveAssetId).compareTo(createOrderTransactionData.getAmount()) < 0) + if (creator.getConfirmedBalance(haveAssetId).compareTo(committedCost) < 0) return ValidationResult.NO_BALANCE; // Check creator has enough funds for fee in QORA @@ -126,21 +169,9 @@ public class CreateAssetOrderTransaction extends Transaction { return ValidationResult.NO_BALANCE; } - // Check "have" amount is integer if "have" asset is not divisible - if (!haveAssetData.getIsDivisible() && createOrderTransactionData.getAmount().stripTrailingZeros().scale() > 0) - return ValidationResult.INVALID_AMOUNT; - - // Check total return from fulfilled order would be integer if "want" asset is not divisible - if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp()) { - // "new" asset pricing - if (!wantAssetData.getIsDivisible() && createOrderTransactionData.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; - } + // Check reference is correct + if (!Arrays.equals(creator.getLastReference(), createOrderTransactionData.getReference())) + return ValidationResult.INVALID_REFERENCE; return ValidationResult.OK; } @@ -161,22 +192,9 @@ 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(), wantAmount, unitPrice, + createOrderTransactionData.getWantAssetId(), createOrderTransactionData.getAmount(), createOrderTransactionData.getPrice(), 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 def4303e..5949dab0 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 wanted asset", TransformationType.ASSET_QUANTITY); + layout.add("trade price in (lowest-assetID asset)/(highest-assetID asset)", TransformationType.ASSET_QUANTITY); layout.add("fee", TransformationType.AMOUNT); layout.add("signature", TransformationType.SIGNATURE); } @@ -58,7 +58,6 @@ 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); @@ -87,8 +86,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getAmount(), AMOUNT_LENGTH); - // Under "new" asset pricing, this is actually the want-amount - Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getWantAmount(), AMOUNT_LENGTH); + Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getPrice(), AMOUNT_LENGTH); Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getFee()); @@ -128,7 +126,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getAmount(), AMOUNT_LENGTH); // This is the crucial difference - Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getWantAmount(), FEE_LENGTH); + Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getPrice(), FEE_LENGTH); Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getFee()); diff --git a/src/test/java/org/qora/test/TransactionTests.java b/src/test/java/org/qora/test/TransactionTests.java index efe6b47c..e738cdce 100644 --- a/src/test/java/org/qora/test/TransactionTests.java +++ b/src/test/java/org/qora/test/TransactionTests.java @@ -990,7 +990,7 @@ public class TransactionTests extends Common { TradeData tradeData = trades.get(0); // Check trade has correct values - BigDecimal expectedAmount = amount.divide(originalOrderData.getUnitPrice()).setScale(8); + BigDecimal expectedAmount = amount.divide(originalOrderData.getPrice()).setScale(8); BigDecimal actualAmount = tradeData.getTargetAmount(); assertTrue(expectedAmount.compareTo(actualAmount) == 0); diff --git a/src/test/java/org/qora/test/assets/GranularityTests.java b/src/test/java/org/qora/test/assets/GranularityTests.java new file mode 100644 index 00000000..d8d75d8e --- /dev/null +++ b/src/test/java/org/qora/test/assets/GranularityTests.java @@ -0,0 +1,85 @@ +package org.qora.test.assets; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qora.asset.Order; +import org.qora.repository.DataException; +import org.qora.test.common.Common; + +public class GranularityTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @After + public void afterTest() throws DataException { + Common.orphanCheck(); + } + + /** + * Check granularity adjustment values. + */ + @Test + public void testGranularities() { + // Price 1/12 is rounded down to 0.08333333. + // To keep [divisible] amount * 0.08333333 to nearest 0.00000001 then amounts need to be multiples of 1.00000000. + testGranularity(true, true, "1", "12", "1"); + + // Any amount * 12 will be valid for divisible asset so granularity is 0.00000001 + testGranularity(true, true, "12", "1", "0.00000001"); + + // Price 1/10 is 0.10000000. + // To keep amount * 0.1 to nearest 1 then amounts need to be multiples of 10. + testGranularity(false, false, "1", "10", "10"); + + // Price is 50307/123 which is 409 + // Any [indivisible] amount * 409 will be valid for divisible asset to granularity is 1 + testGranularity(false, false, "50307", "123", "1"); + + // Price 1/800 is 0.00125000 + // Amounts are indivisible so must be integer. + // Return-amounts are divisible and can be fractional. + // So even though amount needs to be multiples of 1.00000000, + // return-amount will always end up being valid. + // Thus at price 0.00125000 we expect granularity to be 1 + testGranularity(false, true, "1", "800", "1"); + + // Price 1/800 is 0.00125000 + // Amounts are divisible so can be fractional. + // Return-amounts are indivisible so must be integer. + // So even though amount can be multiples of 0.00000001, + // return-amount needs to be multiples of 1.00000000 + // Thus at price 0.00125000 we expect granularity to be 800 + testGranularity(true, false, "1", "800", "800"); + + // Price 800 + // Amounts are indivisible so must be integer. + // Return-amounts are divisible so can be fractional. + // So even though amount needs to be multiples of 1.00000000, + // return-amount will always end up being valid. + // Thus at price 800 we expect granularity to be 1 + testGranularity(false, true, "800", "1", "1"); + + // Price 800 + // Amounts are divisible so can be fractional. + // Return-amounts are indivisible so must be integer. + // So even though amount can be multiples of 0.00000001, + // return-amount needs to be multiples of 1.00000000 + // Thus at price 800 we expect granularity to be 0.00125000 + testGranularity(true, false, "800", "1", "0.00125000"); + } + + private void testGranularity(boolean isAmountAssetDivisible, boolean isReturnAssetDivisible, String dividend, String divisor, String expectedGranularity) { + final BigDecimal price = new BigDecimal(dividend).setScale(8).divide(new BigDecimal(divisor).setScale(8), RoundingMode.DOWN); + + BigDecimal granularity = Order.calculateAmountGranularity(isAmountAssetDivisible, isReturnAssetDivisible, price); + assertEqualBigDecimals("Granularity incorrect", new BigDecimal(expectedGranularity), granularity); + } + +} diff --git a/src/test/java/org/qora/test/assets/MixedPricingTests.java b/src/test/java/org/qora/test/assets/MixedPricingTests.java new file mode 100644 index 00000000..4a9d709b --- /dev/null +++ b/src/test/java/org/qora/test/assets/MixedPricingTests.java @@ -0,0 +1,27 @@ +package org.qora.test.assets; + +import org.junit.After; +import org.junit.Before; +import org.qora.repository.DataException; +import org.qora.test.common.Common; + +public class MixedPricingTests extends Common{ + + @Before + public void beforeTest() throws DataException { + Common.useSettings("test-settings-old-asset.json"); + } + + @After + public void afterTest() throws DataException { + Common.orphanCheck(); + } + + /** + * Check order matching between 'old' pricing order and 'new' pricing order. + *

+ * In this test, the order created under 'old' pricing scheme has + * "amount" in have-asset? + */ + +} diff --git a/src/test/java/org/qora/test/assets/NewTradingTests.java b/src/test/java/org/qora/test/assets/NewTradingTests.java new file mode 100644 index 00000000..1be2d56d --- /dev/null +++ b/src/test/java/org/qora/test/assets/NewTradingTests.java @@ -0,0 +1,349 @@ +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.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.math.RoundingMode; +import java.util.Map; + +public class NewTradingTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @After + public void afterTest() throws DataException { + Common.orphanCheck(); + } + + @Test + public void testSimple() throws DataException { + final BigDecimal testAmount = BigDecimal.valueOf(24L).setScale(8); + final BigDecimal price = BigDecimal.valueOf(2L).setScale(8); + final BigDecimal qoraAmount = BigDecimal.valueOf(48L).setScale(8); + + // amounts are in test-asset + // prices are in qora/test + + final BigDecimal aliceAmount = testAmount; + final BigDecimal alicePrice = price; + + final BigDecimal bobAmount = testAmount; + final BigDecimal bobPrice = price; + + final BigDecimal aliceCommitment = testAmount; + final BigDecimal bobCommitment = qoraAmount; + + final BigDecimal aliceReturn = qoraAmount; + final BigDecimal bobReturn = testAmount; + + // alice (target) order: have 'testAmount' test, want qora @ 'price' qora/test (commits testAmount test) + // bob (initiating) order: have qora, want 'testAmount' test @ 'price' qora/test (commits testAmount*price = qoraAmount) + // Alice should be -testAmount, +qoraAmount + // Bob should be -qoraAmount, +testAmount + + AssetUtils.genericTradeTest(AssetUtils.testAssetId, Asset.QORA, aliceAmount, alicePrice, bobAmount, bobPrice, aliceCommitment, bobCommitment, aliceReturn, bobReturn); + } + + /** + * 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 NewTradingTests#testOldNonExactFraction + * @see NewTradingTests#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); + final BigDecimal price = qoraAmount.divide(atnlAmount, RoundingMode.DOWN); + + // amounts are in ATNL + // prices are in qora/ATNL + + final BigDecimal aliceAmount = atnlAmount; + final BigDecimal alicePrice = price; + + final BigDecimal bobAmount = atnlAmount; + final BigDecimal bobPrice = price; + + final BigDecimal aliceCommitment = atnlAmount; + final BigDecimal bobCommitment = qoraAmount; + + final BigDecimal aliceReturn = qoraAmount; + final BigDecimal bobReturn = atnlAmount; + + AssetUtils.genericTradeTest(atnlAssetId, Asset.QORA, aliceAmount, alicePrice, bobAmount, bobPrice, aliceCommitment, bobCommitment, aliceReturn, bobReturn); + } + + /** + * 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 indivisible asset + ragsAssetId = AssetUtils.issueAsset(repository, "alice", "rags", 1000000L, false); + + // Issue another indivisible asset + richesAssetId = AssetUtils.issueAsset(repository, "bob", "riches", 1000000L, false); + } + + // "amount" will be in riches, "price" will be in rags/riches + + final BigDecimal ragsAmount = BigDecimal.valueOf(50307L).setScale(8); + final BigDecimal richesAmount = BigDecimal.valueOf(123L).setScale(8); + + final BigDecimal price = ragsAmount.divide(richesAmount, RoundingMode.DOWN); + final BigDecimal two = BigDecimal.valueOf(2L); + + final BigDecimal aliceAmount = richesAmount.multiply(two).setScale(8); + final BigDecimal alicePrice = price; + final BigDecimal aliceCommitment = aliceAmount.multiply(alicePrice).setScale(8); // rags + + final BigDecimal bobAmount = richesAmount; + final BigDecimal bobPrice = price; + final BigDecimal bobCommitment = bobAmount; // riches + + final BigDecimal aliceReturn = bobAmount; // riches + final BigDecimal bobReturn = bobAmount.multiply(alicePrice).setScale(8); // rags + + AssetUtils.genericTradeTest(ragsAssetId, richesAssetId, aliceAmount, alicePrice, bobAmount, bobPrice, aliceCommitment, bobCommitment, aliceReturn, bobReturn); + } + + /** + * Check partial matching of indivisible amounts (new pricing). + *

+ * Assume both "rags" and "riches" assets are indivisible. + * + * Alice places an order: + * Have rags, want riches, amount 3 riches, price 1 rags/riches + * + * Alice has 1 * 3 = 3 rags subtracted from their rags balance. + * + * Bob places an order: + * Have riches, want rags, amount 8 riches, price 0.25 rags/riches + * + * Bob has 8 riches subtracted from their riches balance. + * Bob expects at least 8 * 0.25 = 2 rags if his order fully completes. + * + * Alice is offering more rags for riches than Bob expects. + * So Alice's order is a match for Bob's, and Alice's order price is used. + * + * Bob wants to trade 8 riches, but Alice only wants to trade 3 riches, + * so the matched amount is 3 riches. + * + * Bob gains 3 * 1 = 3 rags and Alice gains 3 riches. + * Alice's order has 0 riches left (fully completed). + * + * Bob's order has 8 - 3 = 5 riches left. + * + * At Bob's order's price of 0.25 rags/riches, + * it would take 1.25 rags to complete the rest of Bob's order. + * But rags are indivisible so this can't happen at that price. + * + * However, someone could buy at a better price, e.g. 0.4 rags/riches, + * trading 2 rags for 5 riches. + * + * Or Bob could cancel the rest of his order and be refunded 5 riches. + */ + @Test + public void testPartialIndivisible() throws DataException { + // Issue some indivisible assets + long ragsAssetId; + long richesAssetId; + try (Repository repository = RepositoryManager.getRepository()) { + // Issue indivisible asset + ragsAssetId = AssetUtils.issueAsset(repository, "alice", "rags", 1000000L, false); + + // Issue another indivisible asset + richesAssetId = AssetUtils.issueAsset(repository, "bob", "riches", 1000000L, false); + } + + // "amount" will be in riches, "price" will be in rags/riches + + final BigDecimal aliceAmount = new BigDecimal("3").setScale(8); + final BigDecimal alicePrice = new BigDecimal("1").setScale(8); + final BigDecimal aliceCommitment = aliceAmount.multiply(alicePrice).setScale(8); // rags + + final BigDecimal bobAmount = new BigDecimal("8").setScale(8); + final BigDecimal bobPrice = new BigDecimal("0.25").setScale(8); + final BigDecimal bobCommitment = bobAmount; // riches + + final BigDecimal aliceReturn = aliceAmount; // riches + final BigDecimal bobReturn = aliceAmount.multiply(alicePrice).setScale(8); + + AssetUtils.genericTradeTest(ragsAssetId, richesAssetId, aliceAmount, alicePrice, bobAmount, bobPrice, aliceCommitment, bobCommitment, aliceReturn, bobReturn); + } + + /** + * Check matching of orders with prices that + * would have had reciprocals that can't be represented in floating binary. + *

+ * For example, sell 2 TEST for 24 OTHER so + * unit price is 2 / 24 or 0.08333333(recurring) TEST/OTHER. + *

+ * But although price is rounded down to 0.08333333, + * the price is the same for both sides. + *

+ * Traded amounts are expected to be 24 OTHER + * and 1.99999992 TEST. + */ + @Test + public void testNonExactFraction() throws DataException { + final BigDecimal aliceAmount = new BigDecimal("24.00000000").setScale(8); + final BigDecimal alicePrice = new BigDecimal("0.08333333").setScale(8); + final BigDecimal aliceCommitment = new BigDecimal("1.99999992").setScale(8); + + final BigDecimal bobAmount = new BigDecimal("24.00000000").setScale(8); + final BigDecimal bobPrice = new BigDecimal("0.08333333").setScale(8); + final BigDecimal bobCommitment = new BigDecimal("24.00000000").setScale(8); + + // Expected traded amounts + final BigDecimal aliceReturn = new BigDecimal("24.00000000").setScale(8); // other + final BigDecimal bobReturn = new BigDecimal("1.99999992").setScale(8); // test + + long otherAssetId; + try (Repository repository = RepositoryManager.getRepository()) { + otherAssetId = AssetUtils.issueAsset(repository, "bob", "other", 5000L, true); + } + + AssetUtils.genericTradeTest(AssetUtils.testAssetId, otherAssetId, aliceAmount, alicePrice, bobAmount, bobPrice, aliceCommitment, bobCommitment, aliceReturn, bobReturn); + } + + /** + * Check that better prices are used in preference when matching orders. + */ + @Test + public void testPriceImprovement() throws DataException { + final BigDecimal initialTestAssetAmount = new BigDecimal("24.00000000").setScale(8); + + final BigDecimal basePrice = new BigDecimal("1.00000000").setScale(8); + final BigDecimal betterPrice = new BigDecimal("2.10000000").setScale(8); + final BigDecimal bestPrice = new BigDecimal("2.40000000").setScale(8); + + final BigDecimal minimalPrice = new BigDecimal("0.00000001").setScale(8); + final BigDecimal matchingTestAssetAmount = new BigDecimal("12.00000000").setScale(8); + + try (Repository repository = RepositoryManager.getRepository()) { + Map> initialBalances = AccountUtils.getBalances(repository, Asset.QORA, AssetUtils.testAssetId); + + // Create 'better' initial order + byte[] bobOrderId = AssetUtils.createOrder(repository, "bob", Asset.QORA, AssetUtils.testAssetId, initialTestAssetAmount, betterPrice); + + // Create 'best' initial - surrounded by other orders so price improvement code should re-order results + byte[] chloeOrderId = AssetUtils.createOrder(repository, "chloe", Asset.QORA, AssetUtils.testAssetId, initialTestAssetAmount, bestPrice); + + // Create 'base' initial order + byte[] dilbertOrderId = AssetUtils.createOrder(repository, "dilbert", Asset.QORA, AssetUtils.testAssetId, initialTestAssetAmount, basePrice); + + // Create matching order + byte[] aliceOrderId = AssetUtils.createOrder(repository, "alice", AssetUtils.testAssetId, Asset.QORA, matchingTestAssetAmount, minimalPrice); + + // 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) + BigDecimal matchedQoraAmount = matchingTestAssetAmount.multiply(bestPrice).setScale(8, RoundingMode.DOWN); + BigDecimal tradedTestAssetAmount = matchingTestAssetAmount; + + // Alice Qora + expectedBalance = initialBalances.get("alice").get(Asset.QORA).add(matchedQoraAmount); + AccountUtils.assertBalance(repository, "alice", Asset.QORA, expectedBalance); + + // Alice test asset + expectedBalance = initialBalances.get("alice").get(AssetUtils.testAssetId).subtract(matchingTestAssetAmount); + AccountUtils.assertBalance(repository, "alice", AssetUtils.testAssetId, expectedBalance); + + // Bob Qora + expectedBalance = initialBalances.get("bob").get(Asset.QORA).subtract(initialTestAssetAmount.multiply(betterPrice).setScale(8, RoundingMode.DOWN)); + AccountUtils.assertBalance(repository, "bob", Asset.QORA, expectedBalance); + + // Bob test asset + expectedBalance = initialBalances.get("bob").get(AssetUtils.testAssetId); + AccountUtils.assertBalance(repository, "bob", AssetUtils.testAssetId, expectedBalance); + + // Chloe Qora + expectedBalance = initialBalances.get("chloe").get(Asset.QORA).subtract(initialTestAssetAmount.multiply(bestPrice).setScale(8, RoundingMode.DOWN)); + AccountUtils.assertBalance(repository, "chloe", Asset.QORA, expectedBalance); + + // Chloe test asset + expectedBalance = initialBalances.get("chloe").get(AssetUtils.testAssetId).add(tradedTestAssetAmount); + AccountUtils.assertBalance(repository, "chloe", AssetUtils.testAssetId, expectedBalance); + + // Dilbert Qora + expectedBalance = initialBalances.get("dilbert").get(Asset.QORA).subtract(initialTestAssetAmount.multiply(basePrice).setScale(8, RoundingMode.DOWN)); + AccountUtils.assertBalance(repository, "dilbert", Asset.QORA, expectedBalance); + + // Dilbert test asset + expectedBalance = initialBalances.get("dilbert").get(AssetUtils.testAssetId); + AccountUtils.assertBalance(repository, "dilbert", AssetUtils.testAssetId, expectedBalance); + + // Check orders + OrderData aliceOrderData = repository.getAssetRepository().fromOrderId(aliceOrderId); + OrderData bobOrderData = repository.getAssetRepository().fromOrderId(bobOrderId); + OrderData chloeOrderData = repository.getAssetRepository().fromOrderId(chloeOrderId); + OrderData dilbertOrderData = repository.getAssetRepository().fromOrderId(dilbertOrderId); + + // Alice's fulfilled + Common.assertEqualBigDecimals("Alice's order's fulfilled amount incorrect", tradedTestAssetAmount, aliceOrderData.getFulfilled()); + + // Bob's fulfilled should be zero + Common.assertEqualBigDecimals("Bob's order should be totally unfulfilled", BigDecimal.ZERO, bobOrderData.getFulfilled()); + + // Chloe's fulfilled + Common.assertEqualBigDecimals("Chloe's order's fulfilled amount incorrect", tradedTestAssetAmount, chloeOrderData.getFulfilled()); + + // Dilbert's fulfilled should be zero + Common.assertEqualBigDecimals("Dilbert's order should be totally unfulfilled", BigDecimal.ZERO, dilbertOrderData.getFulfilled()); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/qora/test/assets/OldTradingTests.java b/src/test/java/org/qora/test/assets/OldTradingTests.java new file mode 100644 index 00000000..a2449d60 --- /dev/null +++ b/src/test/java/org/qora/test/assets/OldTradingTests.java @@ -0,0 +1,215 @@ +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.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 OldTradingTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useSettings("test-settings-old-asset.json"); + } + + @After + public void afterTest() throws DataException { + Common.orphanCheck(); + } + + /** + * 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 indivisible asset + asset112Id = AssetUtils.issueAsset(repository, "alice", "RUB.iPLZ", 999999999999L, false); + + // Issue another indivisible 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); + + AssetUtils.genericTradeTest(asset113Id, asset112Id, asset113Amount, asset112Price, asset112Amount, asset113Price, asset113Amount, asset112Amount, asset112Matched, asset113Matched); + + // 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); + AccountUtils.assertBalance(repository, "alice", asset113Id, expectedBalance); + + // Alice asset 112 + expectedBalance = initialBalances.get("alice").get(asset112Id).add(asset112Matched2); + AccountUtils.assertBalance(repository, "alice", asset112Id, expectedBalance); + + BigDecimal expectedFulfilled = asset113Matched2; + BigDecimal actualFulfilled = repository.getAssetRepository().fromOrderId(furtherOrderId).getFulfilled(); + assertEqualBigDecimals("Order fulfilled incorrect", expectedFulfilled, actualFulfilled); + } + } + + /** + * 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 aliceAmount = new BigDecimal("24.00000000").setScale(8); + final BigDecimal alicePrice = new BigDecimal("0.08333333").setScale(8); + + final BigDecimal bobAmount = new BigDecimal("2.00000000").setScale(8); + final BigDecimal bobPrice = new BigDecimal("12.00000000").setScale(8); + + final BigDecimal aliceCommitment = aliceAmount; + final BigDecimal bobCommitment = bobAmount; + + // Due to rounding these are the expected traded amounts. + final BigDecimal aliceReturn = new BigDecimal("1.99999992").setScale(8); + final BigDecimal bobReturn = new BigDecimal("24.00000000").setScale(8); + + AssetUtils.genericTradeTest(AssetUtils.testAssetId, Asset.QORA, aliceAmount, alicePrice, bobAmount, bobPrice, aliceCommitment, bobCommitment, aliceReturn, bobReturn); + } + + /** + * 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. + + // Target order 2jMinWSBjxaLnQvhcEoWGs2JSdX7qbwxMTZenQXXhjGYDHCJDL6EjXPz5VXYuUfZM5LvRNNbcaeBbM6Xhb4tN53g + // Creator was QZyuTa3ygjThaPRhrCp1BW4R5Sed6uAGN8 at 2014-10-23 11:14:42.525000+0:00 + // Have: 150000 [ATFunding], Price: 1.7000000 QORA + + // Initiating 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 aliceAmount = new BigDecimal("150000").setScale(8); + final BigDecimal alicePrice = new BigDecimal("1.70000000").setScale(8); + + final BigDecimal bobAmount = new BigDecimal("2.00000000").setScale(8); + final BigDecimal bobPrice = new BigDecimal("0.58000000").setScale(8); + + final BigDecimal aliceCommitment = aliceAmount; + final BigDecimal bobCommitment = bobAmount; + + final BigDecimal aliceReturn = new BigDecimal("1.99999985").setScale(8); + final BigDecimal bobReturn = new BigDecimal("1.17647050").setScale(8); + + AssetUtils.genericTradeTest(AssetUtils.testAssetId, Asset.QORA, aliceAmount, alicePrice, bobAmount, bobPrice, aliceCommitment, bobCommitment, aliceReturn, bobReturn); + } + + /** + * 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. + + // Target order 3jinKPHEak9xrjeYtCaE1PawwRZeRkhYA6q4A7sqej7f3jio8WwXwXpfLWVZkPQ3h6cVdwPhcDFNgbbrBXcipHee + // Creator was QiGx93L9rNHSNWCY1bJnQTPwB3nhxYTCUj at 2015-06-10 20:31:44.840000+0:00 + // Have: 1000000 [BitBTC], Price: 0.90000000 [Bitcoin] + + // Initiating 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 aliceAmount = new BigDecimal("1000000").setScale(8); + final BigDecimal alicePrice = new BigDecimal("0.90000000").setScale(8); + + final BigDecimal bobAmount = new BigDecimal("73251").setScale(8); + final BigDecimal bobPrice = new BigDecimal("1.01000000").setScale(8); + + final BigDecimal aliceCommitment = aliceAmount; + final BigDecimal bobCommitment = bobAmount; + + final BigDecimal aliceReturn = new BigDecimal("73250.99992674").setScale(8); + final BigDecimal bobReturn = new BigDecimal("81389.99991860").setScale(8); + + AssetUtils.genericTradeTest(Asset.QORA, AssetUtils.testAssetId, aliceAmount, alicePrice, bobAmount, bobPrice, aliceCommitment, bobCommitment, aliceReturn, bobReturn); + } + +} \ No newline at end of file diff --git a/src/test/java/org/qora/test/assets/TradingTests.java b/src/test/java/org/qora/test/assets/TradingTests.java deleted file mode 100644 index 7662ecfa..00000000 --- a/src/test/java/org/qora/test/assets/TradingTests.java +++ /dev/null @@ -1,441 +0,0 @@ -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 index 63e6012a..3e2d3989 100644 --- a/src/test/java/org/qora/test/common/AccountUtils.java +++ b/src/test/java/org/qora/test/common/AccountUtils.java @@ -29,4 +29,10 @@ public class AccountUtils { return balances; } + public static void assertBalance(Repository repository, String accountName, long assetId, BigDecimal expectedBalance) throws DataException { + BigDecimal actualBalance = Common.getTestAccount(repository, accountName).getConfirmedBalance(assetId); + + Common.assertEqualBigDecimals(String.format("Test account '%s' asset %d balance incorrect", accountName, assetId), expectedBalance, actualBalance); + } + } diff --git a/src/test/java/org/qora/test/common/AssetUtils.java b/src/test/java/org/qora/test/common/AssetUtils.java index 9cca4eb1..543f0f20 100644 --- a/src/test/java/org/qora/test/common/AssetUtils.java +++ b/src/test/java/org/qora/test/common/AssetUtils.java @@ -1,8 +1,13 @@ package org.qora.test.common; +import static org.junit.Assert.assertNotNull; + import java.math.BigDecimal; +import java.util.Map; import org.qora.account.PrivateKeyAccount; +import org.qora.block.BlockChain; +import org.qora.data.asset.OrderData; import org.qora.data.transaction.CreateAssetOrderTransactionData; import org.qora.data.transaction.IssueAssetTransactionData; import org.qora.data.transaction.TransactionData; @@ -10,6 +15,7 @@ import org.qora.data.transaction.TransferAssetTransactionData; import org.qora.group.Group; import org.qora.repository.DataException; import org.qora.repository.Repository; +import org.qora.repository.RepositoryManager; public class AssetUtils { @@ -42,18 +48,71 @@ public class AssetUtils { TransactionUtils.signAndForge(repository, transactionData, fromAccount); } - public static byte[] createOrder(Repository repository, String accountName, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal wantAmount) throws DataException { + public static byte[] createOrder(Repository repository, String accountName, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal price) 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); + TransactionData transactionData = new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, account.getPublicKey(), haveAssetId, wantAssetId, amount, price, fee); TransactionUtils.signAndForge(repository, transactionData, account); return repository.getAssetRepository().getAccountsOrders(account.getPublicKey(), null, null, null, null, true).get(0).getOrderId(); } + public static void genericTradeTest(long haveAssetId, long wantAssetId, + BigDecimal aliceAmount, BigDecimal alicePrice, + BigDecimal bobAmount, BigDecimal bobPrice, + BigDecimal aliceCommitment, BigDecimal bobCommitment, + BigDecimal aliceReturn, BigDecimal bobReturn) throws DataException { + try (Repository repository = RepositoryManager.getRepository()) { + Map> initialBalances = AccountUtils.getBalances(repository, haveAssetId, wantAssetId); + + // Create target order + byte[] targetOrderId = createOrder(repository, "alice", haveAssetId, wantAssetId, aliceAmount, alicePrice); + + // Create initiating order + byte[] initiatingOrderId = createOrder(repository, "bob", wantAssetId, haveAssetId, bobAmount, bobPrice); + + // Check balances to check expected outcome + BigDecimal expectedBalance; + + // Alice have asset + expectedBalance = initialBalances.get("alice").get(haveAssetId).subtract(aliceCommitment); + AccountUtils.assertBalance(repository, "alice", haveAssetId, expectedBalance); + + // Alice want asset + expectedBalance = initialBalances.get("alice").get(wantAssetId).add(aliceReturn); + AccountUtils.assertBalance(repository, "alice", wantAssetId, expectedBalance); + + // Bob want asset + expectedBalance = initialBalances.get("bob").get(wantAssetId).subtract(bobCommitment); + AccountUtils.assertBalance(repository, "bob", wantAssetId, expectedBalance); + + // Bob have asset + expectedBalance = initialBalances.get("bob").get(haveAssetId).add(bobReturn); + AccountUtils.assertBalance(repository, "bob", haveAssetId, expectedBalance); + + // Check orders + BigDecimal expectedFulfilled; + + // Check matching order + OrderData targetOrderData = repository.getAssetRepository().fromOrderId(targetOrderId); + OrderData initiatingOrderData = repository.getAssetRepository().fromOrderId(initiatingOrderId); + + boolean isNewPricing = initiatingOrderData.getTimestamp() > BlockChain.getInstance().getNewAssetPricingTimestamp(); + BigDecimal newPricingAmount = (initiatingOrderData.getHaveAssetId() < initiatingOrderData.getWantAssetId()) ? bobReturn : aliceReturn; + + assertNotNull("matching order missing", initiatingOrderData); + expectedFulfilled = isNewPricing ? newPricingAmount : aliceReturn; + Common.assertEqualBigDecimals(String.format("Bob's order \"fulfilled\" incorrect"), expectedFulfilled, initiatingOrderData.getFulfilled()); + + // Check initial order + assertNotNull("initial order missing", targetOrderData); + expectedFulfilled = isNewPricing ? newPricingAmount : bobReturn; + Common.assertEqualBigDecimals(String.format("Alice's order \"fulfilled\" incorrect"), expectedFulfilled, targetOrderData.getFulfilled()); + } + } + } diff --git a/src/test/java/org/qora/test/common/Common.java b/src/test/java/org/qora/test/common/Common.java index 578e4ddf..a19684c1 100644 --- a/src/test/java/org/qora/test/common/Common.java +++ b/src/test/java/org/qora/test/common/Common.java @@ -13,6 +13,8 @@ import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.bitcoinj.core.Base58; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; @@ -34,12 +36,29 @@ import org.qora.settings.Settings; public class Common { + 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); + } + + private static final Logger LOGGER = LogManager.getLogger(Common.class); + 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"; + static { + // 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 List initialAssets; private static List initialGroups; private static List initialBalances; @@ -49,19 +68,6 @@ public class Common { 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")); @@ -82,6 +88,7 @@ public class Common { closeRepository(); // Load/check settings, which potentially sets up blockchain config, etc. + LOGGER.debug(String.format("Using setting file: %s", settingsFilename)); URL testSettingsUrl = Common.class.getClassLoader().getResource(settingsFilename); assertNotNull("Test settings JSON file not found", testSettingsUrl); Settings.fileInstance(testSettingsUrl.getPath()); @@ -112,6 +119,8 @@ public class Common { /** Orphan back to genesis block and compare initial snapshot. */ public static void orphanCheck() throws DataException { + LOGGER.debug("Orphaning back to genesis block"); + try (final Repository repository = RepositoryManager.getRepository()) { // Orphan back to genesis block while (repository.getBlockRepository().getBlockchainHeight() > 1) { diff --git a/src/test/java/org/qora/test/common/ObjectUtils.java b/src/test/java/org/qora/test/common/ObjectUtils.java new file mode 100644 index 00000000..4acd035c --- /dev/null +++ b/src/test/java/org/qora/test/common/ObjectUtils.java @@ -0,0 +1,21 @@ +package org.qora.test.common; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; + +public class ObjectUtils { + + public static Object callMethod(Object obj, String methodName, Object... args) { + Method[] methods = obj.getClass().getDeclaredMethods(); + + Method foundMethod = Arrays.stream(methods).filter(method -> method.getName().equals(methodName)).findFirst().get(); + + try { + return foundMethod.invoke(obj, args); + } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + throw new RuntimeException("method call failed", e); + } + } + +} diff --git a/src/test/resources/log4j2-test.properties b/src/test/resources/log4j2-test.properties index a74d53ed..5c8723e1 100644 --- a/src/test/resources/log4j2-test.properties +++ b/src/test/resources/log4j2-test.properties @@ -14,6 +14,10 @@ logger.hsqldb.level = warn logger.hsqldbDebug.name = org.qora.repository.hsqldb.HSQLDBRepository logger.hsqldbDebug.level = debug +# Unit test debugging +logger.tests.name = org.qora.test +logger.tests.level = debug + # Suppress extraneous Jersey warning logger.jerseyInject.name = org.glassfish.jersey.internal.inject.Providers logger.jerseyInject.level = error