mirror of https://github.com/qortal/qortal
Browse Source
Chunk hashes are now stored off chain in a metadata file. The metadata file's hash is then included in the transaction. The main benefits of this approach are: 1. We no longer need to limit the total file size, because adding more chunks doesn't increase the transaction size. 2. This increases the chain capacity by a huge amount - a 512MB file would have previously increased the transaction size by 16kB, whereas it now requires only an additional 32 bytes. 3. We no longer need to use variable difficulty; every transaction is the same size and so the difficulty can be constant no matter how large the files are. 4. Additional metadata (such as title, description, and tags) can ultimately be stored in the metadata file, as apposed to using a separate transaction & resource. 5. There is also scope for adding hashes of individual files into the metadata file, if we ever wanted to allow single files to be requested without having to download and build the entire resource. Although this is unlikely to be available in the short term. The only real negative is that we now how to fetch the metadata file before we know anything about the chunks for a transaction. This seems to be quite a small trade off by comparison. Since we're not live yet, there is no backwards support for on-chain hashes, so a new data testchain will be required. This hasn't been tested outside of unit tests yet, so there will likely be several fixes needed before it is stable.qdn
CalDescent
3 years ago
22 changed files with 807 additions and 334 deletions
@ -0,0 +1,101 @@
|
||||
package org.qortal.arbitrary.metadata; |
||||
|
||||
import org.apache.logging.log4j.LogManager; |
||||
import org.apache.logging.log4j.Logger; |
||||
import org.qortal.repository.DataException; |
||||
|
||||
import java.io.BufferedWriter; |
||||
import java.io.File; |
||||
import java.io.FileWriter; |
||||
import java.io.IOException; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.nio.file.Paths; |
||||
|
||||
/** |
||||
* ArbitraryDataQortalMetadata |
||||
* |
||||
* This is a base class to handle reading and writing JSON to a .qortal folder |
||||
* within the supplied filePath. This is used when storing data against an existing |
||||
* arbitrary data file structure. |
||||
* |
||||
* It is not usable on its own; it must be subclassed, with three methods overridden: |
||||
* |
||||
* fileName() - the file name to use within the .qortal folder |
||||
* readJson() - code to unserialize the JSON file |
||||
* buildJson() - code to serialize the JSON file |
||||
* |
||||
*/ |
||||
public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata { |
||||
|
||||
protected static final Logger LOGGER = LogManager.getLogger(ArbitraryDataQortalMetadata.class); |
||||
|
||||
protected Path filePath; |
||||
protected Path qortalDirectoryPath; |
||||
|
||||
protected String jsonString; |
||||
|
||||
public ArbitraryDataQortalMetadata(Path filePath) { |
||||
super(filePath); |
||||
|
||||
this.qortalDirectoryPath = Paths.get(filePath.toString(), ".qortal"); |
||||
} |
||||
|
||||
protected String fileName() { |
||||
// To be overridden
|
||||
return null; |
||||
} |
||||
|
||||
protected void readJson() throws DataException { |
||||
// To be overridden
|
||||
} |
||||
|
||||
protected void buildJson() { |
||||
// To be overridden
|
||||
} |
||||
|
||||
|
||||
@Override |
||||
public void read() throws IOException, DataException { |
||||
this.loadJson(); |
||||
this.readJson(); |
||||
} |
||||
|
||||
@Override |
||||
public void write() throws IOException, DataException { |
||||
this.buildJson(); |
||||
this.createParentDirectories(); |
||||
this.createQortalDirectory(); |
||||
|
||||
Path patchPath = Paths.get(this.qortalDirectoryPath.toString(), this.fileName()); |
||||
BufferedWriter writer = new BufferedWriter(new FileWriter(patchPath.toString())); |
||||
writer.write(this.jsonString); |
||||
writer.close(); |
||||
} |
||||
|
||||
@Override |
||||
protected void loadJson() throws IOException { |
||||
Path path = Paths.get(this.qortalDirectoryPath.toString(), this.fileName()); |
||||
File patchFile = new File(path.toString()); |
||||
if (!patchFile.exists()) { |
||||
throw new IOException(String.format("Patch file doesn't exist: %s", path.toString())); |
||||
} |
||||
|
||||
this.jsonString = new String(Files.readAllBytes(path)); |
||||
} |
||||
|
||||
|
||||
protected void createQortalDirectory() throws DataException { |
||||
try { |
||||
Files.createDirectories(this.qortalDirectoryPath); |
||||
} catch (IOException e) { |
||||
throw new DataException("Unable to create .qortal directory"); |
||||
} |
||||
} |
||||
|
||||
|
||||
public String getJsonString() { |
||||
return this.jsonString; |
||||
} |
||||
|
||||
} |
@ -0,0 +1,78 @@
|
||||
package org.qortal.arbitrary.metadata; |
||||
|
||||
import org.json.JSONArray; |
||||
import org.json.JSONObject; |
||||
import org.qortal.repository.DataException; |
||||
import org.qortal.utils.Base58; |
||||
|
||||
import java.nio.file.Path; |
||||
import java.util.ArrayList; |
||||
import java.util.Arrays; |
||||
import java.util.List; |
||||
|
||||
public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { |
||||
|
||||
private List<byte[]> chunks; |
||||
|
||||
public ArbitraryDataTransactionMetadata(Path filePath) { |
||||
super(filePath); |
||||
|
||||
} |
||||
|
||||
@Override |
||||
protected void readJson() throws DataException { |
||||
if (this.jsonString == null) { |
||||
throw new DataException("Transaction metadata JSON string is null"); |
||||
} |
||||
|
||||
List<byte[]> chunksList = new ArrayList<>(); |
||||
JSONObject cache = new JSONObject(this.jsonString); |
||||
if (cache.has("chunks")) { |
||||
JSONArray chunks = cache.getJSONArray("chunks"); |
||||
if (chunks != null) { |
||||
for (int i=0; i<chunks.length(); i++) { |
||||
String chunk = chunks.getString(i); |
||||
if (chunk != null) { |
||||
chunksList.add(Base58.decode(chunk)); |
||||
} |
||||
} |
||||
} |
||||
this.chunks = chunksList; |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
protected void buildJson() { |
||||
JSONObject outer = new JSONObject(); |
||||
|
||||
JSONArray chunks = new JSONArray(); |
||||
if (this.chunks != null) { |
||||
for (byte[] chunk : this.chunks) { |
||||
chunks.put(Base58.encode(chunk)); |
||||
} |
||||
} |
||||
outer.put("chunks", chunks); |
||||
|
||||
this.jsonString = outer.toString(2); |
||||
LOGGER.trace("Transaction metadata: {}", this.jsonString); |
||||
} |
||||
|
||||
|
||||
public void setChunks(List<byte[]> chunks) { |
||||
this.chunks = chunks; |
||||
} |
||||
|
||||
public List<byte[]> getChunks() { |
||||
return this.chunks; |
||||
} |
||||
|
||||
public boolean containsChunk(byte[] chunk) { |
||||
for (byte[] c : this.chunks) { |
||||
if (Arrays.equals(c, chunk)) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
} |
@ -0,0 +1,136 @@
|
||||
package org.qortal.test.arbitrary; |
||||
|
||||
import org.junit.Before; |
||||
import org.junit.Test; |
||||
import org.qortal.account.PrivateKeyAccount; |
||||
import org.qortal.arbitrary.ArbitraryDataDigest; |
||||
import org.qortal.arbitrary.ArbitraryDataFile; |
||||
import org.qortal.arbitrary.ArbitraryDataFile.*; |
||||
import org.qortal.arbitrary.ArbitraryDataReader; |
||||
import org.qortal.arbitrary.ArbitraryDataTransactionBuilder; |
||||
import org.qortal.arbitrary.exception.MissingDataException; |
||||
import org.qortal.arbitrary.misc.Service; |
||||
import org.qortal.data.transaction.ArbitraryTransactionData; |
||||
import org.qortal.data.transaction.RegisterNameTransactionData; |
||||
import org.qortal.repository.DataException; |
||||
import org.qortal.repository.Repository; |
||||
import org.qortal.repository.RepositoryManager; |
||||
import org.qortal.test.common.BlockUtils; |
||||
import org.qortal.test.common.Common; |
||||
import org.qortal.test.common.TransactionUtils; |
||||
import org.qortal.test.common.transaction.TestTransaction; |
||||
import org.qortal.transaction.Transaction; |
||||
import org.qortal.utils.Base58; |
||||
|
||||
import java.io.BufferedWriter; |
||||
import java.io.File; |
||||
import java.io.FileWriter; |
||||
import java.io.IOException; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.nio.file.Paths; |
||||
import java.util.Random; |
||||
|
||||
import static org.junit.Assert.*; |
||||
|
||||
public class ArbitraryTransactionMetadataTests extends Common { |
||||
|
||||
@Before |
||||
public void beforeTest() throws DataException { |
||||
Common.useDefaultSettings(); |
||||
} |
||||
|
||||
@Test |
||||
public void testMultipleChunks() throws DataException, IOException, MissingDataException { |
||||
try (final Repository repository = RepositoryManager.getRepository()) { |
||||
PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); |
||||
String publicKey58 = Base58.encode(alice.getPublicKey()); |
||||
String name = "TEST"; // Can be anything for this test
|
||||
String identifier = null; // Not used for this test
|
||||
Service service = Service.WEBSITE; // Can be anything for this test
|
||||
int chunkSize = 100; |
||||
int dataLength = 900; // Actual data length will be longer due to encryption
|
||||
|
||||
// Register the name to Alice
|
||||
RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); |
||||
TransactionUtils.signAndMint(repository, transactionData, alice); |
||||
|
||||
// Create PUT transaction
|
||||
Path path1 = generateRandomDataPath(dataLength); |
||||
ArbitraryDataFile arbitraryDataFile = this.createAndMintTxn(repository, publicKey58, path1, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize); |
||||
|
||||
// Check the chunk count is correct
|
||||
assertEquals(10, arbitraryDataFile.chunkCount()); |
||||
|
||||
// Now build the latest data state for this name
|
||||
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ResourceIdType.NAME, service, identifier); |
||||
arbitraryDataReader.loadSynchronously(true); |
||||
Path initialLayerPath = arbitraryDataReader.getFilePath(); |
||||
ArbitraryDataDigest initialLayerDigest = new ArbitraryDataDigest(initialLayerPath); |
||||
initialLayerDigest.compute(); |
||||
|
||||
// Its directory hash should match the original directory hash
|
||||
ArbitraryDataDigest path1Digest = new ArbitraryDataDigest(path1); |
||||
path1Digest.compute(); |
||||
assertEquals(path1Digest.getHash58(), initialLayerDigest.getHash58()); |
||||
} |
||||
} |
||||
|
||||
|
||||
private Path generateRandomDataPath(int length) throws IOException { |
||||
// Create a file in a random temp directory
|
||||
Path tempDir = Files.createTempDirectory("generateRandomDataPath"); |
||||
File file = new File(Paths.get(tempDir.toString(), "file.txt").toString()); |
||||
file.deleteOnExit(); |
||||
|
||||
// Write a random string to the file
|
||||
BufferedWriter file1Writer = new BufferedWriter(new FileWriter(file)); |
||||
String initialString = this.generateRandomString(length - 1); // -1 due to newline at EOF
|
||||
|
||||
// Add a newline every 50 chars
|
||||
// initialString = initialString.replaceAll("(.{50})", "$1\n");
|
||||
|
||||
file1Writer.write(initialString); |
||||
file1Writer.newLine(); |
||||
file1Writer.close(); |
||||
|
||||
return tempDir; |
||||
} |
||||
|
||||
private String generateRandomString(int length) { |
||||
int leftLimit = 48; // numeral '0'
|
||||
int rightLimit = 122; // letter 'z'
|
||||
Random random = new Random(); |
||||
|
||||
return random.ints(leftLimit, rightLimit + 1) |
||||
.filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97)) |
||||
.limit(length) |
||||
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) |
||||
.toString(); |
||||
} |
||||
|
||||
private ArbitraryDataFile createAndMintTxn(Repository repository, String publicKey58, Path path, String name, String identifier, |
||||
ArbitraryTransactionData.Method method, Service service, PrivateKeyAccount account, |
||||
int chunkSize) throws DataException { |
||||
|
||||
ArbitraryDataTransactionBuilder txnBuilder = new ArbitraryDataTransactionBuilder( |
||||
repository, publicKey58, path, name, method, service, identifier); |
||||
|
||||
txnBuilder.setChunkSize(chunkSize); |
||||
txnBuilder.build(); |
||||
txnBuilder.computeNonce(); |
||||
ArbitraryTransactionData transactionData = txnBuilder.getArbitraryTransactionData(); |
||||
Transaction.ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, account); |
||||
assertEquals(Transaction.ValidationResult.OK, result); |
||||
BlockUtils.mintBlock(repository); |
||||
|
||||
// We need a new ArbitraryDataFile instance because the files will have been moved to the signature's folder
|
||||
byte[] hash = txnBuilder.getArbitraryDataFile().getHash(); |
||||
byte[] signature = transactionData.getSignature(); |
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); |
||||
arbitraryDataFile.setMetadataHash(transactionData.getMetadataHash()); |
||||
|
||||
return arbitraryDataFile; |
||||
} |
||||
|
||||
} |
@ -1,70 +0,0 @@
|
||||
package org.qortal.test.arbitrary; |
||||
|
||||
import org.junit.Before; |
||||
import org.junit.Test; |
||||
|
||||
import org.qortal.arbitrary.misc.Service; |
||||
import org.qortal.data.PaymentData; |
||||
import org.qortal.data.transaction.ArbitraryTransactionData; |
||||
import org.qortal.repository.DataException; |
||||
import org.qortal.repository.Repository; |
||||
import org.qortal.repository.RepositoryManager; |
||||
import org.qortal.test.common.*; |
||||
import org.qortal.test.common.transaction.TestTransaction; |
||||
import org.qortal.transaction.ArbitraryTransaction; |
||||
import org.qortal.transaction.Transaction; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
|
||||
import static org.junit.Assert.*; |
||||
|
||||
public class ArbitraryTransactionTests extends Common { |
||||
|
||||
private static final int version = 4; |
||||
private static final String recipient = Common.getTestAccount(null, "bob").getAddress(); |
||||
|
||||
|
||||
@Before |
||||
public void beforeTest() throws DataException { |
||||
Common.useDefaultSettings(); |
||||
} |
||||
|
||||
@Test |
||||
public void testDifficultyCalculation() throws DataException { |
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) { |
||||
|
||||
TestAccount alice = Common.getTestAccount(repository, "alice"); |
||||
ArbitraryTransactionData.DataType dataType = ArbitraryTransactionData.DataType.DATA_HASH; |
||||
Service service = Service.ARBITRARY_DATA; |
||||
ArbitraryTransactionData.Method method = ArbitraryTransactionData.Method.PUT; |
||||
ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.NONE; |
||||
List<PaymentData> payments = new ArrayList<>(); |
||||
|
||||
ArbitraryTransactionData transactionData = new ArbitraryTransactionData(TestTransaction.generateBase(alice), |
||||
5, service, 0, 0, null, null, method, |
||||
null, compression, null, dataType, null, payments); |
||||
|
||||
ArbitraryTransaction transaction = (ArbitraryTransaction) Transaction.fromData(repository, transactionData); |
||||
assertEquals(12, transaction.difficultyForFileSize(1)); |
||||
assertEquals(12, transaction.difficultyForFileSize(5123456)); |
||||
assertEquals(12, transaction.difficultyForFileSize(74 * 1024 * 1024)); |
||||
assertEquals(13, transaction.difficultyForFileSize(75 * 1024 * 1024)); |
||||
assertEquals(13, transaction.difficultyForFileSize(144 * 1024 * 1024)); |
||||
assertEquals(14, transaction.difficultyForFileSize(145 * 1024 * 1024)); |
||||
assertEquals(14, transaction.difficultyForFileSize(214 * 1024 * 1024)); |
||||
assertEquals(15, transaction.difficultyForFileSize(215 * 1024 * 1024)); |
||||
assertEquals(15, transaction.difficultyForFileSize(289 * 1024 * 1024)); |
||||
assertEquals(16, transaction.difficultyForFileSize(290 * 1024 * 1024)); |
||||
assertEquals(16, transaction.difficultyForFileSize(359 * 1024 * 1024)); |
||||
assertEquals(17, transaction.difficultyForFileSize(360 * 1024 * 1024)); |
||||
assertEquals(17, transaction.difficultyForFileSize(429 * 1024 * 1024)); |
||||
assertEquals(18, transaction.difficultyForFileSize(430 * 1024 * 1024)); |
||||
assertEquals(18, transaction.difficultyForFileSize(499 * 1024 * 1024)); |
||||
assertEquals(19, transaction.difficultyForFileSize(500 * 1024 * 1024)); |
||||
|
||||
} |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue