mirror of
https://github.com/Qortal/qortal.git
synced 2025-03-30 09:05:52 +00:00
Much tidier code thanks to not having to pass Connection objects around as params. Also no need for two forms of the same method, one with Connection param, one without. Also corrected SQL-Transaction-related methods in DB, e.g. commit, rollback, etc. so they use the proper underlying JDBC methods.
376 lines
12 KiB
Java
376 lines
12 KiB
Java
package qora.transaction;
|
|
|
|
import java.io.ByteArrayOutputStream;
|
|
import java.io.IOException;
|
|
import java.math.BigDecimal;
|
|
import java.nio.ByteBuffer;
|
|
import java.nio.charset.Charset;
|
|
import java.sql.ResultSet;
|
|
import java.sql.SQLException;
|
|
import java.util.Arrays;
|
|
|
|
import org.json.simple.JSONObject;
|
|
|
|
import com.google.common.hash.HashCode;
|
|
import com.google.common.primitives.Ints;
|
|
import com.google.common.primitives.Longs;
|
|
|
|
import database.DB;
|
|
import database.NoDataFoundException;
|
|
import database.SaveHelper;
|
|
import qora.account.Account;
|
|
import qora.account.PublicKeyAccount;
|
|
import qora.assets.Asset;
|
|
import qora.block.Block;
|
|
import qora.block.BlockChain;
|
|
import qora.crypto.Crypto;
|
|
import utils.Base58;
|
|
import utils.ParseException;
|
|
import utils.Serialization;
|
|
|
|
public class MessageTransaction extends Transaction {
|
|
|
|
// Properties
|
|
protected int version;
|
|
protected PublicKeyAccount sender;
|
|
protected Account recipient;
|
|
protected Long assetId;
|
|
protected BigDecimal amount;
|
|
protected byte[] data;
|
|
protected boolean isText;
|
|
protected boolean isEncrypted;
|
|
|
|
// Property lengths
|
|
private static final int SENDER_LENGTH = 32;
|
|
private static final int AMOUNT_LENGTH = 8;
|
|
private static final int ASSET_ID_LENGTH = 8;
|
|
private static final int DATA_SIZE_LENGTH = 4;
|
|
private static final int IS_TEXT_LENGTH = 1;
|
|
private static final int IS_ENCRYPTED_LENGTH = 1;
|
|
private static final int TYPELESS_DATALESS_LENGTH_V1 = BASE_TYPELESS_LENGTH + SENDER_LENGTH + RECIPIENT_LENGTH + AMOUNT_LENGTH + DATA_SIZE_LENGTH
|
|
+ IS_TEXT_LENGTH + IS_ENCRYPTED_LENGTH;
|
|
private static final int TYPELESS_DATALESS_LENGTH_V3 = BASE_TYPELESS_LENGTH + SENDER_LENGTH + RECIPIENT_LENGTH + ASSET_ID_LENGTH + AMOUNT_LENGTH
|
|
+ DATA_SIZE_LENGTH + IS_TEXT_LENGTH + IS_ENCRYPTED_LENGTH;
|
|
|
|
// Other property lengths
|
|
private static final int MAX_DATA_SIZE = 4000;
|
|
|
|
// Constructors
|
|
public MessageTransaction(PublicKeyAccount sender, String recipient, Long assetId, BigDecimal amount, BigDecimal fee, byte[] data, boolean isText,
|
|
boolean isEncrypted, long timestamp, byte[] reference, byte[] signature) {
|
|
super(TransactionType.MESSAGE, fee, sender, timestamp, reference, signature);
|
|
|
|
this.version = Transaction.getVersionByTimestamp(this.timestamp);
|
|
this.sender = sender;
|
|
this.recipient = new Account(recipient);
|
|
|
|
if (assetId != null)
|
|
this.assetId = assetId;
|
|
else
|
|
this.assetId = Asset.QORA;
|
|
|
|
this.amount = amount;
|
|
this.data = data;
|
|
this.isText = isText;
|
|
this.isEncrypted = isEncrypted;
|
|
}
|
|
|
|
// Getters/Setters
|
|
|
|
public int getVersion() {
|
|
return this.version;
|
|
}
|
|
|
|
public Account getSender() {
|
|
return this.sender;
|
|
}
|
|
|
|
public Account getRecipient() {
|
|
return this.recipient;
|
|
}
|
|
|
|
public Long getAssetId() {
|
|
return this.assetId;
|
|
}
|
|
|
|
public BigDecimal getAmount() {
|
|
return this.amount;
|
|
}
|
|
|
|
public byte[] getData() {
|
|
return this.data;
|
|
}
|
|
|
|
public boolean isText() {
|
|
return this.isText;
|
|
}
|
|
|
|
public boolean isEncrypted() {
|
|
return this.isEncrypted;
|
|
}
|
|
|
|
// More information
|
|
|
|
public int getDataLength() {
|
|
if (this.version == 1)
|
|
return TYPE_LENGTH + TYPELESS_DATALESS_LENGTH_V1 + this.data.length;
|
|
else
|
|
return TYPE_LENGTH + TYPELESS_DATALESS_LENGTH_V3 + this.data.length;
|
|
}
|
|
|
|
// Load/Save
|
|
|
|
/**
|
|
* Construct MessageTransaction from DB using signature.
|
|
*
|
|
* @param signature
|
|
* @throws NoDataFoundException
|
|
* if no matching row found
|
|
* @throws SQLException
|
|
*/
|
|
protected MessageTransaction(byte[] signature) throws SQLException {
|
|
super(TransactionType.MESSAGE, signature);
|
|
|
|
ResultSet rs = DB.checkedExecute(
|
|
"SELECT version, sender, recipient, is_text, is_encrypted, amount, asset_id, data FROM MessageTransactions WHERE signature = ?", signature);
|
|
if (rs == null)
|
|
throw new NoDataFoundException();
|
|
|
|
this.version = rs.getInt(1);
|
|
this.sender = new PublicKeyAccount(DB.getResultSetBytes(rs.getBinaryStream(2), CREATOR_LENGTH));
|
|
this.recipient = new Account(rs.getString(3));
|
|
this.isText = rs.getBoolean(4);
|
|
this.isEncrypted = rs.getBoolean(5);
|
|
this.amount = rs.getBigDecimal(6).setScale(8);
|
|
this.assetId = rs.getLong(7);
|
|
this.data = DB.getResultSetBytes(rs.getBinaryStream(8));
|
|
}
|
|
|
|
/**
|
|
* Load MessageTransaction from DB using signature.
|
|
*
|
|
* @param signature
|
|
* @return MessageTransaction, or null if not found
|
|
* @throws SQLException
|
|
*/
|
|
public static MessageTransaction fromSignature(byte[] signature) throws SQLException {
|
|
try {
|
|
return new MessageTransaction(signature);
|
|
} catch (NoDataFoundException e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void save() throws SQLException {
|
|
super.save();
|
|
|
|
SaveHelper saveHelper = new SaveHelper("MessageTransactions");
|
|
saveHelper.bind("signature", this.signature).bind("version", this.version).bind("sender", this.sender.getPublicKey())
|
|
.bind("recipient", this.recipient.getAddress()).bind("is_text", this.isText).bind("is_encrypted", this.isEncrypted).bind("amount", this.amount)
|
|
.bind("asset_id", this.assetId).bind("data", this.data);
|
|
saveHelper.execute();
|
|
}
|
|
|
|
// Converters
|
|
|
|
protected static Transaction parse(ByteBuffer byteBuffer) throws ParseException {
|
|
if (byteBuffer.remaining() < TIMESTAMP_LENGTH)
|
|
throw new ParseException("Byte data too short for MessageTransaction");
|
|
|
|
long timestamp = byteBuffer.getLong();
|
|
int version = Transaction.getVersionByTimestamp(timestamp);
|
|
|
|
int minimumRemaining = version == 1 ? TYPELESS_DATALESS_LENGTH_V1 : TYPELESS_DATALESS_LENGTH_V3;
|
|
minimumRemaining -= TIMESTAMP_LENGTH; // Already read above
|
|
|
|
if (byteBuffer.remaining() < minimumRemaining)
|
|
throw new ParseException("Byte data too short for MessageTransaction");
|
|
|
|
byte[] reference = new byte[REFERENCE_LENGTH];
|
|
byteBuffer.get(reference);
|
|
|
|
PublicKeyAccount sender = Serialization.deserializePublicKey(byteBuffer);
|
|
String recipient = Serialization.deserializeRecipient(byteBuffer);
|
|
|
|
long assetId;
|
|
if (version == 1)
|
|
assetId = Asset.QORA;
|
|
else
|
|
assetId = byteBuffer.getLong();
|
|
|
|
BigDecimal amount = Serialization.deserializeBigDecimal(byteBuffer);
|
|
|
|
int dataSize = byteBuffer.getInt(0);
|
|
// Don't allow invalid dataSize here to avoid run-time issues
|
|
if (dataSize > MAX_DATA_SIZE)
|
|
throw new ParseException("MessageTransaction data size too large");
|
|
|
|
byte[] data = new byte[dataSize];
|
|
byteBuffer.get(data);
|
|
|
|
boolean isEncrypted = byteBuffer.get() != 0;
|
|
boolean isText = byteBuffer.get() != 0;
|
|
|
|
BigDecimal fee = Serialization.deserializeBigDecimal(byteBuffer);
|
|
|
|
byte[] signature = new byte[SIGNATURE_LENGTH];
|
|
byteBuffer.get(signature);
|
|
|
|
return new MessageTransaction(sender, recipient, assetId, amount, fee, data, isText, isEncrypted, timestamp, reference, signature);
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
@Override
|
|
public JSONObject toJSON() throws SQLException {
|
|
JSONObject json = getBaseJSON();
|
|
|
|
json.put("version", this.version);
|
|
json.put("sender", this.sender.getAddress());
|
|
json.put("senderPublicKey", HashCode.fromBytes(this.sender.getPublicKey()).toString());
|
|
json.put("recipient", this.recipient.getAddress());
|
|
json.put("amount", this.amount.toPlainString());
|
|
json.put("assetId", this.assetId);
|
|
json.put("isText", this.isText);
|
|
json.put("isEncrypted", this.isEncrypted);
|
|
|
|
// We can only show plain text as unencoded
|
|
if (this.isText && !this.isEncrypted)
|
|
json.put("data", new String(this.data, Charset.forName("UTF-8")));
|
|
else
|
|
json.put("data", HashCode.fromBytes(this.data).toString());
|
|
|
|
return json;
|
|
}
|
|
|
|
public byte[] toBytes() {
|
|
try {
|
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream(getDataLength());
|
|
bytes.write(Ints.toByteArray(this.type.value));
|
|
bytes.write(Longs.toByteArray(this.timestamp));
|
|
bytes.write(this.reference);
|
|
bytes.write(this.sender.getPublicKey());
|
|
bytes.write(Base58.decode(this.recipient.getAddress()));
|
|
|
|
if (this.version != 1)
|
|
bytes.write(Longs.toByteArray(this.assetId));
|
|
|
|
bytes.write(Serialization.serializeBigDecimal(this.amount));
|
|
|
|
bytes.write(Ints.toByteArray(this.data.length));
|
|
bytes.write(this.data);
|
|
|
|
bytes.write((byte) (this.isEncrypted ? 1 : 0));
|
|
bytes.write((byte) (this.isText ? 1 : 0));
|
|
|
|
bytes.write(Serialization.serializeBigDecimal(this.fee));
|
|
bytes.write(this.signature);
|
|
return bytes.toByteArray();
|
|
} catch (IOException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}
|
|
|
|
// Processing
|
|
|
|
public ValidationResult isValid() throws SQLException {
|
|
// Lowest cost checks first
|
|
|
|
// Are message transactions even allowed at this point?
|
|
if (this.version != Transaction.getVersionByTimestamp(this.timestamp))
|
|
return ValidationResult.NOT_YET_RELEASED;
|
|
|
|
if (BlockChain.getHeight() < Block.MESSAGE_RELEASE_HEIGHT)
|
|
return ValidationResult.NOT_YET_RELEASED;
|
|
|
|
// Check data length
|
|
if (this.data.length < 1 || this.data.length > MAX_DATA_SIZE)
|
|
return ValidationResult.INVALID_DATA_LENGTH;
|
|
|
|
// Check recipient is a valid address
|
|
if (!Crypto.isValidAddress(this.recipient.getAddress()))
|
|
return ValidationResult.INVALID_ADDRESS;
|
|
|
|
if (this.version == 1) {
|
|
// Check amount is positive (V1)
|
|
if (this.amount.compareTo(BigDecimal.ZERO) <= 0)
|
|
return ValidationResult.NEGATIVE_AMOUNT;
|
|
} else {
|
|
// Check amount is not negative (V3) as sending messages without a payment is OK
|
|
if (this.amount.compareTo(BigDecimal.ZERO) < 0)
|
|
return ValidationResult.NEGATIVE_AMOUNT;
|
|
}
|
|
|
|
// Check fee is positive
|
|
if (this.fee.compareTo(BigDecimal.ZERO) <= 0)
|
|
return ValidationResult.NEGATIVE_FEE;
|
|
|
|
// Check reference is correct
|
|
if (!Arrays.equals(this.sender.getLastReference(), this.reference))
|
|
return ValidationResult.INVALID_REFERENCE;
|
|
|
|
// Does asset exist? (This test not present in gen1)
|
|
if (this.assetId != Asset.QORA && !Asset.exists(this.assetId))
|
|
return ValidationResult.ASSET_DOES_NOT_EXIST;
|
|
|
|
// If asset is QORA then we need to check amount + fee in one go
|
|
if (this.assetId == Asset.QORA) {
|
|
// Check sender has enough funds for amount + fee in QORA
|
|
if (this.sender.getConfirmedBalance(Asset.QORA).compareTo(this.amount.add(this.fee)) == -1)
|
|
return ValidationResult.NO_BALANCE;
|
|
} else {
|
|
// Check sender has enough funds for amount in whatever asset
|
|
if (this.sender.getConfirmedBalance(this.assetId).compareTo(this.amount) == -1)
|
|
return ValidationResult.NO_BALANCE;
|
|
|
|
// Check sender has enough funds for fee in QORA
|
|
if (this.sender.getConfirmedBalance(Asset.QORA).compareTo(this.fee) == -1)
|
|
return ValidationResult.NO_BALANCE;
|
|
}
|
|
|
|
return ValidationResult.OK;
|
|
}
|
|
|
|
public void process() throws SQLException {
|
|
this.save();
|
|
|
|
// Update sender's balance due to amount
|
|
this.sender.setConfirmedBalance(this.assetId, this.sender.getConfirmedBalance(this.assetId).subtract(this.amount));
|
|
// Update sender's balance due to fee
|
|
this.sender.setConfirmedBalance(Asset.QORA, this.sender.getConfirmedBalance(Asset.QORA).subtract(this.fee));
|
|
|
|
// Update recipient's balance
|
|
this.recipient.setConfirmedBalance(this.assetId, this.recipient.getConfirmedBalance(this.assetId).add(this.amount));
|
|
|
|
// Update sender's reference
|
|
this.sender.setLastReference(this.signature);
|
|
|
|
// For QORA amounts only: if recipient has no reference yet, then this is their starting reference
|
|
if (this.assetId == Asset.QORA && this.recipient.getLastReference() == null)
|
|
this.recipient.setLastReference(this.signature);
|
|
}
|
|
|
|
public void orphan() throws SQLException {
|
|
this.delete();
|
|
|
|
// Update sender's balance due to amount
|
|
this.sender.setConfirmedBalance(this.assetId, this.sender.getConfirmedBalance(this.assetId).add(this.amount));
|
|
// Update sender's balance due to fee
|
|
this.sender.setConfirmedBalance(Asset.QORA, this.sender.getConfirmedBalance(Asset.QORA).add(this.fee));
|
|
|
|
// Update recipient's balance
|
|
this.recipient.setConfirmedBalance(this.assetId, this.recipient.getConfirmedBalance(this.assetId).subtract(this.amount));
|
|
|
|
// Update sender's reference
|
|
this.sender.setLastReference(this.reference);
|
|
|
|
/*
|
|
* For QORA amounts only: If recipient's last reference is this transaction's signature, then they can't have made any transactions of their own (which
|
|
* would have changed their last reference) thus this is their first reference so remove it.
|
|
*/
|
|
if (this.assetId == Asset.QORA && Arrays.equals(this.recipient.getLastReference(), this.signature))
|
|
this.recipient.setLastReference(null);
|
|
}
|
|
|
|
}
|