Interim commit with newer asset order "price" arg

+ unit test
+ newer unit test harness

but still needs:

BlockChain config support for activating newer "price" arg
New unit test to check old "price" arg usage

Rework existing asset-related unit tests

Check API inputs/output pre/post "price" arg crossover
This commit is contained in:
catbref 2019-03-28 16:28:31 +00:00
parent d3c1602d9b
commit 789b311984
9 changed files with 246 additions and 18 deletions

View File

@ -10,6 +10,7 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qora.account.Account;
import org.qora.account.PublicKeyAccount;
import org.qora.block.BlockChain;
import org.qora.data.asset.AssetData;
import org.qora.data.asset.OrderData;
import org.qora.data.asset.TradeData;
@ -110,10 +111,16 @@ public class Order {
// Save this order into repository so it's available for matching, possibly by itself
this.repository.getAssetRepository().save(this.orderData);
boolean isOurOrderV2 = this.orderData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp();
// Attempt to match orders
LOGGER.debug("Processing our order " + HashCode.fromBytes(this.orderData.getOrderId()).toString());
LOGGER.trace("We have: " + this.orderData.getAmount().toPlainString() + " " + haveAssetData.getName());
LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName());
if (isOurOrderV2)
LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName());
else
LOGGER.trace("We want " + this.orderData.getPrice().toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName());
// Fetch corresponding open orders that might potentially match, hence reversed want/have assetId args.
// Returned orders are sorted with lowest "price" first.
@ -121,7 +128,7 @@ public class Order {
LOGGER.trace("Open orders fetched from repository: " + orders.size());
/*
* Our order example:
* Our order example (V1):
*
* haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), price=0.002
*
@ -130,17 +137,38 @@ public class Order {
* So if our order matched, we'd end up with 10,000 * 0.002 = 20 QORA, essentially costing 1/0.002 = 500 GOLD each.
*
* So 500 GOLD [each] is our "buyingPrice".
*
* Our order example (V2):
*
* haveAssetId=[GOLD], amount=10,000, wantAssetId=0 (QORA), price=20
*
* This translates to "we have 10,000 GOLD and want to buy 20 QORA"
*
* So if our order matched, we'd end up with 20 QORA, essentially costing 10,000 / 20 = 500 GOLD each.
*
* So 500 GOLD [each] is our "buyingPrice".
*/
BigDecimal ourPrice = this.orderData.getPrice();
BigDecimal ourAmount = this.orderData.getAmount();
BigDecimal ourPrice;
if (isOurOrderV2)
ourPrice = ourAmount.divide(this.orderData.getPrice(), RoundingMode.DOWN);
else
ourPrice = this.orderData.getPrice();
for (OrderData theirOrderData : orders) {
LOGGER.trace("Considering order " + HashCode.fromBytes(theirOrderData.getOrderId()).toString());
// Note swapped use of have/want asset data as this is from 'their' perspective.
LOGGER.trace("They have: " + theirOrderData.getAmount().toPlainString() + " " + wantAssetData.getName());
LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName() + " per " + wantAssetData.getName());
boolean isTheirOrderV2 = theirOrderData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp();
if (isTheirOrderV2)
LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName());
else
LOGGER.trace("They want " + theirOrderData.getPrice().toPlainString() + " " + haveAssetData.getName() + " per " + wantAssetData.getName());
/*
* Potential matching order example:
* Potential matching order example (V1):
*
* haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=486
*
@ -149,10 +177,25 @@ public class Order {
* So if their order matched, they'd end up with 40 * 486 = 19,440 GOLD, essentially costing 1/486 = 0.00205761 QORA each.
*
* So 0.00205761 QORA [each] is their "buyingPrice".
*
* Potential matching order example (V2):
*
* haveAssetId=0 (QORA), amount=40, wantAssetId=[GOLD], price=19,440
*
* This translates to "we have 40 QORA and want to buy 19,440 GOLD"
*
* So if their order matched, they'd end up with 19,440 GOLD, essentially costing 40 / 19,440 = 0.00205761 QORA each.
*
* So 0.00205761 QORA [each] is their "buyingPrice".
*/
// Round down otherwise their buyingPrice would be better than advertised and cause issues
BigDecimal theirBuyingPrice = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN);
BigDecimal theirBuyingPrice;
if (isTheirOrderV2)
theirBuyingPrice = theirOrderData.getAmount().divide(theirOrderData.getPrice(), RoundingMode.DOWN);
else
theirBuyingPrice = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN);
LOGGER.trace("theirBuyingPrice: " + theirBuyingPrice.toPlainString() + " " + wantAssetData.getName() + " per " + haveAssetData.getName());
// If their buyingPrice is less than what we're willing to pay then we're done as prices only get worse as we iterate through list of orders
@ -162,9 +205,11 @@ public class Order {
// Calculate how many want-asset we could buy at their price
BigDecimal ourAmountLeft = this.getAmountLeft().multiply(theirBuyingPrice).setScale(8, RoundingMode.DOWN);
LOGGER.trace("ourAmountLeft (max we could buy at their price): " + ourAmountLeft.toPlainString() + " " + wantAssetData.getName());
// How many want-asset is remaining available in this order
BigDecimal theirAmountLeft = Order.getAmountLeft(theirOrderData);
LOGGER.trace("theirAmountLeft (max amount remaining in order): " + theirAmountLeft.toPlainString() + " " + wantAssetData.getName());
// So matchable want-asset amount is the minimum of above two values
BigDecimal matchedAmount = ourAmountLeft.min(theirAmountLeft);
LOGGER.trace("matchedAmount: " + matchedAmount.toPlainString() + " " + wantAssetData.getName());
@ -186,7 +231,11 @@ public class Order {
// Trade can go ahead!
// Calculate the total cost to us, in have-asset, based on their price
BigDecimal tradePrice = matchedAmount.multiply(theirOrderData.getPrice()).setScale(8);
BigDecimal tradePrice;
if (isTheirOrderV2)
tradePrice = matchedAmount.divide(theirBuyingPrice).setScale(8); // XXX is this right?
else
tradePrice = matchedAmount.multiply(theirOrderData.getPrice()).setScale(8);
LOGGER.trace("tradePrice ('want' trade agreed): " + tradePrice.toPlainString() + " " + haveAssetData.getName());
// Construct trade

View File

@ -310,6 +310,7 @@ public class GenesisBlock extends Block {
}
transaction.process();
creator.setLastReference(transactionData.getSignature());
}
} catch (TransformationException e) {
throw new RuntimeException("Can't process genesis block transaction", e);

View File

@ -22,7 +22,7 @@ public class CreateAssetOrderTransactionData extends TransactionData {
private long wantAssetId;
@Schema(description = "amount of \"have\" asset to trade")
private BigDecimal amount;
@Schema(description = "amount of \"want\" asset to receive per unit of \"have\" asset traded")
@Schema(description = "amount of \"want\" asset to receive")
private BigDecimal price;
// Constructors

View File

@ -130,9 +130,16 @@ public class CreateAssetOrderTransaction extends Transaction {
return ValidationResult.INVALID_AMOUNT;
// Check total return from fulfilled order would be integer if "want" asset is not divisible
if (!wantAssetData.getIsDivisible()
&& createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()).stripTrailingZeros().scale() > 0)
return ValidationResult.INVALID_RETURN;
if (createOrderTransactionData.getTimestamp() >= BlockChain.getInstance().getQoraV2Timestamp()) {
// v2
if (!wantAssetData.getIsDivisible() && createOrderTransactionData.getPrice().stripTrailingZeros().scale() > 0)
return ValidationResult.INVALID_RETURN;
} else {
// v1
if (!wantAssetData.getIsDivisible()
&& createOrderTransactionData.getAmount().multiply(createOrderTransactionData.getPrice()).stripTrailingZeros().scale() > 0)
return ValidationResult.INVALID_RETURN;
}
return ValidationResult.OK;
}

View File

@ -1,24 +1,38 @@
package org.qora.test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.*;
import java.net.URL;
import java.security.Security;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.bitcoinj.core.Base58;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.qora.account.PrivateKeyAccount;
import org.qora.api.resource.TransactionsResource.ConfirmationStatus;
import org.qora.block.BlockChain;
import org.qora.block.BlockGenerator;
import org.qora.data.transaction.TransactionData;
import org.qora.repository.DataException;
import org.qora.repository.Repository;
import org.qora.repository.RepositoryFactory;
import org.qora.repository.RepositoryManager;
import org.qora.repository.hsqldb.HSQLDBRepositoryFactory;
import org.qora.settings.Settings;
import org.qora.test.common.TestAccount;
import org.qora.transaction.Transaction;
import org.qora.transaction.Transaction.ValidationResult;
public class Common {
public static final String testConnectionUrl = "jdbc:hsqldb:mem:testdb";
// public static final String testConnectionUrl = "jdbc:hsqldb:file:testdb/blockchain;create=true";
public static final String testSettingsFilename = "test-settings.json";
public static final byte[] v2testPrivateKey = Base58.decode("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6");
@ -33,7 +47,53 @@ public class Common {
Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
// Load/check settings, which potentially sets up blockchain config, etc.
Settings.fileInstance(testSettingsFilename);
URL testSettingsUrl = Common.class.getClassLoader().getResource(testSettingsFilename);
assertNotNull("Test settings JSON file not found", testSettingsUrl);
Settings.fileInstance(testSettingsUrl.getPath());
}
public static Map<String, TransactionData> lastTransactionByAddress;
public static Map<String, TestAccount> testAccountsByName = new HashMap<>();
static {
testAccountsByName.put("main", new TestAccount("A9MNsATgQgruBUjxy2rjWY36Yf19uRioKZbiLFT2P7c6"));
testAccountsByName.put("dummy", new TestAccount("AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot"));
}
public static PrivateKeyAccount getTestAccount(Repository repository, String name) {
return new PrivateKeyAccount(repository, testAccountsByName.get(name).getSeed());
}
public static void resetBlockchain() throws DataException {
BlockChain.validate();
lastTransactionByAddress = new HashMap<>();
try (Repository repository = RepositoryManager.getRepository()) {
for (TestAccount account : testAccountsByName.values()) {
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, account.getAddress(), ConfirmationStatus.BOTH, 1, null, true);
assertFalse("Test account should have existing transaction", signatures.isEmpty());
TransactionData transactionData = repository.getTransactionRepository().fromSignature(signatures.get(0));
lastTransactionByAddress.put(account.getAddress(), transactionData);
}
}
}
public static void signAndForge(Repository repository, TransactionData transactionData, PrivateKeyAccount signingAccount) throws DataException {
Transaction transaction = Transaction.fromData(repository, transactionData);
transaction.sign(signingAccount);
// Add to unconfirmed
assertTrue("Transaction's signature should be valid", transaction.isSignatureValid());
ValidationResult result = transaction.isValidUnconfirmed();
assertEquals("Transaction invalid", ValidationResult.OK, result);
repository.getTransactionRepository().save(transactionData);
repository.getTransactionRepository().unconfirmTransaction(transactionData);
repository.saveChanges();
// Generate block
BlockGenerator.generateTestingBlock(repository, signingAccount);
}
@BeforeClass
@ -47,7 +107,7 @@ public class Common {
RepositoryManager.closeRepositoryFactory();
}
public static void assetEmptyBlockchain(Repository repository) throws DataException {
public static void assertEmptyBlockchain(Repository repository) throws DataException {
assertEquals("Blockchain should be empty for this test", 0, repository.getBlockRepository().getBlockchainHeight());
}

View File

@ -0,0 +1,54 @@
package org.qora.test.assets;
import org.junit.Before;
import org.junit.Test;
import org.qora.asset.Asset;
import org.qora.repository.DataException;
import org.qora.repository.Repository;
import org.qora.repository.RepositoryManager;
import org.qora.test.Common;
import org.qora.test.common.AssetUtils;
import static org.junit.Assert.*;
import java.math.BigDecimal;
public class TradingTests extends Common {
@Before
public void beforeTest() throws DataException {
Common.resetBlockchain();
}
/*
* Check full matching of orders with prices that
* can't be represented in floating binary.
*
* For example, sell 1 GOLD for 12 QORA so
* price is 1/12 or 0.083...
*/
@Test
public void testNonExactFraction() throws DataException {
final long qoraAmount = 24L;
final long otherAmount = 2L;
final long transferAmount = 100L;
try (Repository repository = RepositoryManager.getRepository()) {
// Create initial order
AssetUtils.createOrder(repository, "main", Asset.QORA, AssetUtils.testAssetId, qoraAmount, otherAmount);
// Give 100 asset to other account so they can create order
AssetUtils.transferAsset(repository, "main", "dummy", AssetUtils.testAssetId, transferAmount);
// Create matching order
AssetUtils.createOrder(repository, "dummy", AssetUtils.testAssetId, Asset.QORA, otherAmount, qoraAmount);
// Check balances to check expected outcome
BigDecimal actualAmount = Common.getTestAccount(repository, "dummy").getConfirmedBalance(AssetUtils.testAssetId);
BigDecimal expectedAmount = BigDecimal.valueOf(transferAmount - otherAmount).setScale(8);
assertTrue("dummy account's asset balance incorrect", actualAmount.compareTo(expectedAmount) == 0);
}
}
}

View File

@ -0,0 +1,46 @@
package org.qora.test.common;
import java.math.BigDecimal;
import org.qora.account.PrivateKeyAccount;
import org.qora.data.transaction.CreateAssetOrderTransactionData;
import org.qora.data.transaction.TransactionData;
import org.qora.data.transaction.TransferAssetTransactionData;
import org.qora.group.Group;
import org.qora.repository.DataException;
import org.qora.repository.Repository;
import org.qora.test.Common;
public class AssetUtils {
public static final int txGroupId = Group.NO_GROUP;
public static final BigDecimal fee = BigDecimal.ONE.setScale(8);
public static final long testAssetId = 1L;
public static void transferAsset(Repository repository, String fromAccountName, String toAccountName, long assetId, long amount) throws DataException {
PrivateKeyAccount fromAccount = Common.getTestAccount(repository, fromAccountName);
PrivateKeyAccount toAccount = Common.getTestAccount(repository, toAccountName);
byte[] reference = fromAccount.getLastReference();
long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000;
TransactionData transferAssetTransactionData = new TransferAssetTransactionData(timestamp, AssetUtils.txGroupId, reference, fromAccount.getPublicKey(), toAccount.getAddress(), BigDecimal.valueOf(amount), assetId, AssetUtils.fee);
Common.signAndForge(repository, transferAssetTransactionData, fromAccount);
}
public static void createOrder(Repository repository, String accountName, long haveAssetId, long wantAssetId, long haveAmount, long wantAmount) throws DataException {
PrivateKeyAccount account = Common.getTestAccount(repository, accountName);
byte[] reference = account.getLastReference();
long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1000;
BigDecimal amount = BigDecimal.valueOf(haveAmount);
BigDecimal price = BigDecimal.valueOf(wantAmount);
// Note: "price" is not the same in V2 as in V1
TransactionData initialOrderTransactionData = new CreateAssetOrderTransactionData(timestamp, txGroupId, reference, account.getPublicKey(), haveAssetId, wantAssetId, amount, price, fee);
Common.signAndForge(repository, initialOrderTransactionData, account);
}
}

View File

@ -0,0 +1,10 @@
package org.qora.test.common;
import org.qora.account.PrivateKeyAccount;
import org.qora.utils.Base58;
public class TestAccount extends PrivateKeyAccount {
public TestAccount(String privateKey) {
super(null, Base58.decode(privateKey));
}
}

View File

@ -7,16 +7,17 @@
"blockTimestampMargin": 500,
"maxBytesPerUnitFee": 1024,
"unitFee": "0.1",
"requireGroupForApproval": true,
"defaultGroupId": 2,
"requireGroupForApproval": false,
"genesisInfo": {
"version": 4,
"timestamp": 0,
"generatingBalance": "10000000",
"transactions": [
{ "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORA", "description": "QORA native coin", "quantity": 10000000000, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" },
{ "type": "ISSUE_ASSET", "owner": "QcFmNxSArv5tWEzCtTKb2Lqc5QkKuQ7RNs", "assetName": "QORA", "description": "QORA native coin", "data": "", "quantity": 10000000000, "isDivisible": true, "fee": 0, "reference": "3Verk6ZKBJc3WTTVfxFC9icSjKdM8b92eeJEpJP8qNizG4ZszNFq8wdDYdSjJXq2iogDFR1njyhsBdVpbvDfjzU7" },
{ "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "9876543210.12345678", "fee": 0 },
{ "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 }
{ "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000", "fee": 0 },
{ "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 },
{ "type": "ISSUE_ASSET", "owner": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "assetName": "test", "description": "test asset", "data": "", "quantity": 1000, "isDivisible": true, "fee": 0 }
]
},
"featureTriggers": {