diff --git a/src/main/java/org/qortal/asset/Asset.java b/src/main/java/org/qortal/asset/Asset.java index eadc4778..53465423 100644 --- a/src/main/java/org/qortal/asset/Asset.java +++ b/src/main/java/org/qortal/asset/Asset.java @@ -8,6 +8,7 @@ import org.qortal.data.transaction.UpdateAssetTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.utils.Amounts; +import org.qortal.utils.Unicode; public class Asset { @@ -23,6 +24,7 @@ public class Asset { // Other useful constants + public static final int MIN_NAME_SIZE = 3; public static final int MAX_NAME_SIZE = 40; public static final int MAX_DESCRIPTION_SIZE = 4000; public static final int MAX_DATA_SIZE = 400000; @@ -49,8 +51,8 @@ public class Asset { this.assetData = new AssetData(ownerAddress, issueAssetTransactionData.getAssetName(), issueAssetTransactionData.getDescription(), issueAssetTransactionData.getQuantity(), issueAssetTransactionData.isDivisible(), issueAssetTransactionData.getData(), - issueAssetTransactionData.isUnspendable(), - issueAssetTransactionData.getTxGroupId(), issueAssetTransactionData.getSignature()); + issueAssetTransactionData.isUnspendable(), issueAssetTransactionData.getTxGroupId(), + issueAssetTransactionData.getSignature(), issueAssetTransactionData.getReducedAssetName()); } public Asset(Repository repository, long assetId) throws DataException { @@ -66,6 +68,10 @@ public class Asset { // Processing + public static String reduceName(String assetName) { + return Unicode.sanitize(assetName); + } + public void issue() throws DataException { this.repository.getAssetRepository().save(this.assetData); } diff --git a/src/main/java/org/qortal/data/asset/AssetData.java b/src/main/java/org/qortal/data/asset/AssetData.java index 70e8afaf..2477f751 100644 --- a/src/main/java/org/qortal/data/asset/AssetData.java +++ b/src/main/java/org/qortal/data/asset/AssetData.java @@ -20,11 +20,17 @@ public class AssetData { private String data; private boolean isUnspendable; private int creationGroupId; + // No need to expose this via API @XmlTransient @Schema(hidden = true) private byte[] reference; + // For internal use only + @XmlTransient + @Schema(hidden = true) + private String reducedAssetName; + // Constructors // necessary for JAXB serialization @@ -32,7 +38,8 @@ public class AssetData { } // NOTE: key is Long, not long, because it can be null if asset ID/key not yet assigned. - public AssetData(Long assetId, String owner, String name, String description, long quantity, boolean isDivisible, String data, boolean isUnspendable, int creationGroupId, byte[] reference) { + public AssetData(Long assetId, String owner, String name, String description, long quantity, boolean isDivisible, + String data, boolean isUnspendable, int creationGroupId, byte[] reference, String reducedAssetName) { this.assetId = assetId; this.owner = owner; this.name = name; @@ -43,11 +50,13 @@ public class AssetData { this.isUnspendable = isUnspendable; this.creationGroupId = creationGroupId; this.reference = reference; + this.reducedAssetName = reducedAssetName; } // New asset with unassigned assetId - public AssetData(String owner, String name, String description, long quantity, boolean isDivisible, String data, boolean isUnspendable, int creationGroupId, byte[] reference) { - this(null, owner, name, description, quantity, isDivisible, data, isUnspendable, creationGroupId, reference); + public AssetData(String owner, String name, String description, long quantity, boolean isDivisible, String data, + boolean isUnspendable, int creationGroupId, byte[] reference, String reducedAssetName) { + this(null, owner, name, description, quantity, isDivisible, data, isUnspendable, creationGroupId, reference, reducedAssetName); } // Getters/Setters @@ -112,4 +121,12 @@ public class AssetData { this.reference = reference; } + public String getReducedAssetName() { + return this.reducedAssetName; + } + + public void setReducedAssetName(String reducedAssetName) { + this.reducedAssetName = reducedAssetName; + } + } diff --git a/src/main/java/org/qortal/data/transaction/IssueAssetTransactionData.java b/src/main/java/org/qortal/data/transaction/IssueAssetTransactionData.java index e9a932bf..f86b4071 100644 --- a/src/main/java/org/qortal/data/transaction/IssueAssetTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/IssueAssetTransactionData.java @@ -3,10 +3,12 @@ package org.qortal.data.transaction; import javax.xml.bind.Unmarshaller; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlTransient; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue; import org.qortal.account.NullAccount; +import org.qortal.asset.Asset; import org.qortal.block.GenesisBlock; import org.qortal.transaction.Transaction.TransactionType; @@ -48,6 +50,11 @@ public class IssueAssetTransactionData extends TransactionData { @Schema(description = "whether non-owner holders of asset are barred from using asset", example = "false") private boolean isUnspendable; + // For internal use + @Schema(hidden = true) + @XmlTransient + private String reducedAssetName; + // Constructors // For JAXB @@ -64,12 +71,20 @@ public class IssueAssetTransactionData extends TransactionData { if (parent instanceof GenesisBlock.GenesisInfo && this.issuerPublicKey == null) this.issuerPublicKey = NullAccount.PUBLIC_KEY; + /* + * If we're being constructed as part of the genesis block info inside blockchain config + * then we need to construct 'reduced' form of asset name. + */ + if (parent instanceof GenesisBlock.GenesisInfo && this.reducedAssetName == null) + this.reducedAssetName = Asset.reduceName(this.assetName); + this.creatorPublicKey = this.issuerPublicKey; } /** From repository */ - public IssueAssetTransactionData(BaseTransactionData baseTransactionData, - Long assetId, String assetName, String description, long quantity, boolean isDivisible, String data, boolean isUnspendable) { + public IssueAssetTransactionData(BaseTransactionData baseTransactionData, Long assetId, String assetName, + String description, long quantity, boolean isDivisible, String data, boolean isUnspendable, + String reducedAssetName) { super(TransactionType.ISSUE_ASSET, baseTransactionData); this.assetId = assetId; @@ -80,12 +95,13 @@ public class IssueAssetTransactionData extends TransactionData { this.isDivisible = isDivisible; this.data = data; this.isUnspendable = isUnspendable; + this.reducedAssetName = reducedAssetName; } /** From network/API */ public IssueAssetTransactionData(BaseTransactionData baseTransactionData, String assetName, String description, long quantity, boolean isDivisible, String data, boolean isUnspendable) { - this(baseTransactionData, null, assetName, description, quantity, isDivisible, data, isUnspendable); + this(baseTransactionData, null, assetName, description, quantity, isDivisible, data, isUnspendable, null); } // Getters/Setters @@ -126,4 +142,12 @@ public class IssueAssetTransactionData extends TransactionData { return this.isUnspendable; } + public String getReducedAssetName() { + return this.reducedAssetName; + } + + public void setReducedAssetName(String reducedAssetName) { + this.reducedAssetName = reducedAssetName; + } + } diff --git a/src/main/java/org/qortal/repository/AssetRepository.java b/src/main/java/org/qortal/repository/AssetRepository.java index 94b1ceb0..fb516880 100644 --- a/src/main/java/org/qortal/repository/AssetRepository.java +++ b/src/main/java/org/qortal/repository/AssetRepository.java @@ -19,6 +19,8 @@ public interface AssetRepository { public boolean assetExists(String assetName) throws DataException; + public boolean reducedAssetNameExists(String reducedAssetName) throws DataException; + public List getAllAssets(Integer limit, Integer offset, Boolean reverse) throws DataException; public default List getAllAssets() throws DataException { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAssetRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAssetRepository.java index 4257c6c2..6610a4f3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAssetRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAssetRepository.java @@ -25,7 +25,9 @@ public class HSQLDBAssetRepository implements AssetRepository { @Override public AssetData fromAssetId(long assetId) throws DataException { - String sql = "SELECT owner, asset_name, description, quantity, is_divisible, data, is_unspendable, creation_group_id, reference FROM Assets WHERE asset_id = ?"; + String sql = "SELECT owner, asset_name, description, quantity, is_divisible, data, " + + "is_unspendable, creation_group_id, reference, reduced_asset_name " + + "FROM Assets WHERE asset_id = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, assetId)) { if (resultSet == null) @@ -40,9 +42,10 @@ public class HSQLDBAssetRepository implements AssetRepository { boolean isUnspendable = resultSet.getBoolean(7); int creationGroupId = resultSet.getInt(8); byte[] reference = resultSet.getBytes(9); + String reducedAssetName = resultSet.getString(10); return new AssetData(assetId, owner, assetName, description, quantity, isDivisible, data, isUnspendable, - creationGroupId, reference); + creationGroupId, reference, reducedAssetName); } catch (SQLException e) { throw new DataException("Unable to fetch asset from repository", e); } @@ -50,7 +53,9 @@ public class HSQLDBAssetRepository implements AssetRepository { @Override public AssetData fromAssetName(String assetName) throws DataException { - String sql = "SELECT owner, asset_id, description, quantity, is_divisible, data, is_unspendable, creation_group_id, reference FROM Assets WHERE asset_name = ?"; + String sql = "SELECT owner, asset_id, description, quantity, is_divisible, data, " + + "is_unspendable, creation_group_id, reference, reduced_asset_name " + + "FROM Assets WHERE asset_name = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, assetName)) { if (resultSet == null) @@ -65,9 +70,10 @@ public class HSQLDBAssetRepository implements AssetRepository { boolean isUnspendable = resultSet.getBoolean(7); int creationGroupId = resultSet.getInt(8); byte[] reference = resultSet.getBytes(9); + String reducedAssetName = resultSet.getString(10); return new AssetData(assetId, owner, assetName, description, quantity, isDivisible, data, isUnspendable, - creationGroupId, reference); + creationGroupId, reference, reducedAssetName); } catch (SQLException e) { throw new DataException("Unable to fetch asset from repository", e); } @@ -91,10 +97,20 @@ public class HSQLDBAssetRepository implements AssetRepository { } } + @Override + public boolean reducedAssetNameExists(String reducedAssetName) throws DataException { + try { + return this.repository.exists("Assets", "reduced_asset_name = ?", reducedAssetName); + } catch (SQLException e) { + throw new DataException("Unable to check for asset in repository", e); + } + } + @Override public List getAllAssets(Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(256); - sql.append("SELECT asset_id, owner, asset_name, description, quantity, is_divisible, data, is_unspendable, creation_group_id, reference " + sql.append("SELECT asset_id, owner, asset_name, description, quantity, is_divisible, data, " + + "is_unspendable, creation_group_id, reference, reduced_asset_name " + "FROM Assets ORDER BY asset_id"); if (reverse != null && reverse) sql.append(" DESC"); @@ -118,9 +134,10 @@ public class HSQLDBAssetRepository implements AssetRepository { boolean isUnspendable = resultSet.getBoolean(8); int creationGroupId = resultSet.getInt(9); byte[] reference = resultSet.getBytes(10); + String reducedAssetName = resultSet.getString(11); assets.add(new AssetData(assetId, owner, assetName, description, quantity, isDivisible, data, - isUnspendable,creationGroupId, reference)); + isUnspendable,creationGroupId, reference, reducedAssetName)); } while (resultSet.next()); return assets; @@ -161,7 +178,8 @@ public class HSQLDBAssetRepository implements AssetRepository { .bind("asset_name", assetData.getName()).bind("description", assetData.getDescription()) .bind("quantity", assetData.getQuantity()).bind("is_divisible", assetData.isDivisible()) .bind("data", assetData.getData()).bind("is_unspendable", assetData.isUnspendable()) - .bind("creation_group_id", assetData.getCreationGroupId()).bind("reference", assetData.getReference()); + .bind("creation_group_id", assetData.getCreationGroupId()).bind("reference", assetData.getReference()) + .bind("reduced_asset_name", assetData.getReducedAssetName()); try { saveHelper.execute(this.repository); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 8cfa8f06..42447232 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -355,9 +355,11 @@ public class HSQLDBDatabaseUpdates { + "quantity BIGINT NOT NULL, is_divisible BOOLEAN NOT NULL, " + "is_unspendable BOOLEAN NOT NULL DEFAULT FALSE, creation_group_id GroupID NOT NULL DEFAULT 0, " + "reference Signature NOT NULL, data AssetData NOT NULL DEFAULT '', " - + "PRIMARY KEY (asset_id))"); + + "reduced_asset_name AssetName NOT NULL, PRIMARY KEY (asset_id))"); // For when a user wants to lookup an asset by name stmt.execute("CREATE INDEX AssetNameIndex on Assets (asset_name)"); + // For looking up assets by 'reduced' name + stmt.execute("CREATE INDEX AssetReducedNameIndex on Assets (reduced_asset_name)"); // We need a corresponding trigger to make sure new asset_id values are assigned sequentially start from 0 stmt.execute("CREATE TRIGGER Asset_ID_Trigger BEFORE INSERT ON Assets " @@ -386,7 +388,8 @@ public class HSQLDBDatabaseUpdates { // Issue Asset Transactions stmt.execute("CREATE TABLE IssueAssetTransactions (signature Signature, issuer QortalPublicKey NOT NULL, asset_name AssetName NOT NULL, " + "description GenericDescription NOT NULL, quantity BIGINT NOT NULL, is_divisible BOOLEAN NOT NULL, asset_id AssetID, " - + "is_unspendable BOOLEAN NOT NULL, data AssetData NOT NULL DEFAULT '', " + TRANSACTION_KEYS + ")"); + + "is_unspendable BOOLEAN NOT NULL, data AssetData NOT NULL DEFAULT '', reduced_asset_name AssetName NOT NULL, " + + TRANSACTION_KEYS + ")"); // Transfer Asset Transactions stmt.execute("CREATE TABLE TransferAssetTransactions (signature Signature, sender QortalPublicKey NOT NULL, recipient QortalAddress NOT NULL, " diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBIssueAssetTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBIssueAssetTransactionRepository.java index 217cb654..2386ef95 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBIssueAssetTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBIssueAssetTransactionRepository.java @@ -17,7 +17,8 @@ public class HSQLDBIssueAssetTransactionRepository extends HSQLDBTransactionRepo } TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { - String sql = "SELECT asset_name, description, quantity, is_divisible, data, is_unspendable, asset_id FROM IssueAssetTransactions WHERE signature = ?"; + String sql = "SELECT asset_name, description, quantity, is_divisible, data, is_unspendable, asset_id, reduced_asset_name " + + "FROM IssueAssetTransactions WHERE signature = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { if (resultSet == null) @@ -35,8 +36,10 @@ public class HSQLDBIssueAssetTransactionRepository extends HSQLDBTransactionRepo if (assetId == 0 && resultSet.wasNull()) assetId = null; + String reducedAssetName = resultSet.getString(8); + return new IssueAssetTransactionData(baseTransactionData, assetId, assetName, description, quantity, isDivisible, - data, isUnspendable); + data, isUnspendable, reducedAssetName); } catch (SQLException e) { throw new DataException("Unable to fetch issue asset transaction from repository", e); } @@ -49,7 +52,7 @@ public class HSQLDBIssueAssetTransactionRepository extends HSQLDBTransactionRepo HSQLDBSaver saveHelper = new HSQLDBSaver("IssueAssetTransactions"); saveHelper.bind("signature", issueAssetTransactionData.getSignature()).bind("issuer", issueAssetTransactionData.getIssuerPublicKey()) - .bind("asset_name", issueAssetTransactionData.getAssetName()) + .bind("asset_name", issueAssetTransactionData.getAssetName()).bind("reduced_asset_name", issueAssetTransactionData.getReducedAssetName()) .bind("description", issueAssetTransactionData.getDescription()).bind("quantity", issueAssetTransactionData.getQuantity()) .bind("is_divisible", issueAssetTransactionData.isDivisible()).bind("data", issueAssetTransactionData.getData()) .bind("is_unspendable", issueAssetTransactionData.isUnspendable()).bind("asset_id", issueAssetTransactionData.getAssetId()); diff --git a/src/main/java/org/qortal/transaction/IssueAssetTransaction.java b/src/main/java/org/qortal/transaction/IssueAssetTransaction.java index e4c9cf63..66d635e2 100644 --- a/src/main/java/org/qortal/transaction/IssueAssetTransaction.java +++ b/src/main/java/org/qortal/transaction/IssueAssetTransaction.java @@ -7,9 +7,11 @@ import org.qortal.account.Account; import org.qortal.asset.Asset; import org.qortal.data.transaction.IssueAssetTransactionData; import org.qortal.data.transaction.TransactionData; +import org.qortal.naming.Name; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.utils.Amounts; +import org.qortal.utils.Unicode; import com.google.common.base.Utf8; @@ -40,15 +42,29 @@ public class IssueAssetTransaction extends Transaction { return this.getCreator(); } + private String getReducedAssetName() { + if (this.issueAssetTransactionData.getReducedAssetName() == null) { + String reducedAssetName = Name.reduceName(this.issueAssetTransactionData.getAssetName()); + this.issueAssetTransactionData.setReducedAssetName(reducedAssetName); + } + + return this.issueAssetTransactionData.getReducedAssetName(); + } + // Processing @Override public ValidationResult isValid() throws DataException { // Check name size bounds - int assetNameLength = Utf8.encodedLength(this.issueAssetTransactionData.getAssetName()); - if (assetNameLength < 1 || assetNameLength > Asset.MAX_NAME_SIZE) + String assetName = this.issueAssetTransactionData.getAssetName(); + int assetNameLength = Utf8.encodedLength(assetName); + if (assetNameLength < Asset.MIN_NAME_SIZE || assetNameLength > Asset.MAX_NAME_SIZE) return ValidationResult.INVALID_NAME_LENGTH; + // Check name is in normalized form (no leading/trailing whitespace, etc.) + if (!assetName.equals(Unicode.normalize(assetName))) + return ValidationResult.NAME_NOT_LOWER_CASE; + // Check description size bounds int assetDescriptionlength = Utf8.encodedLength(this.issueAssetTransactionData.getDescription()); if (assetDescriptionlength < 1 || assetDescriptionlength > Asset.MAX_DESCRIPTION_SIZE) @@ -74,13 +90,16 @@ public class IssueAssetTransaction extends Transaction { if (issuer.getConfirmedBalance(Asset.QORT) < this.issueAssetTransactionData.getFee()) return ValidationResult.NO_BALANCE; + // Fill in missing reduced name. Caller is likely to save this as next step. + getReducedAssetName(); + return ValidationResult.OK; } @Override public ValidationResult isProcessable() throws DataException { - // Check the asset name isn't already taken. - if (this.repository.getAssetRepository().assetExists(this.issueAssetTransactionData.getAssetName())) + // Check the name isn't already taken + if (this.repository.getAssetRepository().reducedAssetNameExists(getReducedAssetName())) return ValidationResult.ASSET_ALREADY_EXISTS; return ValidationResult.OK; diff --git a/src/test/java/org/qortal/test/assets/MiscTests.java b/src/test/java/org/qortal/test/assets/MiscTests.java index 6a1be14f..c676f94f 100644 --- a/src/test/java/org/qortal/test/assets/MiscTests.java +++ b/src/test/java/org/qortal/test/assets/MiscTests.java @@ -1,12 +1,21 @@ package org.qortal.test.assets; -import static org.junit.Assert.assertEquals; +import static org.junit.Assert.*; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.qortal.data.transaction.IssueAssetTransactionData; +import org.qortal.data.transaction.TransactionData; import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.test.common.Common; +import org.qortal.test.common.TestAccount; +import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.utils.Amounts; public class MiscTests extends Common { @@ -21,6 +30,32 @@ public class MiscTests extends Common { Common.orphanCheck(); } + @Test + public void testCreateAssetWithExistingName() throws DataException { + try (Repository repository = RepositoryManager.getRepository()) { + TestAccount alice = Common.getTestAccount(repository, "alice"); + + String assetName = "test-asset"; + String description = "description"; + long quantity = 12345678L; + boolean isDivisible = true; + String data = "{}"; + boolean isUnspendable = false; + + TransactionData transactionData = new IssueAssetTransactionData(TestTransaction.generateBase(alice), assetName, description, quantity, isDivisible, data, isUnspendable); + TransactionUtils.signAndMint(repository, transactionData, alice); + + String duplicateAssetName = "TEST-Ásset"; + transactionData = new IssueAssetTransactionData(TestTransaction.generateBase(alice), duplicateAssetName, description, quantity, isDivisible, data, isUnspendable); + + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(alice); + + ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be invalid", ValidationResult.OK != result); + } + } + @Test public void testCalcCommitmentWithRoundUp() throws DataException { long amount = 1234_87654321L;