diff --git a/src/data/assets/OrderData.java b/src/data/assets/OrderData.java index 8f1d5fe9..c1d0e1de 100644 --- a/src/data/assets/OrderData.java +++ b/src/data/assets/OrderData.java @@ -13,9 +13,10 @@ public class OrderData implements Comparable { private BigDecimal price; private long timestamp; private boolean isClosed; + private boolean isFulfilled; public OrderData(byte[] orderId, byte[] creatorPublicKey, long haveAssetId, long wantAssetId, BigDecimal amount, BigDecimal fulfilled, BigDecimal price, - long timestamp, boolean isClosed) { + long timestamp, boolean isClosed, boolean isFulfilled) { this.orderId = orderId; this.creatorPublicKey = creatorPublicKey; this.haveAssetId = haveAssetId; @@ -25,10 +26,11 @@ public class OrderData implements Comparable { this.price = price; 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); + this(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, BigDecimal.ZERO.setScale(8), price, timestamp, false, false); } public byte[] getOrderId() { @@ -75,6 +77,14 @@ public class OrderData implements Comparable { this.isClosed = isClosed; } + public boolean getIsFulfilled() { + return this.isFulfilled; + } + + public void setIsFulfilled(boolean isFulfilled) { + this.isFulfilled = isFulfilled; + } + @Override public int compareTo(OrderData orderData) { // Compare using prices diff --git a/src/data/assets/TradeData.java b/src/data/assets/TradeData.java new file mode 100644 index 00000000..5a14b9a4 --- /dev/null +++ b/src/data/assets/TradeData.java @@ -0,0 +1,46 @@ +package data.assets; + +import java.math.BigDecimal; + +public class TradeData { + + // Properties + private byte[] initiator; + private byte[] target; + private BigDecimal amount; + private BigDecimal price; + private long timestamp; + + // Constructors + + public TradeData(byte[] initiator, byte[] target, BigDecimal amount, BigDecimal price, long timestamp) { + this.initiator = initiator; + this.target = target; + this.amount = amount; + this.price = price; + this.timestamp = timestamp; + } + + // Getters/setters + + public byte[] getInitiator() { + return this.initiator; + } + + public byte[] getTarget() { + return this.target; + } + + public BigDecimal getAmount() { + return this.amount; + } + + public BigDecimal getPrice() { + return this.price; + } + + public long getTimestamp() { + return this.timestamp; + } + +} diff --git a/src/migrate.java b/src/migrate.java index b9aa9b8c..2583153e 100644 --- a/src/migrate.java +++ b/src/migrate.java @@ -133,19 +133,19 @@ public class migrate { PreparedStatement registerNamePStmt = c .prepareStatement("INSERT INTO RegisterNameTransactions " + formatWithPlaceholders("signature", "registrant", "name", "owner", "data")); PreparedStatement updateNamePStmt = c - .prepareStatement("INSERT INTO UpdateNameTransactions " + formatWithPlaceholders("signature", "owner", "name", "new_owner", "new_data")); + .prepareStatement("INSERT INTO UpdateNameTransactions " + formatWithPlaceholders("signature", "owner", "name", "new_owner", "new_data", "name_reference")); PreparedStatement sellNamePStmt = c .prepareStatement("INSERT INTO SellNameTransactions " + formatWithPlaceholders("signature", "owner", "name", "amount")); PreparedStatement cancelSellNamePStmt = c .prepareStatement("INSERT INTO CancelSellNameTransactions " + formatWithPlaceholders("signature", "owner", "name")); PreparedStatement buyNamePStmt = c - .prepareStatement("INSERT INTO BuyNameTransactions " + formatWithPlaceholders("signature", "buyer", "name", "seller", "amount")); + .prepareStatement("INSERT INTO BuyNameTransactions " + formatWithPlaceholders("signature", "buyer", "name", "seller", "amount", "name_reference")); PreparedStatement createPollPStmt = c - .prepareStatement("INSERT INTO CreatePollTransactions " + formatWithPlaceholders("signature", "creator", "owner", "poll", "description")); + .prepareStatement("INSERT INTO CreatePollTransactions " + formatWithPlaceholders("signature", "creator", "owner", "poll_name", "description")); PreparedStatement createPollOptionPStmt = c - .prepareStatement("INSERT INTO CreatePollTransactionOptions " + formatWithPlaceholders("signature", "option")); + .prepareStatement("INSERT INTO CreatePollTransactionOptions " + formatWithPlaceholders("signature", "option_name")); PreparedStatement voteOnPollPStmt = c - .prepareStatement("INSERT INTO VoteOnPollTransactions " + formatWithPlaceholders("signature", "voter", "poll", "option_index")); + .prepareStatement("INSERT INTO VoteOnPollTransactions " + formatWithPlaceholders("signature", "voter", "poll_name", "option_index")); PreparedStatement arbitraryPStmt = c .prepareStatement("INSERT INTO ArbitraryTransactions " + formatWithPlaceholders("signature", "creator", "service", "data_hash")); PreparedStatement issueAssetPStmt = c.prepareStatement("INSERT INTO IssueAssetTransactions " @@ -396,6 +396,7 @@ public class migrate { updateNamePStmt.setString(3, (String) transaction.get("name")); updateNamePStmt.setString(4, (String) transaction.get("newOwner")); updateNamePStmt.setString(5, (String) transaction.get("newValue")); + updateNamePStmt.setBytes(6, txSignature); // dummy value for name_reference updateNamePStmt.execute(); updateNamePStmt.clearParameters(); @@ -426,6 +427,7 @@ public class migrate { buyNamePStmt.setString(3, (String) transaction.get("name")); buyNamePStmt.setString(4, (String) transaction.get("seller")); buyNamePStmt.setBigDecimal(5, BigDecimal.valueOf(Double.valueOf((String) transaction.get("amount")).doubleValue())); + buyNamePStmt.setBytes(6, txSignature); // dummy value for name_reference buyNamePStmt.execute(); buyNamePStmt.clearParameters(); diff --git a/src/qora/assets/Order.java b/src/qora/assets/Order.java index d64fecef..23c98004 100644 --- a/src/qora/assets/Order.java +++ b/src/qora/assets/Order.java @@ -1,9 +1,16 @@ package qora.assets; import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; import java.util.List; +import data.assets.AssetData; import data.assets.OrderData; +import data.assets.TradeData; +import qora.account.Account; +import qora.account.PublicKeyAccount; +import repository.AssetRepository; import repository.DataException; import repository.Repository; @@ -28,44 +35,168 @@ public class Order { // More information + public static BigDecimal getAmountLeft(OrderData orderData) { + return orderData.getAmount().subtract(orderData.getFulfilled()); + } + public BigDecimal getAmountLeft() { - return this.orderData.getAmount().subtract(this.orderData.getFulfilled()); + return Order.getAmountLeft(this.orderData); + } + + public static boolean isFulfilled(OrderData orderData) { + return orderData.getFulfilled().compareTo(orderData.getAmount()) == 0; } public boolean isFulfilled() { - return this.orderData.getFulfilled().compareTo(this.orderData.getAmount()) == 0; + return Order.isFulfilled(this.orderData); } - // TODO - // public List getInitiatedTrades() {} + 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); - // TODO - // public boolean isConfirmed() {} + // 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); + haveAmount = haveAmount.divide(gcd); + priceAmount = priceAmount.divide(gcd); + + // Calculate GCD in combination with divisibility + if (wantAssetData.getIsDivisible()) + haveAmount = haveAmount.multiply(multiplier); + + if (haveAssetData.getIsDivisible()) + priceAmount = priceAmount.multiply(multiplier); + + gcd = haveAmount.gcd(priceAmount); + + // Calculate the increment at which we have to buy + BigDecimal increment = new BigDecimal(haveAmount.divide(gcd)); + if (wantAssetData.getIsDivisible()) + increment = increment.divide(new BigDecimal(multiplier)); + + // Return + return increment; + } // Navigation - // XXX is this getInitiatedTrades() above? - public List getTrades() { - // TODO - - return null; + public List getTrades() throws DataException { + return this.repository.getAssetRepository().getOrdersTrades(this.orderData.getOrderId()); } // Processing 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); + + // Subtract asset from creator + Account creator = new PublicKeyAccount(this.repository, this.orderData.getCreatorPublicKey()); + creator.setConfirmedBalance(haveAssetId, creator.getConfirmedBalance(haveAssetId).subtract(this.orderData.getAmount())); + + // Save this order into repository so it's available for matching, possibly by itself this.repository.getAssetRepository().save(this.orderData); - // TODO + // Attempt to match orders + + // 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); + + /* + * Our order example: + * + * haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), price=0.002 + * + * This translates to "we have 10,000 GOLD and want to buy QORA at a price of 0.002 QORA per GOLD" + * + * So if our order matched, we'd end up with 10,000 * 0.002 = 20 QORA, essentially costing 1/0.002 = 500 GOLD each. + * + * So 500 GOLD [each] is our "buyingPrice". + */ + BigDecimal ourPrice = this.orderData.getPrice(); + + for (OrderData theirOrderData : orders) { + /* + * Potential matching order example: + * + * 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 "buyingPrice". + */ + + // Round down otherwise their buyingPrice would be better than advertised and cause issues + BigDecimal theirBuyingPrice = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN); + + // 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) + break; + + // Calculate how many want-asset we could buy at their price + BigDecimal ourAmountLeft = this.getAmountLeft().multiply(theirBuyingPrice).setScale(8, RoundingMode.DOWN); + // How many want-asset is left available in this order + BigDecimal theirAmountLeft = Order.getAmountLeft(theirOrderData); + // So matchable want-asset amount is the minimum of above two values + BigDecimal matchedAmount = ourAmountLeft.min(theirAmountLeft); + + // If we can't buy anything then we're done + if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0) + break; + + // Calculate amount granularity based on both assets' divisibility + BigDecimal increment = this.calculateAmountGranularity(haveAssetData, wantAssetData, theirOrderData); + matchedAmount = matchedAmount.subtract(matchedAmount.remainder(increment)); + + // If we can't buy anything then we're done + if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0) + break; + + // Trade can go ahead! + + // Calculate the total cost to us based on their price + BigDecimal tradePrice = matchedAmount.multiply(theirOrderData.getPrice()).setScale(8); + + // Construct trade + TradeData tradeData = new TradeData(this.orderData.getOrderId(), theirOrderData.getOrderId(), matchedAmount, tradePrice, + 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(matchedAmount)); + + // Continue on to process other open orders in case we still have amount left to match + } } public void orphan() throws DataException { - // TODO + // Orphan trades that occurred as a result of this order + for (TradeData tradeData : getTrades()) { + Trade trade = new Trade(this.repository, tradeData); + trade.orphan(); + } + // Delete this order from repository this.repository.getAssetRepository().delete(this.orderData.getOrderId()); + + // 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())); } - // This is CancelOrderTransactions so that an Order can no longer trade + // This is called by CancelOrderTransaction so that an Order can no longer trade public void cancel() throws DataException { this.orderData.setIsClosed(true); this.repository.getAssetRepository().save(this.orderData); @@ -73,7 +204,8 @@ public class Order { // Opposite of cancel() above for use during orphaning public void reopen() throws DataException { - // TODO + this.orderData.setIsClosed(false); + this.repository.getAssetRepository().save(this.orderData); } } diff --git a/src/qora/assets/Trade.java b/src/qora/assets/Trade.java index a835f226..2220e724 100644 --- a/src/qora/assets/Trade.java +++ b/src/qora/assets/Trade.java @@ -1,47 +1,80 @@ package qora.assets; -import java.math.BigDecimal; -import java.math.BigInteger; +import data.assets.OrderData; +import data.assets.TradeData; +import qora.account.Account; +import qora.account.PublicKeyAccount; +import repository.AssetRepository; +import repository.DataException; +import repository.Repository; public class Trade { // Properties - private BigInteger initiator; - private BigInteger target; - private BigDecimal amount; - private BigDecimal price; - private long timestamp; + private Repository repository; + private TradeData tradeData; // Constructors - public Trade(BigInteger initiator, BigInteger target, BigDecimal amount, BigDecimal price, long timestamp) { - this.initiator = initiator; - this.target = target; - this.amount = amount; - this.price = price; - this.timestamp = timestamp; + public Trade(Repository repository, TradeData tradeData) { + this.repository = repository; + this.tradeData = tradeData; } - // Getters/setters + // Processing - public BigInteger getInitiator() { - return this.initiator; + public void process() throws DataException { + AssetRepository assetRepository = this.repository.getAssetRepository(); + + // Save trade into repository + assetRepository.save(tradeData); + + // Update corresponding Orders on both sides of trade + OrderData initiatingOrder = assetRepository.fromOrderId(this.tradeData.getInitiator()); + initiatingOrder.setFulfilled(initiatingOrder.getFulfilled().add(tradeData.getPrice())); + initiatingOrder.setIsFulfilled(Order.isFulfilled(initiatingOrder)); + assetRepository.save(initiatingOrder); + + OrderData targetOrder = assetRepository.fromOrderId(this.tradeData.getTarget()); + targetOrder.setFulfilled(targetOrder.getFulfilled().add(tradeData.getAmount())); + targetOrder.setIsFulfilled(Order.isFulfilled(targetOrder)); + assetRepository.save(targetOrder); + + // Actually transfer asset balances + Account initiatingCreator = new PublicKeyAccount(this.repository, initiatingOrder.getCreatorPublicKey()); + initiatingCreator.setConfirmedBalance(initiatingOrder.getWantAssetId(), + initiatingCreator.getConfirmedBalance(initiatingOrder.getWantAssetId()).add(tradeData.getAmount())); + + Account targetCreator = new PublicKeyAccount(this.repository, targetOrder.getCreatorPublicKey()); + targetCreator.setConfirmedBalance(targetOrder.getWantAssetId(), + targetCreator.getConfirmedBalance(targetOrder.getWantAssetId()).add(tradeData.getPrice())); } - public BigInteger getTarget() { - return this.target; - } + public void orphan() throws DataException { + AssetRepository assetRepository = this.repository.getAssetRepository(); - public BigDecimal getAmount() { - return this.amount; - } + // Revert corresponding Orders on both sides of trade + OrderData initiatingOrder = assetRepository.fromOrderId(this.tradeData.getInitiator()); + initiatingOrder.setFulfilled(initiatingOrder.getFulfilled().subtract(tradeData.getPrice())); + initiatingOrder.setIsFulfilled(Order.isFulfilled(initiatingOrder)); + assetRepository.save(initiatingOrder); - public BigDecimal getPrice() { - return this.price; - } + OrderData targetOrder = assetRepository.fromOrderId(this.tradeData.getTarget()); + targetOrder.setFulfilled(targetOrder.getFulfilled().subtract(tradeData.getAmount())); + targetOrder.setIsFulfilled(Order.isFulfilled(targetOrder)); + assetRepository.save(targetOrder); - public long getTimestamp() { - return this.timestamp; + // Reverse asset transfers + Account initiatingCreator = new PublicKeyAccount(this.repository, initiatingOrder.getCreatorPublicKey()); + initiatingCreator.setConfirmedBalance(initiatingOrder.getWantAssetId(), + initiatingCreator.getConfirmedBalance(initiatingOrder.getWantAssetId()).subtract(tradeData.getAmount())); + + Account targetCreator = new PublicKeyAccount(this.repository, targetOrder.getCreatorPublicKey()); + targetCreator.setConfirmedBalance(targetOrder.getWantAssetId(), + targetCreator.getConfirmedBalance(targetOrder.getWantAssetId()).subtract(tradeData.getPrice())); + + // Remove trade from repository + assetRepository.delete(tradeData); } } diff --git a/src/repository/AssetRepository.java b/src/repository/AssetRepository.java index 977b2644..89e27f63 100644 --- a/src/repository/AssetRepository.java +++ b/src/repository/AssetRepository.java @@ -1,7 +1,10 @@ package repository; +import java.util.List; + import data.assets.AssetData; import data.assets.OrderData; +import data.assets.TradeData; public interface AssetRepository { @@ -23,8 +26,18 @@ public interface AssetRepository { public OrderData fromOrderId(byte[] orderId) throws DataException; + public List getOpenOrders(long haveAssetId, long wantAssetId) throws DataException; + public void save(OrderData orderData) throws DataException; public void delete(byte[] orderId) throws DataException; + // Trades + + public List getOrdersTrades(byte[] orderId) throws DataException; + + public void save(TradeData tradeData) throws DataException; + + public void delete(TradeData tradeData) throws DataException; + } diff --git a/src/repository/hsqldb/HSQLDBAssetRepository.java b/src/repository/hsqldb/HSQLDBAssetRepository.java index 82ff6692..51b37be0 100644 --- a/src/repository/hsqldb/HSQLDBAssetRepository.java +++ b/src/repository/hsqldb/HSQLDBAssetRepository.java @@ -3,9 +3,13 @@ package repository.hsqldb; import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; import data.assets.AssetData; import data.assets.OrderData; +import data.assets.TradeData; import repository.AssetRepository; import repository.DataException; @@ -77,6 +81,7 @@ public class HSQLDBAssetRepository implements AssetRepository { public void save(AssetData assetData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("Assets"); + saveHelper.bind("asset_id", assetData.getAssetId()).bind("owner", assetData.getOwner()).bind("asset_name", assetData.getName()) .bind("description", assetData.getDescription()).bind("quantity", assetData.getQuantity()).bind("is_divisible", assetData.getIsDivisible()) .bind("reference", assetData.getReference()); @@ -104,7 +109,7 @@ public class HSQLDBAssetRepository implements AssetRepository { public OrderData fromOrderId(byte[] orderId) throws DataException { try { ResultSet resultSet = this.repository.checkedExecute( - "SELECT creator, have_asset_id, want_asset_id, amount, fulfilled, price, timestamp, is_closed 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; @@ -117,18 +122,53 @@ public class HSQLDBAssetRepository implements AssetRepository { BigDecimal price = resultSet.getBigDecimal(6); long timestamp = resultSet.getTimestamp(7).getTime(); boolean isClosed = resultSet.getBoolean(8); + boolean isFulfilled = resultSet.getBoolean(9); - return new OrderData(orderId, creatorPublicKey, haveAssetId, wantAssetId, amount, fulfilled, price, timestamp, isClosed); + 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); } } + public List getOpenOrders(long haveAssetId, long wantAssetId) throws DataException { + List orders = new ArrayList(); + + try { + ResultSet resultSet = this.repository.checkedExecute( + "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 ASC", + haveAssetId, wantAssetId); + 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).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 asset orders from repository", e); + } + } + public void save(OrderData orderData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("AssetOrders"); + 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("isClosed", orderData.getIsClosed()); + .bind("fulfilled", orderData.getFulfilled()).bind("price", orderData.getPrice()).bind("ordered", new Timestamp(orderData.getTimestamp())) + .bind("is_closed", orderData.getIsClosed()).bind("is_fulfilled", orderData.getIsFulfilled()); try { saveHelper.execute(this.repository); @@ -139,10 +179,59 @@ public class HSQLDBAssetRepository implements AssetRepository { public void delete(byte[] orderId) throws DataException { try { - this.repository.delete("AssetOrders", "orderId = ?", orderId); + this.repository.delete("AssetOrders", "asset_order_id = ?", orderId); } catch (SQLException e) { throw new DataException("Unable to delete asset order from repository", e); } } + // Trades + + public List getOrdersTrades(byte[] initiatingOrderId) throws DataException { + List trades = new ArrayList(); + + try { + ResultSet resultSet = this.repository.checkedExecute("SELECT target_order_id, amount, price, traded FROM AssetTrades WHERE initiating_order_id = ?", + initiatingOrderId); + if (resultSet == null) + return trades; + + do { + byte[] targetOrderId = resultSet.getBytes(1); + BigDecimal amount = resultSet.getBigDecimal(2); + BigDecimal price = resultSet.getBigDecimal(3); + long timestamp = resultSet.getTimestamp(4).getTime(); + + TradeData trade = new TradeData(initiatingOrderId, targetOrderId, amount, price, timestamp); + trades.add(trade); + } while (resultSet.next()); + + return trades; + } catch (SQLException e) { + throw new DataException("Unable to fetch asset order's trades from repository", e); + } + } + + public void save(TradeData tradeData) throws DataException { + HSQLDBSaver saveHelper = new HSQLDBSaver("AssetTrades"); + + saveHelper.bind("initiating_order_id", tradeData.getInitiator()).bind("target_order_id", tradeData.getTarget()).bind("amount", tradeData.getAmount()) + .bind("price", tradeData.getPrice()).bind("traded", new Timestamp(tradeData.getTimestamp())); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save asset trade into repository", e); + } + } + + public void delete(TradeData tradeData) throws DataException { + try { + this.repository.delete("AssetTrades", "initiating_order_id = ? AND target_order_id = ? AND amount = ? AND price = ?", tradeData.getInitiator(), + tradeData.getTarget(), tradeData.getAmount(), tradeData.getPrice()); + } catch (SQLException e) { + throw new DataException("Unable to delete asset trade from repository", e); + } + } + } diff --git a/src/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/repository/hsqldb/HSQLDBDatabaseUpdates.java index 0610a9bb..41bc509a 100644 --- a/src/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -300,16 +300,24 @@ public class HSQLDBDatabaseUpdates { // Asset Orders stmt.execute( "CREATE TABLE AssetOrders (asset_order_id AssetOrderID, creator QoraPublicKey NOT NULL, have_asset_id AssetID NOT NULL, want_asset_id AssetID NOT NULL, " - + "amount QoraAmount NOT NULL, fulfilled QoraAmount NOT NULL, price QoraAmount NOT NULL, ordered TIMESTAMP NOT NULL, is_closed BOOLEAN NOT NULL, " - + "PRIMARY KEY (asset_order_id))"); - // For quick matching of orders. is_closed included so inactive orders can be filtered out. - stmt.execute("CREATE INDEX AssetOrderHaveIndex on AssetOrders (have_asset_id, is_closed)"); - stmt.execute("CREATE INDEX AssetOrderWantIndex on AssetOrders (want_asset_id, is_closed)"); + + "amount QoraAmount NOT NULL, fulfilled QoraAmount NOT NULL, price QoraAmount NOT NULL, " + + "ordered TIMESTAMP NOT NULL, is_closed BOOLEAN NOT NULL, is_fulfilled BOOLEAN NOT NULL, " + "PRIMARY KEY (asset_order_id))"); + // For quick matching of orders. is_closed are is_fulfilled included so inactive orders can be filtered out. + stmt.execute("CREATE INDEX AssetOrderMatchingIndex on AssetOrders (have_asset_id, want_asset_id, is_closed, is_fulfilled)"); // For when a user wants to look up their current/historic orders. is_closed included so user can filter by active/inactive orders. stmt.execute("CREATE INDEX AssetOrderCreatorIndex on AssetOrders (creator, is_closed)"); break; case 24: + // Asset Trades + stmt.execute("CREATE TABLE AssetTrades (initiating_order_id AssetOrderId NOT NULL, target_order_id AssetOrderId NOT NULL, " + + "amount QoraAmount NOT NULL, price QoraAmount NOT NULL, traded TIMESTAMP NOT NULL)"); + // For looking up historic trades based on orders + stmt.execute("CREATE INDEX AssetTradeBuyOrderIndex on AssetTrades (initiating_order_id, traded)"); + stmt.execute("CREATE INDEX AssetTradeSellOrderIndex on AssetTrades (target_order_id, traded)"); + break; + + case 25: // Polls/Voting stmt.execute( "CREATE TABLE Polls (poll_name PollName, description VARCHAR(4000) NOT NULL, creator QoraPublicKey NOT NULL, owner QoraAddress NOT NULL, " @@ -324,7 +332,7 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE INDEX PollOwnerIndex on Polls (owner)"); break; - case 25: + case 26: // Registered Names stmt.execute( "CREATE TABLE Names (name RegisteredName, data VARCHAR(4000) NOT NULL, registrant QoraPublicKey NOT NULL, owner QoraAddress NOT NULL, " diff --git a/src/test/TransactionTests.java b/src/test/TransactionTests.java index 6e2c429e..050ce35b 100644 --- a/src/test/TransactionTests.java +++ b/src/test/TransactionTests.java @@ -18,10 +18,14 @@ import data.PaymentData; import data.account.AccountBalanceData; import data.account.AccountData; import data.assets.AssetData; +import data.assets.OrderData; +import data.assets.TradeData; import data.block.BlockData; import data.naming.NameData; import data.transaction.BuyNameTransactionData; +import data.transaction.CancelOrderTransactionData; import data.transaction.CancelSellNameTransactionData; +import data.transaction.CreateOrderTransactionData; import data.transaction.CreatePollTransactionData; import data.transaction.IssueAssetTransactionData; import data.transaction.MessageTransactionData; @@ -42,7 +46,9 @@ import qora.assets.Asset; import qora.block.Block; import qora.block.BlockChain; import qora.transaction.BuyNameTransaction; +import qora.transaction.CancelOrderTransaction; import qora.transaction.CancelSellNameTransaction; +import qora.transaction.CreateOrderTransaction; import qora.transaction.CreatePollTransaction; import qora.transaction.IssueAssetTransaction; import qora.transaction.MessageTransaction; @@ -73,8 +79,9 @@ public class TransactionTests { private static final byte[] senderSeed = HashCode.fromString("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef").asBytes(); private static final byte[] recipientSeed = HashCode.fromString("fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210").asBytes(); - private static final BigDecimal initialGeneratorBalance = BigDecimal.valueOf(1_000_000_000L); - private static final BigDecimal initialSenderBalance = BigDecimal.valueOf(1_000_000L); + private static final BigDecimal initialGeneratorBalance = BigDecimal.valueOf(1_000_000_000L).setScale(8); + private static final BigDecimal initialSenderBalance = BigDecimal.valueOf(1_000_000L).setScale(8); + private static final BigDecimal genericPaymentAmount = BigDecimal.valueOf(1_000L).setScale(8); private Repository repository; private AccountRepository accountRepository; @@ -135,7 +142,7 @@ public class TransactionTests { private Transaction createPayment(PrivateKeyAccount sender, String recipient) throws DataException { // Make a new payment transaction - BigDecimal amount = BigDecimal.valueOf(1_000L); + BigDecimal amount = genericPaymentAmount; BigDecimal fee = BigDecimal.ONE; long timestamp = parentBlockData.getTimestamp() + 1_000; PaymentTransactionData paymentTransactionData = new PaymentTransactionData(sender.getPublicKey(), recipient, amount, fee, timestamp, reference); @@ -163,7 +170,7 @@ public class TransactionTests { assertTrue(paymentTransaction.isSignatureValid()); assertEquals(ValidationResult.OK, paymentTransaction.isValid()); - // Forge new block with payment transaction + // Forge new block with transaction Block block = new Block(repository, parentBlockData, generator, null, null); block.addTransaction(paymentTransactionData); block.sign(); @@ -591,7 +598,7 @@ public class TransactionTests { lastBlock.orphan(); repository.saveChanges(); - // Recheck poll's votes + // Re-check poll's votes votes = repository.getVotingRepository().getVotes(pollName); assertNotNull(votes); @@ -621,7 +628,7 @@ public class TransactionTests { assertTrue(issueAssetTransaction.isSignatureValid()); assertEquals(ValidationResult.OK, issueAssetTransaction.isValid()); - // Forge new block with payment transaction + // Forge new block with transaction Block block = new Block(repository, parentBlockData, generator, null, null); block.addTransaction(issueAssetTransactionData); block.sign(); @@ -711,7 +718,7 @@ public class TransactionTests { assertTrue(transferAssetTransaction.isSignatureValid()); assertEquals(ValidationResult.OK, transferAssetTransaction.isValid()); - // Forge new block with payment transaction + // Forge new block with transaction Block block = new Block(repository, parentBlockData, generator, null, null); block.addTransaction(transferAssetTransactionData); block.sign(); @@ -775,12 +782,267 @@ public class TransactionTests { @Test public void testCreateAssetOrderTransaction() throws DataException { - // TODO + // Issue asset using another test + testIssueAssetTransaction(); + + // Asset info + String assetName = "test asset"; + AssetRepository assetRepo = this.repository.getAssetRepository(); + AssetData originalAssetData = assetRepo.fromAssetName(assetName); + long assetId = originalAssetData.getAssetId(); + + // Buyer + PrivateKeyAccount buyer = new PrivateKeyAccount(repository, recipientSeed); + + // Send buyer some funds so they have a reference + Transaction somePaymentTransaction = createPayment(sender, buyer.getAddress()); + byte[] buyersReference = somePaymentTransaction.getTransactionData().getSignature(); + + // Forge new block with transaction + Block block = new Block(repository, parentBlockData, generator, null, null); + block.addTransaction(somePaymentTransaction.getTransactionData()); + block.sign(); + + block.process(); + repository.saveChanges(); + parentBlockData = block.getBlockData(); + + // Order: buyer has 10 QORA and wants to buy "test asset" at a price of 50 "test asset" per QORA. + long haveAssetId = Asset.QORA; + BigDecimal amount = BigDecimal.valueOf(10).setScale(8); + long wantAssetId = assetId; + BigDecimal price = BigDecimal.valueOf(50).setScale(8); + BigDecimal fee = BigDecimal.ONE; + long timestamp = parentBlockData.getTimestamp() + 1_000; + + CreateOrderTransactionData createOrderTransactionData = new CreateOrderTransactionData(buyer.getPublicKey(), haveAssetId, wantAssetId, amount, price, + fee, timestamp, buyersReference); + Transaction createOrderTransaction = new CreateOrderTransaction(this.repository, createOrderTransactionData); + createOrderTransaction.calcSignature(buyer); + assertTrue(createOrderTransaction.isSignatureValid()); + assertEquals(ValidationResult.OK, createOrderTransaction.isValid()); + + // Forge new block with transaction + block = new Block(repository, parentBlockData, generator, null, null); + block.addTransaction(createOrderTransactionData); + block.sign(); + + assertTrue("Block signatures invalid", block.isSignatureValid()); + assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); + + block.process(); + repository.saveChanges(); + + // Check order was created + byte[] orderId = createOrderTransactionData.getSignature(); + OrderData orderData = assetRepo.fromOrderId(orderId); + assertNotNull(orderData); + + // Check buyer's balance reduced + BigDecimal expectedBalance = genericPaymentAmount.subtract(amount).subtract(fee); + BigDecimal actualBalance = buyer.getConfirmedBalance(haveAssetId); + assertTrue(expectedBalance.compareTo(actualBalance) == 0); + + // Orphan transaction + block.orphan(); + repository.saveChanges(); + + // Check order no longer exists + orderData = assetRepo.fromOrderId(orderId); + assertNull(orderData); + + // Check buyer's balance restored + expectedBalance = genericPaymentAmount; + actualBalance = buyer.getConfirmedBalance(haveAssetId); + assertTrue(expectedBalance.compareTo(actualBalance) == 0); + + // Re-process to allow use by other tests + block.process(); + repository.saveChanges(); + + // Update variables for use by other tests + reference = sender.getLastReference(); + parentBlockData = block.getBlockData(); } @Test public void testCancelAssetOrderTransaction() throws DataException { - // TODO + // Issue asset and create order using another test + testCreateAssetOrderTransaction(); + + // Asset info + String assetName = "test asset"; + AssetRepository assetRepo = this.repository.getAssetRepository(); + AssetData originalAssetData = assetRepo.fromAssetName(assetName); + long assetId = originalAssetData.getAssetId(); + + // Buyer + PrivateKeyAccount buyer = new PrivateKeyAccount(repository, recipientSeed); + + // Fetch orders + long haveAssetId = Asset.QORA; + long wantAssetId = assetId; + List orders = assetRepo.getOpenOrders(haveAssetId, wantAssetId); + + assertNotNull(orders); + assertEquals(1, orders.size()); + + OrderData originalOrderData = orders.get(0); + assertNotNull(originalOrderData); + assertFalse(originalOrderData.getIsClosed()); + + // Create cancel order transaction + byte[] orderId = originalOrderData.getOrderId(); + BigDecimal fee = BigDecimal.ONE; + long timestamp = parentBlockData.getTimestamp() + 1_000; + byte[] buyersReference = buyer.getLastReference(); + CancelOrderTransactionData cancelOrderTransactionData = new CancelOrderTransactionData(buyer.getPublicKey(), orderId, fee, timestamp, buyersReference); + + Transaction cancelOrderTransaction = new CancelOrderTransaction(this.repository, cancelOrderTransactionData); + cancelOrderTransaction.calcSignature(buyer); + assertTrue(cancelOrderTransaction.isSignatureValid()); + assertEquals(ValidationResult.OK, cancelOrderTransaction.isValid()); + + // Forge new block with transaction + Block block = new Block(repository, parentBlockData, generator, null, null); + block.addTransaction(cancelOrderTransactionData); + block.sign(); + + assertTrue("Block signatures invalid", block.isSignatureValid()); + assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); + + block.process(); + repository.saveChanges(); + + // Check order is marked as cancelled + OrderData cancelledOrderData = assetRepo.fromOrderId(orderId); + assertNotNull(cancelledOrderData); + assertTrue(cancelledOrderData.getIsClosed()); + + // Orphan + block.orphan(); + repository.saveChanges(); + + // Check order is no longer marked as cancelled + OrderData uncancelledOrderData = assetRepo.fromOrderId(orderId); + assertNotNull(uncancelledOrderData); + assertFalse(uncancelledOrderData.getIsClosed()); + + } + + @Test + public void testMatchingCreateAssetOrderTransaction() throws DataException { + // Issue asset and create order using another test + testCreateAssetOrderTransaction(); + + // Asset info + String assetName = "test asset"; + AssetRepository assetRepo = this.repository.getAssetRepository(); + AssetData originalAssetData = assetRepo.fromAssetName(assetName); + long assetId = originalAssetData.getAssetId(); + + // Buyer + PrivateKeyAccount buyer = new PrivateKeyAccount(repository, recipientSeed); + + // Fetch orders + long originalHaveAssetId = Asset.QORA; + long originalWantAssetId = assetId; + List orders = assetRepo.getOpenOrders(originalHaveAssetId, originalWantAssetId); + + assertNotNull(orders); + assertEquals(1, orders.size()); + + OrderData originalOrderData = orders.get(0); + assertNotNull(originalOrderData); + assertFalse(originalOrderData.getIsClosed()); + + // Original asset owner (sender) will sell asset to "buyer" + + // Order: seller has 40 "test asset" and wants to buy QORA at a price of 1/60 QORA per "test asset". + // This order should be a partial match for original order, and at a better price than asked + long haveAssetId = Asset.QORA; + BigDecimal amount = BigDecimal.valueOf(40).setScale(8); + long wantAssetId = assetId; + BigDecimal price = BigDecimal.ONE.setScale(8).divide(BigDecimal.valueOf(60).setScale(8)); + BigDecimal fee = BigDecimal.ONE; + long timestamp = parentBlockData.getTimestamp() + 1_000; + + CreateOrderTransactionData createOrderTransactionData = new CreateOrderTransactionData(sender.getPublicKey(), haveAssetId, wantAssetId, amount, price, + fee, timestamp, reference); + Transaction createOrderTransaction = new CreateOrderTransaction(this.repository, createOrderTransactionData); + createOrderTransaction.calcSignature(sender); + assertTrue(createOrderTransaction.isSignatureValid()); + assertEquals(ValidationResult.OK, createOrderTransaction.isValid()); + + // Forge new block with transaction + Block block = new Block(repository, parentBlockData, generator, null, null); + block.addTransaction(createOrderTransactionData); + block.sign(); + + assertTrue("Block signatures invalid", block.isSignatureValid()); + assertEquals("Block is invalid", Block.ValidationResult.OK, block.isValid()); + + block.process(); + repository.saveChanges(); + + // Check order was created + byte[] orderId = createOrderTransactionData.getSignature(); + OrderData orderData = assetRepo.fromOrderId(orderId); + assertNotNull(orderData); + assertFalse(orderData.getIsFulfilled()); + + // Check order has trades + List trades = assetRepo.getOrdersTrades(orderId); + assertNotNull(trades); + assertEquals(1, trades.size()); + TradeData tradeData = trades.get(0); + + // Check trade has correct values + BigDecimal expectedAmount = amount.multiply(price); + BigDecimal actualAmount = tradeData.getAmount(); + assertTrue(expectedAmount.compareTo(actualAmount) == 0); + + BigDecimal expectedPrice = originalOrderData.getPrice().multiply(amount); + BigDecimal actualPrice = tradeData.getPrice(); + assertTrue(expectedPrice.compareTo(actualPrice) == 0); + + // Check seller's "test asset" balance + BigDecimal expectedBalance = BigDecimal.valueOf(1_000_000L).setScale(8).subtract(amount); + BigDecimal actualBalance = sender.getConfirmedBalance(haveAssetId); + assertTrue(expectedBalance.compareTo(actualBalance) == 0); + + // Check buyer's "test asset" balance + expectedBalance = amount; + actualBalance = buyer.getConfirmedBalance(haveAssetId); + assertTrue(expectedBalance.compareTo(actualBalance) == 0); + + // Check seller's QORA balance + expectedBalance = initialSenderBalance.subtract(BigDecimal.ONE).subtract(BigDecimal.ONE); + actualBalance = sender.getConfirmedBalance(wantAssetId); + assertTrue(expectedBalance.compareTo(actualBalance) == 0); + + // Orphan transaction + block.orphan(); + repository.saveChanges(); + + // Check order no longer exists + orderData = assetRepo.fromOrderId(orderId); + assertNull(orderData); + + // Check trades no longer exist + trades = assetRepo.getOrdersTrades(orderId); + assertNotNull(trades); + assertEquals(0, trades.size()); + + // Check seller's "test asset" balance restored + expectedBalance = BigDecimal.valueOf(1_000_000L).setScale(8); + actualBalance = sender.getConfirmedBalance(haveAssetId); + assertTrue(expectedBalance.compareTo(actualBalance) == 0); + + // Check buyer's "test asset" balance restored + expectedBalance = BigDecimal.ZERO.setScale(8); + actualBalance = buyer.getConfirmedBalance(haveAssetId); + assertTrue(expectedBalance.compareTo(actualBalance) == 0); } @Test diff --git a/src/transform/block/BlockTransformer.java b/src/transform/block/BlockTransformer.java index 4f014a1e..e4587da5 100644 --- a/src/transform/block/BlockTransformer.java +++ b/src/transform/block/BlockTransformer.java @@ -16,11 +16,11 @@ import com.google.common.primitives.Bytes; import com.google.common.primitives.Ints; import com.google.common.primitives.Longs; +import data.assets.TradeData; import data.block.BlockData; import data.transaction.TransactionData; import qora.account.PublicKeyAccount; import qora.assets.Order; -import qora.assets.Trade; import qora.block.Block; import qora.transaction.CreateOrderTransaction; import qora.transaction.Transaction; @@ -224,6 +224,8 @@ public class BlockTransformer extends Transformer { // Add transaction info JSONArray transactionsJson = new JSONArray(); + + // XXX this should be moved out to API as it requires repository access boolean tradesHappened = false; try { @@ -234,10 +236,10 @@ public class BlockTransformer extends Transformer { if (transaction.getTransactionData().getType() == Transaction.TransactionType.CREATE_ASSET_ORDER) { CreateOrderTransaction orderTransaction = (CreateOrderTransaction) transaction; Order order = orderTransaction.getOrder(); - List trades = order.getTrades(); + List trades = order.getTrades(); - // Filter out trades with timestamps that don't match order transaction's timestamp - trades.removeIf((Trade trade) -> trade.getTimestamp() != order.getOrderData().getTimestamp()); + // Filter out trades with initiatingOrderId that doesn't match this order + trades.removeIf((TradeData tradeData) -> !Arrays.equals(tradeData.getInitiator(), order.getOrderData().getOrderId())); // Any trades left? if (!trades.isEmpty()) {