diff --git a/src/main/java/org/qora/api/model/AggregatedOrder.java b/src/main/java/org/qora/api/model/AggregatedOrder.java
index 29865361..3a56acfd 100644
--- a/src/main/java/org/qora/api/model/AggregatedOrder.java
+++ b/src/main/java/org/qora/api/model/AggregatedOrder.java
@@ -20,9 +20,9 @@ public class AggregatedOrder {
this.orderData = orderData;
}
- @XmlElement(name = "price")
- public BigDecimal getPrice() {
- return this.orderData.getPrice();
+ @XmlElement(name = "unitPrice")
+ public BigDecimal getUnitPrice() {
+ return this.orderData.getUnitPrice();
}
@XmlElement(name = "unfulfilled")
diff --git a/src/main/java/org/qora/asset/Asset.java b/src/main/java/org/qora/asset/Asset.java
index b509d605..11a2137c 100644
--- a/src/main/java/org/qora/asset/Asset.java
+++ b/src/main/java/org/qora/asset/Asset.java
@@ -20,7 +20,7 @@ public class Asset {
public static final int MAX_DESCRIPTION_SIZE = 4000;
public static final int MAX_DATA_SIZE = 400000;
- public static final long MAX_DIVISIBLE_QUANTITY = 10_000_000_000L;
+ public static final long MAX_DIVISIBLE_QUANTITY = 10_000_000_000L; // but also to 8 decimal places
public static final long MAX_INDIVISIBLE_QUANTITY = 1_000_000_000_000_000_000L;
// Properties
diff --git a/src/main/java/org/qora/asset/Order.java b/src/main/java/org/qora/asset/Order.java
index ee7d1c3a..0f169206 100644
--- a/src/main/java/org/qora/asset/Order.java
+++ b/src/main/java/org/qora/asset/Order.java
@@ -6,6 +6,7 @@ import java.math.RoundingMode;
import java.util.Arrays;
import java.util.List;
+import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qora.account.Account;
@@ -22,6 +23,11 @@ import com.google.common.hash.HashCode;
public class Order {
+ /** BigDecimal scale for representing unit price in asset orders. */
+ public static final int BD_PRICE_SCALE = 38;
+ /** BigDecimal scale for representing unit price in asset orders in storage context. */
+ public static final int BD_PRICE_STORAGE_SCALE = BD_PRICE_SCALE + 10;
+
private static final Logger LOGGER = LogManager.getLogger(Order.class);
// Properties
@@ -59,33 +65,41 @@ public class Order {
return Order.isFulfilled(this.orderData);
}
- public BigDecimal calculateAmountGranularity(AssetData haveAssetData, AssetData wantAssetData, OrderData theirOrderData) {
- // 100 million to scale BigDecimal.setScale(8) fractional amounts into integers, essentially 1e8
- BigInteger multiplier = BigInteger.valueOf(100_000_000L);
+ /**
+ * Returns want-asset granularity/unit-size given price.
+ *
+ * @param theirPrice
+ * @return unit price of want asset
+ */
+ public static BigDecimal calculateAmountGranularity(AssetData haveAssetData, AssetData wantAssetData, BigDecimal theirPrice) {
+ // Multiplier to scale BigDecimal.setScale(8) fractional amounts into integers, essentially 1e8
+ BigInteger multiplier = BigInteger.valueOf(1_0000_0000L);
// Calculate the minimum increment at which I can buy using greatest-common-divisor
- BigInteger haveAmount = BigInteger.ONE.multiply(multiplier);
- BigInteger priceAmount = theirOrderData.getPrice().multiply(new BigDecimal(multiplier)).toBigInteger();
- BigInteger gcd = haveAmount.gcd(priceAmount);
+ BigInteger haveAmount = multiplier; // 1 unit (* multiplier)
+ //BigInteger wantAmount = BigDecimal.valueOf(100_000_000L).setScale(Asset.BD_SCALE).divide(theirOrderData.getUnitPrice(), RoundingMode.DOWN).toBigInteger();
+ BigInteger wantAmount = theirPrice.movePointRight(8).toBigInteger();
+
+ BigInteger gcd = haveAmount.gcd(wantAmount);
haveAmount = haveAmount.divide(gcd);
- priceAmount = priceAmount.divide(gcd);
+ wantAmount = wantAmount.divide(gcd);
// Calculate GCD in combination with divisibility
if (wantAssetData.getIsDivisible())
haveAmount = haveAmount.multiply(multiplier);
if (haveAssetData.getIsDivisible())
- priceAmount = priceAmount.multiply(multiplier);
+ wantAmount = wantAmount.multiply(multiplier);
- gcd = haveAmount.gcd(priceAmount);
+ gcd = haveAmount.gcd(wantAmount);
- // Calculate the increment at which we have to buy
- BigDecimal increment = new BigDecimal(haveAmount.divide(gcd));
+ // Calculate the granularity at which we have to buy
+ BigDecimal granularity = new BigDecimal(haveAmount.divide(gcd));
if (wantAssetData.getIsDivisible())
- increment = increment.divide(new BigDecimal(multiplier));
+ granularity = granularity.movePointLeft(8);
// Return
- return increment;
+ return granularity;
}
// Navigation
@@ -96,6 +110,32 @@ public class Order {
// Processing
+ private void logOrder(String orderPrefix, boolean isMatchingNotInitial, OrderData orderData) throws DataException {
+ // Avoid calculations if possible
+ if (LOGGER.getLevel().isMoreSpecificThan(Level.DEBUG))
+ return;
+
+ final boolean isNewPricing = orderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp();
+ final String weThey = isMatchingNotInitial ? "They" : "We";
+
+ AssetData haveAssetData = this.repository.getAssetRepository().fromAssetId(orderData.getHaveAssetId());
+ AssetData wantAssetData = this.repository.getAssetRepository().fromAssetId(orderData.getWantAssetId());
+
+ LOGGER.debug(String.format("%s %s", orderPrefix, HashCode.fromBytes(orderData.getOrderId()).toString()));
+
+ LOGGER.trace(String.format("%s have: %s %s", weThey, orderData.getAmount().stripTrailingZeros().toPlainString(), haveAssetData.getName()));
+
+ if (isNewPricing) {
+ LOGGER.trace(String.format("%s want: %s %s (@ %s %s each)", weThey,
+ orderData.getWantAmount().stripTrailingZeros().toPlainString(), wantAssetData.getName(),
+ orderData.getUnitPrice().toPlainString(), haveAssetData.getName()));
+ } else {
+ LOGGER.trace(String.format("%s want at least %s %s per %s (minimum %s %s total)", weThey,
+ orderData.getUnitPrice().toPlainString(), wantAssetData.getName(), haveAssetData.getName(),
+ orderData.getWantAmount().stripTrailingZeros().toPlainString(), wantAssetData.getName()));
+ }
+ }
+
public void process() throws DataException {
AssetRepository assetRepository = this.repository.getAssetRepository();
@@ -111,64 +151,57 @@ public class Order {
// Save this order into repository so it's available for matching, possibly by itself
this.repository.getAssetRepository().save(this.orderData);
- boolean isOurOrderV2 = this.orderData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp();
+ /*
+ * Our order example ("old"):
+ *
+ * haveAssetId=[GOLD], amount=10,000, wantAssetId=[QORA], price=0.002
+ *
+ * This translates to "we have 10,000 GOLD and want to buy QORA at a price of 0.002 QORA per GOLD"
+ *
+ * So if our order matched, we'd end up with 10,000 * 0.002 = 20 QORA, essentially costing 1/0.002 = 500 GOLD each.
+ *
+ * So 500 GOLD [each] is our (selling) unit price and want-amount is 20 QORA.
+ *
+ * Another example (showing representation error and hence move to "new" pricing):
+ * haveAssetId=[QORA], amount=24, wantAssetId=[GOLD], price=0.08333333
+ * unit price: 12.00000048 GOLD, want-amount: 1.9999992 GOLD
+ */
- // Attempt to match orders
- LOGGER.debug("Processing our order " + HashCode.fromBytes(this.orderData.getOrderId()).toString());
- LOGGER.trace("We have: " + this.orderData.getAmount().toPlainString() + " " + haveAssetData.getName());
-
- if (isOurOrderV2)
- LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName());
- else
- LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName());
+ /*
+ * Our order example ("new"):
+ *
+ * haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), want-amount=20
+ *
+ * This translates to "we have 10,000 GOLD and want to buy 20 QORA"
+ *
+ * So if our order matched, we'd end up with 20 QORA, essentially costing 10,000 / 20 = 500 GOLD each.
+ *
+ * So 500 GOLD [each] is our (selling) unit price and want-amount is 20 QORA.
+ *
+ * Another example:
+ * haveAssetId=[QORA], amount=24, wantAssetId=[GOLD], want-amount=2
+ * unit price: 12.00000000 GOLD, want-amount: 2.00000000 GOLD
+ */
+ logOrder("Processing our order", false, this.orderData);
// Fetch corresponding open orders that might potentially match, hence reversed want/have assetId args.
// Returned orders are sorted with lowest "price" first.
List orders = assetRepository.getOpenOrders(wantAssetId, haveAssetId);
LOGGER.trace("Open orders fetched from repository: " + orders.size());
- /*
- * Our order example (V1):
- *
- * haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), price=0.002
- *
- * This translates to "we have 10,000 GOLD and want to buy QORA at a price of 0.002 QORA per GOLD"
- *
- * So if our order matched, we'd end up with 10,000 * 0.002 = 20 QORA, essentially costing 1/0.002 = 500 GOLD each.
- *
- * So 500 GOLD [each] is our "buyingPrice".
- *
- * Our order example (V2):
- *
- * haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), price=20
- *
- * This translates to "we have 10,000 GOLD and want to buy 20 QORA"
- *
- * So if our order matched, we'd end up with 20 QORA, essentially costing 10,000 / 20 = 500 GOLD each.
- *
- * So 500 GOLD [each] is our "buyingPrice".
- */
- BigDecimal ourAmount = this.orderData.getAmount();
- BigDecimal ourPrice;
- if (isOurOrderV2)
- ourPrice = ourAmount.divide(this.orderData.getPrice(), RoundingMode.DOWN);
- else
- ourPrice = this.orderData.getPrice();
+ if (orders.isEmpty())
+ return;
+
+ // Attempt to match orders
+
+ BigDecimal ourUnitPrice = this.orderData.getUnitPrice();
+ LOGGER.trace(String.format("Our minimum price: %s %s per %s", ourUnitPrice.toPlainString(), wantAssetData.getName(), haveAssetData.getName()));
for (OrderData theirOrderData : orders) {
- LOGGER.trace("Considering order " + HashCode.fromBytes(theirOrderData.getOrderId()).toString());
- // Note swapped use of have/want asset data as this is from 'their' perspective.
- LOGGER.trace("They have: " + theirOrderData.getAmount().toPlainString() + " " + wantAssetData.getName());
-
- boolean isTheirOrderV2 = theirOrderData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp();
-
- if (isTheirOrderV2)
- LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName());
- else
- LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName() + " per " + wantAssetData.getName());
+ logOrder("Considering order", true, theirOrderData);
/*
- * Potential matching order example (V1):
+ * Potential matching order example ("old"):
*
* haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=486
*
@@ -176,9 +209,11 @@ public class Order {
*
* So if their order matched, they'd end up with 40 * 486 = 19,440 GOLD, essentially costing 1/486 = 0.00205761 QORA each.
*
- * So 0.00205761 QORA [each] is their "buyingPrice".
- *
- * Potential matching order example (V2):
+ * So 0.00205761 QORA [each] is their unit price and maximum amount is 19,440 GOLD.
+ */
+
+ /*
+ * Potential matching order example ("new"):
*
* haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=19,440
*
@@ -186,69 +221,124 @@ public class Order {
*
* So if their order matched, they'd end up with 19,440 GOLD, essentially costing 40 / 19,440 = 0.00205761 QORA each.
*
- * So 0.00205761 QORA [each] is their "buyingPrice".
+ * So 0.00205761 QORA [each] is their unit price and maximum amount is 19,440 GOLD.
*/
- // Round down otherwise their buyingPrice would be better than advertised and cause issues
- BigDecimal theirBuyingPrice;
+ boolean isTheirOrderNewAssetPricing = theirOrderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp();
- if (isTheirOrderV2)
- theirBuyingPrice = theirOrderData.getAmount().divide(theirOrderData.getPrice(), RoundingMode.DOWN);
- else
- theirBuyingPrice = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN);
- LOGGER.trace("theirBuyingPrice: " + theirBuyingPrice.toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName());
+ BigDecimal theirBuyingPrice = BigDecimal.ONE.setScale(isTheirOrderNewAssetPricing ? Order.BD_PRICE_STORAGE_SCALE : 8).divide(theirOrderData.getUnitPrice(), RoundingMode.DOWN);
+ LOGGER.trace(String.format("Their price: %s %s per %s", theirBuyingPrice.toPlainString(), wantAssetData.getName(), haveAssetData.getName()));
- // If their buyingPrice is less than what we're willing to pay then we're done as prices only get worse as we iterate through list of orders
- if (theirBuyingPrice.compareTo(ourPrice) < 0)
+ // If their buyingPrice is less than what we're willing to accept then we're done as prices only get worse as we iterate through list of orders
+ if (theirBuyingPrice.compareTo(ourUnitPrice) < 0)
break;
// Calculate how many want-asset we could buy at their price
- BigDecimal ourAmountLeft = this.getAmountLeft().multiply(theirBuyingPrice).setScale(8, RoundingMode.DOWN);
- LOGGER.trace("ourAmountLeft (max we could buy at their price): " + ourAmountLeft.toPlainString() + " " + wantAssetData.getName());
+ BigDecimal ourMaxWantAmount = this.getAmountLeft().multiply(theirBuyingPrice).setScale(8, RoundingMode.DOWN);
+ LOGGER.trace("ourMaxWantAmount (max we could buy at their price): " + ourMaxWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName());
- // How many want-asset is remaining available in this order
- BigDecimal theirAmountLeft = Order.getAmountLeft(theirOrderData);
- LOGGER.trace("theirAmountLeft (max amount remaining in order): " + theirAmountLeft.toPlainString() + " " + wantAssetData.getName());
+ if (isTheirOrderNewAssetPricing) {
+ ourMaxWantAmount = ourMaxWantAmount.max(this.getAmountLeft().divide(theirOrderData.getUnitPrice(), RoundingMode.DOWN).setScale(8, RoundingMode.DOWN));
+ LOGGER.trace("ourMaxWantAmount (max we could buy at their price) using inverted calculation: " + ourMaxWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName());
+ }
+
+ // How many want-asset is remaining available in their order. (have-asset amount from their perspective).
+ BigDecimal theirWantAmountLeft = Order.getAmountLeft(theirOrderData);
+ LOGGER.trace("theirWantAmountLeft (max amount remaining in their order): " + theirWantAmountLeft.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName());
// So matchable want-asset amount is the minimum of above two values
- BigDecimal matchedAmount = ourAmountLeft.min(theirAmountLeft);
- LOGGER.trace("matchedAmount: " + matchedAmount.toPlainString() + " " + wantAssetData.getName());
+ BigDecimal matchedWantAmount = ourMaxWantAmount.min(theirWantAmountLeft);
+ LOGGER.trace("matchedWantAmount: " + matchedWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName());
// If we can't buy anything then try another order
- if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0)
+ if (matchedWantAmount.compareTo(BigDecimal.ZERO) <= 0)
continue;
- // Calculate amount granularity based on both assets' divisibility
- BigDecimal increment = this.calculateAmountGranularity(haveAssetData, wantAssetData, theirOrderData);
- LOGGER.trace("increment (want-asset amount granularity): " + increment.toPlainString() + " " + wantAssetData.getName());
- matchedAmount = matchedAmount.subtract(matchedAmount.remainder(increment));
- LOGGER.trace("matchedAmount adjusted for granularity: " + matchedAmount.toPlainString() + " " + wantAssetData.getName());
+ // We can skip granularity if theirWantAmountLeft is an [integer] multiple of matchedWantAmount as that obviously fits
+ if (!isTheirOrderNewAssetPricing || theirWantAmountLeft.remainder(matchedWantAmount).compareTo(BigDecimal.ZERO) > 0) {
+ // Not an integer multiple so do granularity check
+
+ // Calculate amount granularity based on both assets' divisibility
+ BigDecimal wantGranularity = calculateAmountGranularity(haveAssetData, wantAssetData, theirOrderData.getUnitPrice());
+ LOGGER.trace("wantGranularity (want-asset amount granularity): " + wantGranularity.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName());
+
+ // Reduce matched amount (if need be) to fit granularity
+ matchedWantAmount = matchedWantAmount.subtract(matchedWantAmount.remainder(wantGranularity));
+ LOGGER.trace("matchedWantAmount adjusted for granularity: " + matchedWantAmount.stripTrailingZeros().toPlainString() + " " + wantAssetData.getName());
+ }
// If we can't buy anything then try another order
- if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0)
+ if (matchedWantAmount.compareTo(BigDecimal.ZERO) <= 0)
continue;
+ // Safety checks
+ if (matchedWantAmount.compareTo(Order.getAmountLeft(theirOrderData)) > 0) {
+ Account participant = new PublicKeyAccount(this.repository, theirOrderData.getCreatorPublicKey());
+
+ String message = String.format("Refusing to trade more %s then requested %s [assetId %d] for %s",
+ matchedWantAmount.toPlainString(), Order.getAmountLeft(theirOrderData).toPlainString(), wantAssetId, participant.getAddress());
+ LOGGER.error(message);
+ throw new DataException(message);
+ }
+
+ if (!wantAssetData.getIsDivisible() && matchedWantAmount.stripTrailingZeros().scale() > 0) {
+ Account participant = new PublicKeyAccount(this.repository, theirOrderData.getCreatorPublicKey());
+
+ String message = String.format("Refusing to trade fractional %s [indivisible assetId %d] for %s",
+ matchedWantAmount.toPlainString(), wantAssetId, participant.getAddress());
+ LOGGER.error(message);
+ throw new DataException(message);
+ }
+
// Trade can go ahead!
// Calculate the total cost to us, in have-asset, based on their price
- BigDecimal tradePrice;
- if (isTheirOrderV2)
- tradePrice = matchedAmount.divide(theirBuyingPrice).setScale(8); // XXX is this right?
- else
- tradePrice = matchedAmount.multiply(theirOrderData.getPrice()).setScale(8);
- LOGGER.trace("tradePrice ('want' trade agreed): " + tradePrice.toPlainString() + " " + haveAssetData.getName());
+ BigDecimal haveAmountTraded;
+
+ if (isTheirOrderNewAssetPricing) {
+ BigDecimal theirTruncatedPrice = theirBuyingPrice.setScale(Order.BD_PRICE_SCALE, RoundingMode.DOWN);
+ BigDecimal ourTruncatedPrice = ourUnitPrice.setScale(Order.BD_PRICE_SCALE, RoundingMode.DOWN);
+
+ // Safety check
+ if (theirTruncatedPrice.compareTo(ourTruncatedPrice) < 0) {
+ String message = String.format("Refusing to trade at worse price %s than our minimum of %s",
+ theirTruncatedPrice.toPlainString(), ourTruncatedPrice.toPlainString(), creator.getAddress());
+ LOGGER.error(message);
+ throw new DataException(message);
+ }
+
+ haveAmountTraded = matchedWantAmount.divide(theirTruncatedPrice, RoundingMode.DOWN).setScale(8, RoundingMode.DOWN);
+ } else {
+ haveAmountTraded = matchedWantAmount.multiply(theirOrderData.getUnitPrice()).setScale(8, RoundingMode.DOWN);
+ }
+ LOGGER.trace("haveAmountTraded: " + haveAmountTraded.stripTrailingZeros().toPlainString() + " " + haveAssetData.getName());
+
+ // Safety checks
+ if (haveAmountTraded.compareTo(this.getAmountLeft()) > 0) {
+ String message = String.format("Refusing to trade more %s then requested %s [assetId %d] for %s",
+ haveAmountTraded.toPlainString(), this.getAmountLeft().toPlainString(), haveAssetId, creator.getAddress());
+ LOGGER.error(message);
+ throw new DataException(message);
+ }
+
+ if (!haveAssetData.getIsDivisible() && haveAmountTraded.stripTrailingZeros().scale() > 0) {
+ String message = String.format("Refusing to trade fractional %s [indivisible assetId %d] for %s",
+ haveAmountTraded.toPlainString(), haveAssetId, creator.getAddress());
+ LOGGER.error(message);
+ throw new DataException(message);
+ }
// Construct trade
- TradeData tradeData = new TradeData(this.orderData.getOrderId(), theirOrderData.getOrderId(), matchedAmount, tradePrice,
+ TradeData tradeData = new TradeData(this.orderData.getOrderId(), theirOrderData.getOrderId(), matchedWantAmount, haveAmountTraded,
this.orderData.getTimestamp());
// Process trade, updating corresponding orders in repository
Trade trade = new Trade(this.repository, tradeData);
trade.process();
// Update our order in terms of fulfilment, etc. but do not save into repository as that's handled by Trade above
- this.orderData.setFulfilled(this.orderData.getFulfilled().add(tradePrice));
- LOGGER.trace("Updated our order's fulfilled amount to: " + this.orderData.getFulfilled().toPlainString() + " " + haveAssetData.getName());
- LOGGER.trace("Our order's amount remaining: " + this.getAmountLeft().toPlainString() + " " + haveAssetData.getName());
+ this.orderData.setFulfilled(this.orderData.getFulfilled().add(haveAmountTraded));
+ LOGGER.trace("Updated our order's fulfilled amount to: " + this.orderData.getFulfilled().stripTrailingZeros().toPlainString() + " " + haveAssetData.getName());
+ LOGGER.trace("Our order's amount remaining: " + this.getAmountLeft().stripTrailingZeros().toPlainString() + " " + haveAssetData.getName());
// Continue on to process other open orders if we still have amount left to match
if (this.getAmountLeft().compareTo(BigDecimal.ZERO) <= 0)
diff --git a/src/main/java/org/qora/block/BlockChain.java b/src/main/java/org/qora/block/BlockChain.java
index a6e0b664..a858b07a 100644
--- a/src/main/java/org/qora/block/BlockChain.java
+++ b/src/main/java/org/qora/block/BlockChain.java
@@ -74,7 +74,8 @@ public class BlockChain {
votingTimestamp,
arbitraryTimestamp,
powfixTimestamp,
- v2Timestamp;
+ v2Timestamp,
+ newAssetPricingTimestamp;
}
/** Map of which blockchain features are enabled when (height/timestamp) */
@@ -251,6 +252,10 @@ public class BlockChain {
return featureTriggers.get("v2Timestamp");
}
+ public long getNewAssetPricingTimestamp() {
+ return featureTriggers.get("newAssetPricingTimestamp");
+ }
+
/** Validate blockchain config read from JSON */
private void validateConfig() {
if (this.genesisInfo == null) {
diff --git a/src/main/java/org/qora/data/asset/OrderData.java b/src/main/java/org/qora/data/asset/OrderData.java
index b5797078..4794c1f3 100644
--- a/src/main/java/org/qora/data/asset/OrderData.java
+++ b/src/main/java/org/qora/data/asset/OrderData.java
@@ -24,8 +24,11 @@ public class OrderData implements Comparable {
@Schema(description = "amount of \"have\" asset to trade")
private BigDecimal amount;
+ @Schema(description = "amount of \"want\" asset to receive")
+ private BigDecimal wantAmount;
+
@Schema(description = "amount of \"want\" asset to receive per unit of \"have\" asset traded")
- private BigDecimal price;
+ private BigDecimal unitPrice;
@Schema(description = "how much \"have\" asset has traded")
private BigDecimal fulfilled;
@@ -44,22 +47,24 @@ public class OrderData implements Comparable {
protected OrderData() {
}
- public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal fulfilled, BigDecimal price,
- long timestamp, boolean isClosed, boolean isFulfilled) {
+ public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal fulfilled, BigDecimal wantAmount,
+ BigDecimal unitPrice, long timestamp, boolean isClosed, boolean isFulfilled) {
this.orderId = orderId;
this.creatorPublicKey = creatorPublicKey;
this.haveAssetId = haveAssetId;
this.wantAssetId = wantAssetId;
this.amount = amount;
this.fulfilled = fulfilled;
- this.price = price;
+ this.wantAmount = wantAmount;
+ this.unitPrice = unitPrice;
this.timestamp = timestamp;
this.isClosed = isClosed;
this.isFulfilled = isFulfilled;
}
- public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal price, long timestamp) {
- this(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, BigDecimal.ZERO.setScale(8), price, timestamp, false, false);
+ /** Constructs OrderData using typical deserialized network data */
+ public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal wantAmount, BigDecimal unitPrice, long timestamp) {
+ this(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, BigDecimal.ZERO.setScale(8), wantAmount, unitPrice, timestamp, false, false);
}
// Getters/setters
@@ -92,8 +97,12 @@ public class OrderData implements Comparable {
this.fulfilled = fulfilled;
}
- public BigDecimal getPrice() {
- return this.price;
+ public BigDecimal getWantAmount() {
+ return this.wantAmount;
+ }
+
+ public BigDecimal getUnitPrice() {
+ return this.unitPrice;
}
public long getTimestamp() {
@@ -119,7 +128,7 @@ public class OrderData implements Comparable {
@Override
public int compareTo(OrderData orderData) {
// Compare using prices
- return this.price.compareTo(orderData.getPrice());
+ return this.unitPrice.compareTo(orderData.getUnitPrice());
}
}
diff --git a/src/main/java/org/qora/repository/AssetRepository.java b/src/main/java/org/qora/repository/AssetRepository.java
index 60328d30..b97b03fa 100644
--- a/src/main/java/org/qora/repository/AssetRepository.java
+++ b/src/main/java/org/qora/repository/AssetRepository.java
@@ -39,7 +39,7 @@ public interface AssetRepository {
public List getOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse) throws DataException;
- // Internal, non-API use
+ /** Returns open orders, ordered by ascending unit price (i.e. best price first), for use by order matching logic. */
public default List getOpenOrders(long haveAssetId, long wantAssetId) throws DataException {
return getOpenOrders(haveAssetId, wantAssetId, null, null, null);
}
diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java
index 4d5fd243..6d6a8174 100644
--- a/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java
+++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBAssetRepository.java
@@ -192,7 +192,7 @@ public class HSQLDBAssetRepository implements AssetRepository {
@Override
public OrderData fromOrderId(byte[] orderId) throws DataException {
try (ResultSet resultSet = this.repository.checkedExecute(
- "SELECT creator, have_asset_id, want_asset_id, amount, fulfilled, price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE asset_order_id = ?",
+ "SELECT creator, have_asset_id, want_asset_id, amount, fulfilled, want_amount, unit_price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE asset_order_id = ?",
orderId)) {
if (resultSet == null)
return null;
@@ -202,13 +202,13 @@ public class HSQLDBAssetRepository implements AssetRepository {
long wantAssetId = resultSet.getLong(3);
BigDecimal amount = resultSet.getBigDecimal(4);
BigDecimal fulfilled = resultSet.getBigDecimal(5);
- BigDecimal price = resultSet.getBigDecimal(6);
- long timestamp = resultSet.getTimestamp(7, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
- boolean isClosed = resultSet.getBoolean(8);
- boolean isFulfilled = resultSet.getBoolean(9);
+ BigDecimal wantAmount = resultSet.getBigDecimal(6);
+ BigDecimal unitPrice = resultSet.getBigDecimal(7);
+ long timestamp = resultSet.getTimestamp(8, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
+ boolean isClosed = resultSet.getBoolean(9);
+ boolean isFulfilled = resultSet.getBoolean(10);
- return new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, price,
- timestamp, isClosed, isFulfilled);
+ return new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, wantAmount, unitPrice, timestamp, isClosed, isFulfilled);
} catch (SQLException e) {
throw new DataException("Unable to fetch asset order from repository", e);
}
@@ -217,8 +217,8 @@ public class HSQLDBAssetRepository implements AssetRepository {
@Override
public List getOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset,
Boolean reverse) throws DataException {
- String sql = "SELECT creator, asset_order_id, amount, fulfilled, price, ordered FROM AssetOrders "
- + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE ORDER BY price";
+ String sql = "SELECT creator, asset_order_id, amount, fulfilled, want_amount, unit_price, ordered FROM AssetOrders "
+ + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE ORDER BY unit_price";
if (reverse != null && reverse)
sql += " DESC";
sql += ", ordered";
@@ -237,13 +237,14 @@ public class HSQLDBAssetRepository implements AssetRepository {
byte[] orderId = resultSet.getBytes(2);
BigDecimal amount = resultSet.getBigDecimal(3);
BigDecimal fulfilled = resultSet.getBigDecimal(4);
- BigDecimal price = resultSet.getBigDecimal(5);
- long timestamp = resultSet.getTimestamp(6, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
+ BigDecimal wantAmount = resultSet.getBigDecimal(5);
+ BigDecimal unitPrice = resultSet.getBigDecimal(6);
+ long timestamp = resultSet.getTimestamp(7, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
boolean isClosed = false;
boolean isFulfilled = false;
OrderData order = new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled,
- price, timestamp, isClosed, isFulfilled);
+ wantAmount, unitPrice, timestamp, isClosed, isFulfilled);
orders.add(order);
} while (resultSet.next());
@@ -256,8 +257,8 @@ public class HSQLDBAssetRepository implements AssetRepository {
@Override
public List getAggregatedOpenOrders(long haveAssetId, long wantAssetId, Integer limit, Integer offset,
Boolean reverse) throws DataException {
- String sql = "SELECT price, SUM(amount - fulfilled), MAX(ordered) FROM AssetOrders "
- + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE GROUP BY price ORDER BY price";
+ String sql = "SELECT unit_price, SUM(amount - fulfilled), MAX(ordered) FROM AssetOrders "
+ + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE GROUP BY unit_price ORDER BY unit_price";
if (reverse != null && reverse)
sql += " DESC";
sql += HSQLDBRepository.limitOffsetSql(limit, offset);
@@ -269,12 +270,12 @@ public class HSQLDBAssetRepository implements AssetRepository {
return orders;
do {
- BigDecimal price = resultSet.getBigDecimal(1);
+ BigDecimal unitPrice = resultSet.getBigDecimal(1);
BigDecimal totalUnfulfilled = resultSet.getBigDecimal(2);
long timestamp = resultSet.getTimestamp(3).getTime();
OrderData order = new OrderData(null, null, haveAssetId, wantAssetId, totalUnfulfilled, BigDecimal.ZERO,
- price, timestamp, false, false);
+ BigDecimal.ZERO, unitPrice, timestamp, false, false);
orders.add(order);
} while (resultSet.next());
@@ -287,7 +288,8 @@ public class HSQLDBAssetRepository implements AssetRepository {
@Override
public List getAccountsOrders(byte[] publicKey, Boolean optIsClosed, Boolean optIsFulfilled,
Integer limit, Integer offset, Boolean reverse) throws DataException {
- String sql = "SELECT asset_order_id, have_asset_id, want_asset_id, amount, fulfilled, price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE creator = ?";
+ String sql = "SELECT asset_order_id, have_asset_id, want_asset_id, amount, fulfilled, want_amount, unit_price, ordered, is_closed, is_fulfilled "
+ + "FROM AssetOrders WHERE creator = ?";
if (optIsClosed != null)
sql += " AND is_closed = " + (optIsClosed ? "TRUE" : "FALSE");
if (optIsFulfilled != null)
@@ -309,13 +311,14 @@ public class HSQLDBAssetRepository implements AssetRepository {
long wantAssetId = resultSet.getLong(3);
BigDecimal amount = resultSet.getBigDecimal(4);
BigDecimal fulfilled = resultSet.getBigDecimal(5);
- BigDecimal price = resultSet.getBigDecimal(6);
- long timestamp = resultSet.getTimestamp(7, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
- boolean isClosed = resultSet.getBoolean(8);
- boolean isFulfilled = resultSet.getBoolean(9);
+ BigDecimal wantAmount = resultSet.getBigDecimal(6);
+ BigDecimal unitPrice = resultSet.getBigDecimal(7);
+ long timestamp = resultSet.getTimestamp(8, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
+ boolean isClosed = resultSet.getBoolean(9);
+ boolean isFulfilled = resultSet.getBoolean(10);
- OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, price,
- timestamp, isClosed, isFulfilled);
+ OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, wantAmount,
+ unitPrice, timestamp, isClosed, isFulfilled);
orders.add(order);
} while (resultSet.next());
@@ -328,7 +331,8 @@ public class HSQLDBAssetRepository implements AssetRepository {
@Override
public List getAccountsOrders(byte[] publicKey, long haveAssetId, long wantAssetId, Boolean optIsClosed,
Boolean optIsFulfilled, Integer limit, Integer offset, Boolean reverse) throws DataException {
- String sql = "SELECT asset_order_id, amount, fulfilled, price, ordered, is_closed, is_fulfilled FROM AssetOrders WHERE creator = ? AND have_asset_id = ? AND want_asset_id = ?";
+ String sql = "SELECT asset_order_id, amount, fulfilled, want_amount, unit_price, ordered, is_closed, is_fulfilled "
+ + "FROM AssetOrders WHERE creator = ? AND have_asset_id = ? AND want_asset_id = ?";
if (optIsClosed != null)
sql += " AND is_closed = " + (optIsClosed ? "TRUE" : "FALSE");
if (optIsFulfilled != null)
@@ -348,13 +352,14 @@ public class HSQLDBAssetRepository implements AssetRepository {
byte[] orderId = resultSet.getBytes(1);
BigDecimal amount = resultSet.getBigDecimal(2);
BigDecimal fulfilled = resultSet.getBigDecimal(3);
- BigDecimal price = resultSet.getBigDecimal(4);
- long timestamp = resultSet.getTimestamp(5, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
- boolean isClosed = resultSet.getBoolean(6);
- boolean isFulfilled = resultSet.getBoolean(7);
+ BigDecimal wantAmount = resultSet.getBigDecimal(4);
+ BigDecimal unitPrice = resultSet.getBigDecimal(5);
+ long timestamp = resultSet.getTimestamp(6, Calendar.getInstance(HSQLDBRepository.UTC)).getTime();
+ boolean isClosed = resultSet.getBoolean(7);
+ boolean isFulfilled = resultSet.getBoolean(8);
- OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, price,
- timestamp, isClosed, isFulfilled);
+ OrderData order = new OrderData(orderId, publicKey, haveAssetId, wantAssetId, amount, fulfilled, wantAmount,
+ unitPrice, timestamp, isClosed, isFulfilled);
orders.add(order);
} while (resultSet.next());
@@ -371,7 +376,8 @@ public class HSQLDBAssetRepository implements AssetRepository {
saveHelper.bind("asset_order_id", orderData.getOrderId()).bind("creator", orderData.getCreatorPublicKey())
.bind("have_asset_id", orderData.getHaveAssetId()).bind("want_asset_id", orderData.getWantAssetId())
.bind("amount", orderData.getAmount()).bind("fulfilled", orderData.getFulfilled())
- .bind("price", orderData.getPrice()).bind("ordered", new Timestamp(orderData.getTimestamp()))
+ .bind("want_amount", orderData.getWantAmount()).bind("unit_price", orderData.getUnitPrice())
+ .bind("ordered", new Timestamp(orderData.getTimestamp()))
.bind("is_closed", orderData.getIsClosed()).bind("is_fulfilled", orderData.getIsFulfilled());
try {
@@ -395,8 +401,9 @@ public class HSQLDBAssetRepository implements AssetRepository {
@Override
public List getTrades(long haveAssetId, long wantAssetId, Integer limit, Integer offset, Boolean reverse)
throws DataException {
- String sql = "SELECT initiating_order_id, target_order_id, AssetTrades.target_amount, AssetTrades.initiator_amount, traded FROM AssetOrders JOIN AssetTrades ON initiating_order_id = asset_order_id "
- + "WHERE have_asset_id = ? AND want_asset_id = ? ORDER BY traded";
+ String sql = "SELECT initiating_order_id, target_order_id, AssetTrades.target_amount, AssetTrades.initiator_amount, traded "
+ + "FROM AssetOrders JOIN AssetTrades ON initiating_order_id = asset_order_id "
+ + "WHERE have_asset_id = ? AND want_asset_id = ? ORDER BY traded";
if (reverse != null && reverse)
sql += " DESC";
sql += HSQLDBRepository.limitOffsetSql(limit, offset);
@@ -497,7 +504,8 @@ public class HSQLDBAssetRepository implements AssetRepository {
@Override
public List getOrdersTrades(byte[] orderId, Integer limit, Integer offset, Boolean reverse)
throws DataException {
- String sql = "SELECT initiating_order_id, target_order_id, target_amount, initiator_amount, traded FROM AssetTrades WHERE initiating_order_id = ? OR target_order_id = ? ORDER BY traded";
+ String sql = "SELECT initiating_order_id, target_order_id, target_amount, initiator_amount, traded "
+ + "FROM AssetTrades WHERE initiating_order_id = ? OR target_order_id = ? ORDER BY traded";
if (reverse != null && reverse)
sql += " DESC";
sql += HSQLDBRepository.limitOffsetSql(limit, offset);
diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java
index a25193d4..75f8cc1f 100644
--- a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java
+++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java
@@ -630,6 +630,31 @@ public class HSQLDBDatabaseUpdates {
stmt.execute("ALTER TABLE UpdateAssetTransactions ALTER COLUMN new_data AssetDataLob");
break;
+ case 41:
+ // New asset pricing
+ /*
+ * We store "unit price" for asset orders but need enough precision to accurately
+ * represent fractional values without loss.
+ * Asset quantities can be up to either 1_000_000_000_000_000_000 (19 digits) if indivisible,
+ * or 10_000_000_000.00000000 (11+8 = 19 digits) if divisible.
+ * Two 19-digit numbers need 38 integer and 38 fractional to cover extremes of unit price.
+ * However, we use another 10 more fractional digits to avoid rounding issues.
+ * 38 integer + 48 fractional gives 86, so: DECIMAL (86, 48)
+ */
+ // Rename price to unit_price to preserve indexes
+ stmt.execute("ALTER TABLE AssetOrders ALTER COLUMN price RENAME TO unit_price");
+ // Adjust precision
+ stmt.execute("ALTER TABLE AssetOrders ALTER COLUMN unit_price DECIMAL(76,48)");
+ // Add want-amount column
+ stmt.execute("ALTER TABLE AssetOrders ADD want_amount QoraAmount BEFORE unit_price");
+ // Calculate want-amount values
+ stmt.execute("UPDATE AssetOrders set want_amount = amount * unit_price");
+ // want-amounts all set, so disallow NULL
+ stmt.execute("ALTER TABLE AssetOrders ALTER COLUMN want_amount SET NOT NULL");
+ // Convert old "price" into buying unit price
+ stmt.execute("UPDATE AssetOrders set unit_price = 1 / unit_price");
+ break;
+
default:
// nothing to do
return false;
diff --git a/src/main/java/org/qora/settings/Settings.java b/src/main/java/org/qora/settings/Settings.java
index e975cf7a..266adfe4 100644
--- a/src/main/java/org/qora/settings/Settings.java
+++ b/src/main/java/org/qora/settings/Settings.java
@@ -93,7 +93,6 @@ public class Settings {
// Tell unmarshaller that there's no JSON root element in the JSON input
unmarshaller.setProperty(UnmarshallerProperties.JSON_INCLUDE_ROOT, false);
-
} catch (JAXBException e) {
LOGGER.error("Unable to process settings file", e);
throw new RuntimeException("Unable to process settings file", e);
diff --git a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java
index cf6192d7..04d1017b 100644
--- a/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java
+++ b/src/main/java/org/qora/transaction/CreateAssetOrderTransaction.java
@@ -1,6 +1,7 @@
package org.qora.transaction;
import java.math.BigDecimal;
+import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -130,12 +131,12 @@ public class CreateAssetOrderTransaction extends Transaction {
return ValidationResult.INVALID_AMOUNT;
// Check total return from fulfilled order would be integer if "want" asset is not divisible
- if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp()) {
- // v2
+ if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp()) {
+ // "new" asset pricing
if (!wantAssetData.getIsDivisible() && createOrderTransactionData.getPrice().stripTrailingZeros().scale() > 0)
return ValidationResult.INVALID_RETURN;
} else {
- // v1
+ // "old" asset pricing
if (!wantAssetData.getIsDivisible()
&& createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()).stripTrailingZeros().scale() > 0)
return ValidationResult.INVALID_RETURN;
@@ -160,9 +161,22 @@ public class CreateAssetOrderTransaction extends Transaction {
// Order Id is transaction's signature
byte[] orderId = createOrderTransactionData.getSignature();
+ BigDecimal wantAmount;
+ BigDecimal unitPrice;
+
+ if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp()) {
+ // "new" asset pricing: want-amount provided, unit price to be calculated
+ wantAmount = createOrderTransactionData.getPrice();
+ unitPrice = wantAmount.setScale(Order.BD_PRICE_STORAGE_SCALE).divide(createOrderTransactionData.getAmount().setScale(Order.BD_PRICE_STORAGE_SCALE), RoundingMode.DOWN);
+ } else {
+ // "old" asset pricing: selling unit price provided, want-amount to be calculated
+ wantAmount = createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice());
+ unitPrice = createOrderTransactionData.getPrice();
+ }
+
// Process the order itself
OrderData orderData = new OrderData(orderId, createOrderTransactionData.getCreatorPublicKey(), createOrderTransactionData.getHaveAssetId(),
- createOrderTransactionData.getWantAssetId(), createOrderTransactionData.getAmount(), createOrderTransactionData.getPrice(),
+ createOrderTransactionData.getWantAssetId(), createOrderTransactionData.getAmount(), wantAmount, unitPrice,
createOrderTransactionData.getTimestamp());
new Order(this.repository, orderData).process();
diff --git a/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java b/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java
index 2cb28685..8597e2af 100644
--- a/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java
+++ b/src/main/java/org/qora/transform/transaction/CreateAssetOrderTransactionTransformer.java
@@ -35,7 +35,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform
layout.add("ID of asset of offer", TransformationType.LONG);
layout.add("ID of asset wanted", TransformationType.LONG);
layout.add("amount of asset on offer", TransformationType.ASSET_QUANTITY);
- layout.add("amount of asset wanted per offered asset", TransformationType.ASSET_QUANTITY);
+ layout.add("amount of wanted asset", TransformationType.ASSET_QUANTITY);
layout.add("fee", TransformationType.AMOUNT);
layout.add("signature", TransformationType.SIGNATURE);
}
@@ -58,6 +58,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform
BigDecimal amount = Serialization.deserializeBigDecimal(byteBuffer, AMOUNT_LENGTH);
+ // Under "new" asset pricing, this is actually the want-amount
BigDecimal price = Serialization.deserializeBigDecimal(byteBuffer, AMOUNT_LENGTH);
BigDecimal fee = Serialization.deserializeBigDecimal(byteBuffer);
@@ -86,6 +87,7 @@ public class CreateAssetOrderTransactionTransformer extends TransactionTransform
Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getAmount(), AMOUNT_LENGTH);
+ // Under "new" asset pricing, this is actually the want-amount
Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getPrice(), AMOUNT_LENGTH);
Serialization.serializeBigDecimal(bytes, createOrderTransactionData.getFee());
diff --git a/src/test/java/org/qora/test/Common.java b/src/test/java/org/qora/test/Common.java
index 38d41ff1..fd14f9f8 100644
--- a/src/test/java/org/qora/test/Common.java
+++ b/src/test/java/org/qora/test/Common.java
@@ -7,6 +7,7 @@ import java.security.Security;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
import org.bitcoinj.core.Base58;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
@@ -33,7 +34,7 @@ public class Common {
public static final String testConnectionUrl = "jdbc:hsqldb:mem:testdb";
// public static final String testConnectionUrl = "jdbc:hsqldb:file:testdb/blockchain;create=true";
- public static final String testSettingsFilename = "test-settings.json";
+ public static final String testSettingsFilename = "test-settings-v2.json";
public static final byte[] v2testPrivateKey = Base58.decode("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6");
public static final byte[] v2testPublicKey = Base58.decode("2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP");
@@ -53,24 +54,48 @@ public class Common {
}
public static Map lastTransactionByAddress;
- public static Map testAccountsByName = new HashMap<>();
+ private static Map testAccountsByName = new HashMap<>();
static {
- testAccountsByName.put("main", new TestAccount("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6"));
- testAccountsByName.put("dummy", new TestAccount("AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot"));
+ testAccountsByName.put("alice", new TestAccount(null, "alice", "A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6"));
+ testAccountsByName.put("bob", new TestAccount(null, "bob", "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot"));
+ testAccountsByName.put("chloe", new TestAccount(null, "chloe", "HqVngdE1AmEyDpfwTZqUdFHB13o4bCmpoTNAKEqki66K"));
+ testAccountsByName.put("dilbert", new TestAccount(null, "dilbert", "Gakhh6Ln4vtBFM88nE9JmDaLBDtUBg51aVFpWfSkyVw5"));
}
- public static PrivateKeyAccount getTestAccount(Repository repository, String name) {
- return new PrivateKeyAccount(repository, testAccountsByName.get(name).getSeed());
+ public static TestAccount getTestAccount(Repository repository, String name) {
+ return new TestAccount(repository, name, testAccountsByName.get(name).getSeed());
+ }
+
+ public static List getTestAccounts(Repository repository) {
+ return testAccountsByName.values().stream().map(account -> new TestAccount(repository, account.accountName, account.getSeed())).collect(Collectors.toList());
+ }
+
+ public static void useSettings(String settingsFilename) throws DataException {
+ closeRepository();
+
+ // Load/check settings, which potentially sets up blockchain config, etc.
+ URL testSettingsUrl = Common.class.getClassLoader().getResource(settingsFilename);
+ assertNotNull("Test settings JSON file not found", testSettingsUrl);
+ Settings.fileInstance(testSettingsUrl.getPath());
+
+ setRepository();
+
+ resetBlockchain();
+ }
+
+ public static void useDefaultSettings() throws DataException {
+ useSettings(testSettingsFilename);
}
public static void resetBlockchain() throws DataException {
BlockChain.validate();
+
lastTransactionByAddress = new HashMap<>();
try (Repository repository = RepositoryManager.getRepository()) {
for (TestAccount account : testAccountsByName.values()) {
List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, account.getAddress(), ConfirmationStatus.BOTH, 1, null, true);
- assertFalse("Test account should have existing transaction", signatures.isEmpty());
+ assertFalse(String.format("Test account '%s' should have existing transaction", account.accountName), signatures.isEmpty());
TransactionData transactionData = repository.getTransactionRepository().fromSignature(signatures.get(0));
lastTransactionByAddress.put(account.getAddress(), transactionData);
diff --git a/src/test/java/org/qora/test/TransactionTests.java b/src/test/java/org/qora/test/TransactionTests.java
index 495e295b..e209d05f 100644
--- a/src/test/java/org/qora/test/TransactionTests.java
+++ b/src/test/java/org/qora/test/TransactionTests.java
@@ -989,7 +989,7 @@ public class TransactionTests extends Common {
TradeData tradeData = trades.get(0);
// Check trade has correct values
- BigDecimal expectedAmount = amount.divide(originalOrderData.getPrice()).setScale(8);
+ BigDecimal expectedAmount = amount.divide(originalOrderData.getUnitPrice()).setScale(8);
BigDecimal actualAmount = tradeData.getTargetAmount();
assertTrue(expectedAmount.compareTo(actualAmount) == 0);
diff --git a/src/test/java/org/qora/test/assets/TradingTests.java b/src/test/java/org/qora/test/assets/TradingTests.java
index bcf00664..f34c53b9 100644
--- a/src/test/java/org/qora/test/assets/TradingTests.java
+++ b/src/test/java/org/qora/test/assets/TradingTests.java
@@ -1,5 +1,6 @@
package org.qora.test.assets;
+import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.qora.asset.Asset;
@@ -7,48 +8,394 @@ import org.qora.repository.DataException;
import org.qora.repository.Repository;
import org.qora.repository.RepositoryManager;
import org.qora.test.Common;
+import org.qora.test.common.AccountUtils;
import org.qora.test.common.AssetUtils;
import static org.junit.Assert.*;
import java.math.BigDecimal;
+import java.util.Map;
public class TradingTests extends Common {
@Before
public void beforeTest() throws DataException {
- Common.resetBlockchain();
+ Common.useDefaultSettings();
}
- /*
- * Check full matching of orders with prices that
- * can't be represented in floating binary.
- *
- * For example, sell 1 GOLD for 12 QORA so
- * price is 1/12 or 0.083...
+ @After
+ public void afterTest() throws DataException {
+ }
+
+ /**
+ * Check matching of indivisible amounts.
+ *
+ * New pricing scheme allows two attempts are calculating matched amount
+ * to reduce partial-match issues caused by rounding and recurring fractional digits:
+ *
+ *
+ * - amount * round_down(1 / unit price)
+ * - round_down(amount / unit price)
+ *
+ * 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:
+ *
+ *
+ * - 24 QORA * (1 / 0.0833333...) = 1.99999999 ATNL
+ * - 24 QORA / 0.08333333.... = 2 ATNL
+ *
+ * The second result is obviously more intuitive as is critical where assets are not divisible,
+ * like ATNL in this test case.
+ *
+ * @see TradingTests#testOldNonExactFraction
+ * @see TradingTests#testNonExactFraction
+ * @throws DataException
*/
@Test
- public void testNonExactFraction() throws DataException {
- final long qoraAmount = 24L;
- final long otherAmount = 2L;
+ public void testMixedDivisibility() throws DataException {
+ // Issue indivisible asset
+ long atnlAssetId;
+ try (Repository repository = RepositoryManager.getRepository()) {
+ // Issue indivisible asset
+ atnlAssetId = AssetUtils.issueAsset(repository, "alice", "ATNL", 100000000L, false);
+ }
- final long transferAmount = 100L;
+ final BigDecimal atnlAmount = BigDecimal.valueOf(2L).setScale(8);
+ final BigDecimal qoraAmount = BigDecimal.valueOf(24L).setScale(8);
+
+ genericTradeTest(atnlAssetId, Asset.QORA, atnlAmount, qoraAmount, qoraAmount, atnlAmount, atnlAmount, qoraAmount);
+ }
+
+ /**
+ * Check matching of indivisible amounts (new pricing).
+ *
+ * Alice is selling twice as much as Bob wants,
+ * but at the same [calculated] unit price,
+ * so Bob's order should fully match.
+ *
+ * However, in legacy/"old" mode, the granularity checks
+ * would prevent this trade.
+ */
+ @Test
+ public void testIndivisible() throws DataException {
+ // Issue some indivisible assets
+ long ragsAssetId;
+ long richesAssetId;
+ try (Repository repository = RepositoryManager.getRepository()) {
+ // Issue indivisble asset
+ ragsAssetId = AssetUtils.issueAsset(repository, "alice", "rags", 12345678L, false);
+
+ // Issue another indivisble asset
+ richesAssetId = AssetUtils.issueAsset(repository, "bob", "riches", 87654321L, false);
+ }
+
+ final BigDecimal ragsAmount = BigDecimal.valueOf(50301L).setScale(8);
+ final BigDecimal richesAmount = BigDecimal.valueOf(123L).setScale(8);
+
+ final BigDecimal two = BigDecimal.valueOf(2L);
+
+ genericTradeTest(ragsAssetId, richesAssetId, ragsAmount.multiply(two), richesAmount.multiply(two), richesAmount, ragsAmount, ragsAmount, richesAmount);
+ }
+
+ /**
+ * Check matching of indivisible amounts.
+ *
+ * We use orders similar to some found in legacy qora1 blockchain
+ * to test for expected results with indivisible assets.
+ *
+ * In addition, although the 3rd "further" order would match up to 999 RUB.iPLZ,
+ * granularity at that price reduces matched amount to 493 RUB.iPLZ.
+ */
+ @Test
+ public void testOldIndivisible() throws DataException {
+ Common.useSettings("test-settings-old-asset.json");
+
+ // Issue some indivisible assets
+ long asset112Id;
+ long asset113Id;
+ try (Repository repository = RepositoryManager.getRepository()) {
+ // Issue indivisble asset
+ asset112Id = AssetUtils.issueAsset(repository, "alice", "RUB.iPLZ", 999999999999L, false);
+
+ // Issue another indivisble asset
+ asset113Id = AssetUtils.issueAsset(repository, "bob", "RU.GZP.V123", 10000L, false);
+ }
+
+ // Transfer some assets so orders can be created
+ try (Repository repository = RepositoryManager.getRepository()) {
+ AssetUtils.transferAsset(repository, "alice", "bob", asset112Id, BigDecimal.valueOf(5000L).setScale(8));
+ AssetUtils.transferAsset(repository, "bob", "alice", asset113Id, BigDecimal.valueOf(5000L).setScale(8));
+ }
+
+ final BigDecimal asset113Amount = new BigDecimal("1000").setScale(8);
+ final BigDecimal asset112Price = new BigDecimal("1.00000000").setScale(8);
+
+ final BigDecimal asset112Amount = new BigDecimal("2000").setScale(8);
+ final BigDecimal asset113Price = new BigDecimal("0.98600000").setScale(8);
+
+ final BigDecimal asset112Matched = new BigDecimal("1000").setScale(8);
+ final BigDecimal asset113Matched = new BigDecimal("1000").setScale(8);
+
+ genericTradeTest(asset113Id, asset112Id, asset113Amount, asset112Price, asset112Amount, asset113Price, asset113Matched, asset112Matched);
+
+ // Further trade
+ final BigDecimal asset113Amount2 = new BigDecimal("986").setScale(8);
+ final BigDecimal asset112Price2 = new BigDecimal("1.00000000").setScale(8);
+
+ final BigDecimal asset112Matched2 = new BigDecimal("500").setScale(8);
+ final BigDecimal asset113Matched2 = new BigDecimal("493").setScale(8);
try (Repository repository = RepositoryManager.getRepository()) {
- // Create initial order
- AssetUtils.createOrder(repository, "main", Asset.QORA, AssetUtils.testAssetId, qoraAmount, otherAmount);
+ Map> initialBalances = AccountUtils.getBalances(repository, asset112Id, asset113Id);
- // Give 100 asset to other account so they can create order
- AssetUtils.transferAsset(repository, "main", "dummy", AssetUtils.testAssetId, transferAmount);
-
- // Create matching order
- AssetUtils.createOrder(repository, "dummy", AssetUtils.testAssetId, Asset.QORA, otherAmount, qoraAmount);
+ // Create further order
+ byte[] furtherOrderId = AssetUtils.createOrder(repository, "alice", asset113Id, asset112Id, asset113Amount2, asset112Price2);
// Check balances to check expected outcome
- BigDecimal actualAmount = Common.getTestAccount(repository, "dummy").getConfirmedBalance(AssetUtils.testAssetId);
- BigDecimal expectedAmount = BigDecimal.valueOf(transferAmount - otherAmount).setScale(8);
- assertTrue("dummy account's asset balance incorrect", actualAmount.compareTo(expectedAmount) == 0);
+ BigDecimal expectedBalance;
+
+ // Alice asset 113
+ expectedBalance = initialBalances.get("alice").get(asset113Id).subtract(asset113Amount2);
+ assertBalance(repository, "alice", asset113Id, expectedBalance);
+
+ // Alice asset 112
+ expectedBalance = initialBalances.get("alice").get(asset112Id).add(asset112Matched2);
+ assertBalance(repository, "alice", asset112Id, expectedBalance);
+
+ BigDecimal expectedFulfilled = asset113Matched2;
+ BigDecimal actualFulfilled = repository.getAssetRepository().fromOrderId(furtherOrderId).getFulfilled();
+ assertTrue(String.format("Order fulfilled incorrect: expected %s, actual %s", expectedFulfilled.toPlainString(), actualFulfilled.toPlainString()),
+ actualFulfilled.compareTo(expectedFulfilled) == 0);
}
}
+ /**
+ * Check full matching of orders with prices that
+ * can't be represented in floating binary.
+ *
+ * For example, sell 1 GOLD for 12 QORA so
+ * price is 1/12 or 0.08333333..., which could
+ * lead to rounding issues or inexact match amounts,
+ * but we counter this using the technique described in
+ * {@link #testMixedDivisibility()}
+ */
+ @Test
+ public void testNonExactFraction() throws DataException {
+ final BigDecimal otherAmount = BigDecimal.valueOf(24L).setScale(8);
+ final BigDecimal qoraAmount = BigDecimal.valueOf(2L).setScale(8);
+
+ genericTradeTest(AssetUtils.testAssetId, Asset.QORA, otherAmount, qoraAmount, qoraAmount, otherAmount, otherAmount, qoraAmount);
+ }
+
+ /**
+ * Check legacy partial matching of orders with prices that
+ * can't be represented in floating binary.
+ *
+ * For example, sell 2 TEST for 24 QORA so
+ * unit price is 2 / 24 or 0.08333333.
+ *
+ * This inexactness causes the match amount to be
+ * only 1.99999992 instead of the expected 2.00000000.
+ *
+ * However this behaviour is "grandfathered" in legacy/"old"
+ * mode so we need to test.
+ */
+ @Test
+ public void testOldNonExactFraction() throws DataException {
+ Common.useSettings("test-settings-old-asset.json");
+
+ final BigDecimal initialAmount = new BigDecimal("24.00000000").setScale(8);
+ final BigDecimal initialPrice = new BigDecimal("0.08333333").setScale(8);
+
+ final BigDecimal matchedAmount = new BigDecimal("2.00000000").setScale(8);
+ final BigDecimal matchedPrice = new BigDecimal("12.00000000").setScale(8);
+
+ // Due to rounding these are the expected traded amounts.
+ final BigDecimal tradedQoraAmount = new BigDecimal("24.00000000").setScale(8);
+ final BigDecimal tradedOtherAmount = new BigDecimal("1.99999992").setScale(8);
+
+ genericTradeTest(AssetUtils.testAssetId, Asset.QORA, initialAmount, initialPrice, matchedAmount, matchedPrice, tradedQoraAmount, tradedOtherAmount);
+ }
+
+ /**
+ * Check that better prices are used in preference when matching orders.
+ */
+ @Test
+ public void testPriceImprovement() throws DataException {
+ final BigDecimal qoraAmount = BigDecimal.valueOf(24L).setScale(8);
+ final BigDecimal betterQoraAmount = BigDecimal.valueOf(25L).setScale(8);
+ final BigDecimal bestQoraAmount = BigDecimal.valueOf(31L).setScale(8);
+
+ final BigDecimal otherAmount = BigDecimal.valueOf(2L).setScale(8);
+
+ try (Repository repository = RepositoryManager.getRepository()) {
+ Map> initialBalances = AccountUtils.getBalances(repository, Asset.QORA, AssetUtils.testAssetId);
+
+ // Create best initial order
+ AssetUtils.createOrder(repository, "bob", Asset.QORA, AssetUtils.testAssetId, qoraAmount, otherAmount);
+
+ // Create initial order better than first
+ AssetUtils.createOrder(repository, "chloe", Asset.QORA, AssetUtils.testAssetId, bestQoraAmount, otherAmount);
+
+ // Create initial order
+ AssetUtils.createOrder(repository, "dilbert", Asset.QORA, AssetUtils.testAssetId, betterQoraAmount, otherAmount);
+
+ // Create matching order
+ AssetUtils.createOrder(repository, "alice", AssetUtils.testAssetId, Asset.QORA, otherAmount, qoraAmount);
+
+ // Check balances to check expected outcome
+ BigDecimal expectedBalance;
+
+ // We're expecting Alice's order to match with Chloe's order (as Bob's and Dilberts's orders have worse prices)
+
+ // Alice Qora
+ expectedBalance = initialBalances.get("alice").get(Asset.QORA).add(bestQoraAmount);
+ assertBalance(repository, "alice", Asset.QORA, expectedBalance);
+
+ // Alice test asset
+ expectedBalance = initialBalances.get("alice").get(AssetUtils.testAssetId).subtract(otherAmount);
+ assertBalance(repository, "alice", AssetUtils.testAssetId, expectedBalance);
+
+ // Bob Qora
+ expectedBalance = initialBalances.get("bob").get(Asset.QORA).subtract(qoraAmount);
+ assertBalance(repository, "bob", Asset.QORA, expectedBalance);
+
+ // Bob test asset
+ expectedBalance = initialBalances.get("bob").get(AssetUtils.testAssetId);
+ assertBalance(repository, "bob", AssetUtils.testAssetId, expectedBalance);
+
+ // Chloe Qora
+ expectedBalance = initialBalances.get("chloe").get(Asset.QORA).subtract(bestQoraAmount);
+ assertBalance(repository, "chloe", Asset.QORA, expectedBalance);
+
+ // Chloe test asset
+ expectedBalance = initialBalances.get("chloe").get(AssetUtils.testAssetId).add(otherAmount);
+ assertBalance(repository, "chloe", AssetUtils.testAssetId, expectedBalance);
+
+ // Dilbert Qora
+ expectedBalance = initialBalances.get("dilbert").get(Asset.QORA).subtract(betterQoraAmount);
+ assertBalance(repository, "dilbert", Asset.QORA, expectedBalance);
+
+ // Dilbert test asset
+ expectedBalance = initialBalances.get("dilbert").get(AssetUtils.testAssetId);
+ assertBalance(repository, "dilbert", AssetUtils.testAssetId, expectedBalance);
+ }
+ }
+
+ /**
+ * Check legacy qora1 blockchain matching behaviour.
+ */
+ @Test
+ public void testQora1Compat() throws DataException {
+ // Asset 61 [ATFunding] was issued by QYsLsfwMRBPnunmuWmFkM4hvGsfooY8ssU with 250,000,000 quantity and was divisible.
+
+ // Initial order 2jMinWSBjxaLnQvhcEoWGs2JSdX7qbwxMTZenQXXhjGYDHCJDL6EjXPz5VXYuUfZM5LvRNNbcaeBbM6Xhb4tN53g
+ // Creator was QZyuTa3ygjThaPRhrCp1BW4R5Sed6uAGN8 at 2014-10-23 11:14:42.525000+0:00
+ // Have: 150000 [ATFunding], Price: 1.7000000 QORA
+
+ // Matching order 3Ufqi52nDL3Gi7KqVXpgebVN5FmLrdq2XyUJ11BwSV4byxQ2z96Q5CQeawGyanhpXS4XkYAaJTrNxsDDDxyxwbMN
+ // Creator was QMRoD3RS5vJ4DVNBhBgGtQG4KT3PhkNALH at 2015-03-27 12:24:02.945000+0:00
+ // Have: 2 QORA, Price: 0.58 [ATFunding]
+
+ // Trade: 1.17647050 [ATFunding] for 1.99999985 QORA
+
+ // Load/check settings, which potentially sets up blockchain config, etc.
+ Common.useSettings("test-settings-old-asset.json");
+
+ // Transfer some test asset to bob
+ try (Repository repository = RepositoryManager.getRepository()) {
+ AssetUtils.transferAsset(repository, "alice", "bob", AssetUtils.testAssetId, BigDecimal.valueOf(200000L).setScale(8));
+ }
+
+ final BigDecimal initialAmount = new BigDecimal("150000").setScale(8);
+ final BigDecimal initialPrice = new BigDecimal("1.70000000").setScale(8);
+
+ final BigDecimal matchingAmount = new BigDecimal("2.00000000").setScale(8);
+ final BigDecimal matchingPrice = new BigDecimal("0.58000000").setScale(8);
+
+ final BigDecimal tradedOtherAmount = new BigDecimal("1.17647050").setScale(8);
+ final BigDecimal tradedQoraAmount = new BigDecimal("1.99999985").setScale(8);
+
+ genericTradeTest(AssetUtils.testAssetId, Asset.QORA, initialAmount, initialPrice, matchingAmount, matchingPrice, tradedOtherAmount, tradedQoraAmount);
+ }
+
+ /**
+ * Check legacy qora1 blockchain matching behaviour.
+ */
+ @Test
+ public void testQora1Compat2() throws DataException {
+ // Asset 95 [Bitcoin] was issued by QiGx93L9rNHSNWCY1bJnQTPwB3nhxYTCUj with 21000000 quantity and was divisible.
+ // Asset 96 [BitBTC] was issued by QiGx93L9rNHSNWCY1bJnQTPwB3nhxYTCUj with 21000000 quantity and was divisible.
+
+ // Initial order 3jinKPHEak9xrjeYtCaE1PawwRZeRkhYA6q4A7sqej7f3jio8WwXwXpfLWVZkPQ3h6cVdwPhcDFNgbbrBXcipHee
+ // Creator was QiGx93L9rNHSNWCY1bJnQTPwB3nhxYTCUj at 2015-06-10 20:31:44.840000+0:00
+ // Have: 1000000 [BitBTC], Price: 0.90000000 [Bitcoin]
+
+ // Matching order Jw1UfgspZ344waF8qLhGJanJXVa32FBoVvMW5ByFkyHvZEumF4fPqbaGMa76ba1imC4WX5t3Roa7r23Ys6rhKAA
+ // Creator was QiGx93L9rNHSNWCY1bJnQTPwB3nhxYTCUj at 2015-06-14 17:49:41.410000+0:00
+ // Have: 73251 [Bitcoin], Price: 1.01 [BitBTC]
+
+ // Trade: 81389.99991860 [BitBTC] for 73250.99992674 [Bitcoin]
+
+ // Load/check settings, which potentially sets up blockchain config, etc.
+ Common.useSettings("test-settings-old-asset.json");
+
+ // Transfer some test asset to bob
+ try (Repository repository = RepositoryManager.getRepository()) {
+ AssetUtils.transferAsset(repository, "alice", "bob", AssetUtils.testAssetId, BigDecimal.valueOf(200000L).setScale(8));
+ }
+
+ final BigDecimal initialAmount = new BigDecimal("1000000").setScale(8);
+ final BigDecimal initialPrice = new BigDecimal("0.90000000").setScale(8);
+
+ final BigDecimal matchingAmount = new BigDecimal("73251").setScale(8);
+ final BigDecimal matchingPrice = new BigDecimal("1.01000000").setScale(8);
+
+ final BigDecimal tradedHaveAmount = new BigDecimal("81389.99991860").setScale(8);
+ final BigDecimal tradedWantAmount = new BigDecimal("73250.99992674").setScale(8);
+
+ genericTradeTest(Asset.QORA, AssetUtils.testAssetId, initialAmount, initialPrice, matchingAmount, matchingPrice, tradedHaveAmount, tradedWantAmount);
+ }
+
+ private void genericTradeTest(long haveAssetId, long wantAssetId,
+ BigDecimal initialAmount, BigDecimal initialPrice,
+ BigDecimal matchingAmount, BigDecimal matchingPrice,
+ BigDecimal tradedHaveAmount, BigDecimal tradedWantAmount) throws DataException {
+ try (Repository repository = RepositoryManager.getRepository()) {
+ Map> initialBalances = AccountUtils.getBalances(repository, haveAssetId, wantAssetId);
+
+ // Create initial order
+ AssetUtils.createOrder(repository, "alice", haveAssetId, wantAssetId, initialAmount, initialPrice);
+
+ // Create matching order
+ AssetUtils.createOrder(repository, "bob", wantAssetId, haveAssetId, matchingAmount, matchingPrice);
+
+ // Check balances to check expected outcome
+ BigDecimal expectedBalance;
+
+ // Alice have asset
+ expectedBalance = initialBalances.get("alice").get(haveAssetId).subtract(initialAmount);
+ assertBalance(repository, "alice", haveAssetId, expectedBalance);
+
+ // Alice want asset
+ expectedBalance = initialBalances.get("alice").get(wantAssetId).add(tradedWantAmount);
+ assertBalance(repository, "alice", wantAssetId, expectedBalance);
+
+ // Bob want asset
+ expectedBalance = initialBalances.get("bob").get(wantAssetId).subtract(matchingAmount);
+ assertBalance(repository, "bob", wantAssetId, expectedBalance);
+
+ // Bob have asset
+ expectedBalance = initialBalances.get("bob").get(haveAssetId).add(tradedHaveAmount);
+ assertBalance(repository, "bob", haveAssetId, expectedBalance);
+ }
+ }
+
+ private static void assertBalance(Repository repository, String accountName, long assetId, BigDecimal expectedBalance) throws DataException {
+ BigDecimal actualBalance = Common.getTestAccount(repository, accountName).getConfirmedBalance(assetId);
+
+ assertTrue(String.format("Test account '%s' asset %d balance incorrect: expected %s, actual %s", accountName, assetId, expectedBalance.toPlainString(), actualBalance.toPlainString()),
+ actualBalance.compareTo(expectedBalance) == 0);
+ }
+
}
\ No newline at end of file
diff --git a/src/test/java/org/qora/test/common/AccountUtils.java b/src/test/java/org/qora/test/common/AccountUtils.java
new file mode 100644
index 00000000..cba82127
--- /dev/null
+++ b/src/test/java/org/qora/test/common/AccountUtils.java
@@ -0,0 +1,33 @@
+package org.qora.test.common;
+
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.qora.repository.DataException;
+import org.qora.repository.Repository;
+import org.qora.test.Common;
+
+public class AccountUtils {
+
+ public static Map> getBalances(Repository repository, long... assetIds) throws DataException {
+ Map> balances = new HashMap<>();
+
+ for (TestAccount account : Common.getTestAccounts(repository))
+ for (Long assetId : assetIds) {
+ BigDecimal balance = account.getConfirmedBalance(assetId);
+
+ balances.compute(account.accountName, (key, value) -> {
+ if (value == null)
+ value = new HashMap();
+
+ value.put(assetId, balance);
+
+ return value;
+ });
+ }
+
+ return balances;
+ }
+
+}
diff --git a/src/test/java/org/qora/test/common/AssetUtils.java b/src/test/java/org/qora/test/common/AssetUtils.java
index efb9ee05..12cc91f8 100644
--- a/src/test/java/org/qora/test/common/AssetUtils.java
+++ b/src/test/java/org/qora/test/common/AssetUtils.java
@@ -4,6 +4,7 @@ import java.math.BigDecimal;
import org.qora.account.PrivateKeyAccount;
import org.qora.data.transaction.CreateAssetOrderTransactionData;
+import org.qora.data.transaction.IssueAssetTransactionData;
import org.qora.data.transaction.TransactionData;
import org.qora.data.transaction.TransferAssetTransactionData;
import org.qora.group.Group;
@@ -17,30 +18,43 @@ public class AssetUtils {
public static final BigDecimal fee = BigDecimal.ONE.setScale(8);
public static final long testAssetId = 1L;
- public static void transferAsset(Repository repository, String fromAccountName, String toAccountName, long assetId, long amount) throws DataException {
+ public static long issueAsset(Repository repository, String issuerAccountName, String assetName, long quantity, boolean isDivisible) throws DataException {
+ PrivateKeyAccount account = Common.getTestAccount(repository, issuerAccountName);
+
+ byte[] reference = account.getLastReference();
+ long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000;
+
+ TransactionData transactionData = new IssueAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, account.getPublicKey(), account.getAddress(), assetName, "desc", quantity, isDivisible, "{}", AssetUtils.fee);
+
+ Common.signAndForge(repository, transactionData, account);
+
+ return repository.getAssetRepository().fromAssetName(assetName).getAssetId();
+ }
+
+ public static void transferAsset(Repository repository, String fromAccountName, String toAccountName, long assetId, BigDecimal amount) throws DataException {
PrivateKeyAccount fromAccount = Common.getTestAccount(repository, fromAccountName);
PrivateKeyAccount toAccount = Common.getTestAccount(repository, toAccountName);
byte[] reference = fromAccount.getLastReference();
long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000;
- TransactionData transferAssetTransactionData = new TransferAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, fromAccount.getPublicKey(), toAccount.getAddress(), BigDecimal.valueOf(amount), assetId, AssetUtils.fee);
+ TransactionData transactionData = new TransferAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, fromAccount.getPublicKey(), toAccount.getAddress(), amount, assetId, AssetUtils.fee);
- Common.signAndForge(repository, transferAssetTransactionData, fromAccount);
+ Common.signAndForge(repository, transactionData, fromAccount);
}
- public static void createOrder(Repository repository, String accountName, long haveAssetId, long wantAssetId, long haveAmount, long wantAmount) throws DataException {
+ public static byte[] createOrder(Repository repository, String accountName, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal wantAmount) throws DataException {
PrivateKeyAccount account = Common.getTestAccount(repository, accountName);
byte[] reference = account.getLastReference();
long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000;
- BigDecimal amount = BigDecimal.valueOf(haveAmount);
- BigDecimal price = BigDecimal.valueOf(wantAmount);
// Note: "price" is not the same in V2 as in V1
- TransactionData initialOrderTransactionData = new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, account.getPublicKey(), haveAssetId, wantAssetId, amount, price, fee);
+ TransactionData transactionData = new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, account.getPublicKey(), haveAssetId, wantAssetId, amount, wantAmount, fee);
- Common.signAndForge(repository, initialOrderTransactionData, account);
+ Common.signAndForge(repository, transactionData, account);
+
+ return repository.getAssetRepository().getAccountsOrders(account.getPublicKey(), null, null, null, null, true).get(0).getOrderId();
}
}
diff --git a/src/test/java/org/qora/test/common/TestAccount.java b/src/test/java/org/qora/test/common/TestAccount.java
index 45aa5796..48269ce2 100644
--- a/src/test/java/org/qora/test/common/TestAccount.java
+++ b/src/test/java/org/qora/test/common/TestAccount.java
@@ -1,10 +1,19 @@
package org.qora.test.common;
import org.qora.account.PrivateKeyAccount;
+import org.qora.repository.Repository;
import org.qora.utils.Base58;
public class TestAccount extends PrivateKeyAccount {
- public TestAccount(String privateKey) {
- super(null, Base58.decode(privateKey));
+ public final String accountName;
+
+ public TestAccount(Repository repository, String accountName, byte[] privateKey) {
+ super(repository, privateKey);
+
+ this.accountName = accountName;
+ }
+
+ public TestAccount(Repository repository, String accountName, String privateKey) {
+ this(repository, accountName, Base58.decode(privateKey));
}
}
diff --git a/src/test/resources/log4j2-test.properties b/src/test/resources/log4j2-test.properties
new file mode 100644
index 00000000..a74d53ed
--- /dev/null
+++ b/src/test/resources/log4j2-test.properties
@@ -0,0 +1,92 @@
+rootLogger.level = info
+# On Windows, this might be rewritten as:
+# property.filename = ${sys:user.home}\\AppData\\Roaming\\Qora\\log.txt
+property.filename = log.txt
+
+rootLogger.appenderRef.console.ref = stdout
+rootLogger.appenderRef.rolling.ref = FILE
+
+# Override HSQLDB logging level to "warn" as too much is logged at "info"
+logger.hsqldb.name = hsqldb.db
+logger.hsqldb.level = warn
+
+# Support optional, per-session HSQLDB debugging
+logger.hsqldbDebug.name = org.qora.repository.hsqldb.HSQLDBRepository
+logger.hsqldbDebug.level = debug
+
+# Suppress extraneous Jersey warning
+logger.jerseyInject.name = org.glassfish.jersey.internal.inject.Providers
+logger.jerseyInject.level = error
+
+# Suppress extraneous Jetty entries
+# 2019-02-14 11:46:27 INFO ContextHandler:851 - Started o.e.j.s.ServletContextHandler@6949e948{/,null,AVAILABLE}
+# 2019-02-14 11:46:27 INFO AbstractConnector:289 - Started ServerConnector@50ad322b{HTTP/1.1,[http/1.1]}{0.0.0.0:9085}
+# 2019-02-14 11:46:27 INFO Server:374 - jetty-9.4.11.v20180605; built: 2018-06-05T18:24:03.829Z; git: d5fc0523cfa96bfebfbda19606cad384d772f04c; jvm 1.8.0_181-b13
+# 2019-02-14 11:46:27 INFO Server:411 - Started @2539ms
+logger.oejsSCH.name = org.eclipse.jetty
+logger.oejsSCH.level = warn
+
+# Suppress extraneous slf4j entries
+# 2019-02-14 11:46:27 INFO log:193 - Logging initialized @1636ms to org.eclipse.jetty.util.log.Slf4jLog
+logger.slf4j.name = org.slf4j
+logger.slf4j.level = warn
+
+# Suppress extraneous Reflections entry
+# 2019-02-27 10:45:25 WARN Reflections:179 - given scan urls are empty. set urls in the configuration
+logger.reflections.name = org.reflections.Reflections
+logger.reflections.level = error
+
+# Debugging transactions
+logger.transactions.name = org.qora.transaction
+logger.transactions.level = debug
+
+# Debugging transformers
+logger.transformers.name = org.qora.transform.transaction
+logger.transformers.level = debug
+
+# Debugging transaction searches
+logger.txSearch.name = org.qora.repository.hsqldb.transaction.HSQLDBTransactionRepository
+logger.txSearch.level = trace
+
+# Debug block generator
+logger.blockgen.name = org.qora.block.BlockGenerator
+logger.blockgen.level = trace
+
+# Debug synchronization
+logger.sync.name = org.qora.controller.Synchronizer
+logger.sync.level = trace
+
+# Debug networking
+logger.network.name = org.qora.network.Network
+logger.network.level = trace
+logger.peer.name = org.qora.network.Peer
+logger.peer.level = trace
+logger.controller.name = org.qora.controller.Controller
+logger.controller.level = trace
+
+# Debug defaultGroupId
+logger.defgrp.name = org.qora.account.Account
+logger.defgrp.level = trace
+
+# Debug asset trades
+logger.assettrades.name = org.qora.asset.Order
+logger.assettrades.level = trace
+
+appender.console.type = Console
+appender.console.name = stdout
+appender.console.layout.type = PatternLayout
+appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n
+appender.console.filter.threshold.type = ThresholdFilter
+appender.console.filter.threshold.level = error
+
+appender.rolling.type = RollingFile
+appender.rolling.name = FILE
+appender.rolling.layout.type = PatternLayout
+appender.rolling.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n
+appender.rolling.filePattern = ${filename}.%i
+appender.rolling.policy.type = SizeBasedTriggeringPolicy
+appender.rolling.policy.size = 4MB
+# Set the immediate flush to true (default)
+# appender.rolling.immediateFlush = true
+# Set the append to true (default), should not overwrite
+# appender.rolling.append=true
diff --git a/src/test/resources/test-chain-old-asset.json b/src/test/resources/test-chain-old-asset.json
new file mode 100644
index 00000000..f38b3b09
--- /dev/null
+++ b/src/test/resources/test-chain-old-asset.json
@@ -0,0 +1,34 @@
+{
+ "isTestNet": true,
+ "maxBalance": "10000000000",
+ "blockDifficultyInterval": 10,
+ "minBlockTime": 30,
+ "maxBlockTime": 60,
+ "blockTimestampMargin": 500,
+ "maxBytesPerUnitFee": 1024,
+ "unitFee": "0.1",
+ "requireGroupForApproval": false,
+ "genesisInfo": {
+ "version": 4,
+ "timestamp": 0,
+ "generatingBalance": "10000000",
+ "transactions": [
+ { "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORA", "description": "QORA native coin", "data": "", "quantity": 10000000000, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" },
+ { "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "9876543210.12345678", "fee": 0 },
+ { "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000", "fee": 0 },
+ { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "1000000", "fee": 0 },
+ { "type": "GENESIS", "recipient": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "amount": "1000000", "fee": 0 },
+ { "type": "ISSUE_ASSET", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "assetName": "test", "description": "test asset", "data": "", "quantity": 1000000, "isDivisible": true, "fee": 0 }
+ ]
+ },
+ "featureTriggers": {
+ "messageHeight": 0,
+ "atHeight": 0,
+ "assetsTimestamp": 0,
+ "votingTimestamp": 0,
+ "arbitraryTimestamp": 0,
+ "powfixTimestamp": 0,
+ "v2Timestamp": 0,
+ "newAssetPricingTimestamp": 1600000000000
+ }
+}
diff --git a/src/test/resources/test-v2qorachain.json b/src/test/resources/test-chain-v2.json
similarity index 81%
rename from src/test/resources/test-v2qorachain.json
rename to src/test/resources/test-chain-v2.json
index 420a3ead..94d2730d 100644
--- a/src/test/resources/test-v2qorachain.json
+++ b/src/test/resources/test-chain-v2.json
@@ -16,8 +16,10 @@
{ "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORA", "description": "QORA native coin", "data": "", "quantity": 10000000000, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" },
{ "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "9876543210.12345678", "fee": 0 },
{ "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000", "fee": 0 },
+ { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "1000000", "fee": 0 },
+ { "type": "GENESIS", "recipient": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "amount": "1000000", "fee": 0 },
{ "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 },
- { "type": "ISSUE_ASSET", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "assetName": "test", "description": "test asset", "data": "", "quantity": 1000, "isDivisible": true, "fee": 0 }
+ { "type": "ISSUE_ASSET", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "assetName": "test", "description": "test asset", "data": "", "quantity": 1000000, "isDivisible": true, "fee": 0 }
]
},
"featureTriggers": {
@@ -27,6 +29,7 @@
"votingTimestamp": 0,
"arbitraryTimestamp": 0,
"powfixTimestamp": 0,
- "v2Timestamp": 0
+ "v2Timestamp": 0,
+ "newAssetPricingTimestamp": 0
}
}
diff --git a/src/test/resources/test-settings-old-asset.json b/src/test/resources/test-settings-old-asset.json
new file mode 100644
index 00000000..587a880c
--- /dev/null
+++ b/src/test/resources/test-settings-old-asset.json
@@ -0,0 +1,6 @@
+{
+ "restrictedApi": false,
+ "blockchainConfig": "src/test/resources/test-chain-old-asset.json",
+ "wipeUnconfirmedOnStart": false,
+ "minPeers": 0
+}
diff --git a/src/test/resources/test-settings.json b/src/test/resources/test-settings-v2.json
similarity index 55%
rename from src/test/resources/test-settings.json
rename to src/test/resources/test-settings-v2.json
index 4fe80523..31fc2672 100644
--- a/src/test/resources/test-settings.json
+++ b/src/test/resources/test-settings-v2.json
@@ -1,6 +1,6 @@
{
"restrictedApi": false,
- "blockchainConfig": "src/test/resources/test-v2qorachain.json",
+ "blockchainConfig": "src/test/resources/test-chain-v2.json",
"wipeUnconfirmedOnStart": false,
"minPeers": 0
}