forked from Qortal/qortal
Compare commits
144 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
c735086db8 | ||
|
cd792fff55 | ||
|
f808e80045 | ||
|
5b1f05d1d9 | ||
|
9574100a08 | ||
|
528583fe38 | ||
|
33cfd02c49 | ||
|
94d3664cb0 | ||
|
f5c8dfe766 | ||
|
f7e1f2fca8 | ||
|
811b647c88 | ||
|
3215bb638d | ||
|
8ae7a1d65b | ||
|
29dcd53002 | ||
|
62908f867a | ||
|
5f86ecafd9 | ||
|
fe999a11f4 | ||
|
c14fca5660 | ||
|
fd8d720946 | ||
|
d628b3ab2a | ||
|
5928b54a33 | ||
|
91dfc5efd0 | ||
|
1343a88ee3 | ||
|
7f7b02f003 | ||
|
5650923805 | ||
|
5fb2640a3a | ||
|
66c91fd365 | ||
|
bfc03db6a9 | ||
|
a4bb445f3e | ||
|
27afcf12bf | ||
|
eda6ab5701 | ||
|
13da0e8a7a | ||
|
d260c0a9a9 | ||
|
655073c524 | ||
|
c8f3b6918f | ||
|
1565a461ac | ||
|
1f30bef4f8 | ||
|
6f0479c4fc | ||
|
b967800a3e | ||
|
0b50f965cc | ||
|
90f7cee058 | ||
|
947b523e61 | ||
|
95d72866e9 | ||
|
aea1cc62c8 | ||
|
c763445e6e | ||
|
7a6b83aa22 | ||
|
ba555174ba | ||
|
3763035d4a | ||
|
b1a904a3c7 | ||
|
3c4c5a1457 | ||
|
648fa66f6a | ||
|
072aa469e3 | ||
|
2b2d6f4e52 | ||
|
c6456669e2 | ||
|
a74fa15d60 | ||
|
68b99c8643 | ||
|
b9015217de | ||
|
e1043ceacb | ||
|
8b51590844 | ||
|
a8d92805f9 | ||
|
2cc5b90306 | ||
|
4cb755a2f1 | ||
|
92119b5558 | ||
|
8a1bf8b5ec | ||
|
f8233bd05b | ||
|
29480e5664 | ||
|
5a873f9465 | ||
|
dc1289787d | ||
|
ba4866a2e6 | ||
|
2cbc5aabd5 | ||
|
e3be43a1e6 | ||
|
1e10bcf3b0 | ||
|
a575ea4423 | ||
|
3e45948646 | ||
|
49c0d45bc6 | ||
|
cda32a47f1 | ||
|
49063e54ec | ||
|
df3c68679f | ||
|
81788610c4 | ||
|
fc10b61193 | ||
|
05b4ecd4ed | ||
|
aba589c0e0 | ||
|
c682fa89fd | ||
|
21d1750779 | ||
|
923e90ebed | ||
|
9490c62242 | ||
|
c941bc6024 | ||
|
0acf0729e9 | ||
|
1f77ee535f | ||
|
b693a514fd | ||
|
b571931127 | ||
|
92b983a16e | ||
|
3f71a63512 | ||
|
86b5bae320 | ||
|
3775135e0c | ||
|
c172a5764b | ||
|
1a5e3b4fb1 | ||
|
f39b6a15da | ||
|
2dfee13d86 | ||
|
b9d81645f8 | ||
|
9547a087b2 | ||
|
e014a207ef | ||
|
611240650e | ||
|
c71dce92b5 | ||
|
34c3adf280 | ||
|
95a1c6bf8b | ||
|
36e944d7e2 | ||
|
f044166b81 | ||
|
aed1823afb | ||
|
6dfaaf0054 | ||
|
45bc2e46d6 | ||
|
46e2e1043d | ||
|
a3518d1f05 | ||
|
0a1ab3d685 | ||
|
5dbacc4db3 | ||
|
1ce2dcfb2b | ||
|
ed6333f82e | ||
|
f27c9193c7 | ||
|
e48529704c | ||
|
53508f9298 | ||
|
33aeec7e87 | ||
|
8f847d3689 | ||
|
16dc23ddc7 | ||
|
e80494b784 | ||
|
111ec3b483 | ||
|
db4a9ee880 | ||
|
b1ebe1864b | ||
|
3c251c35ea | ||
|
4954a1744b | ||
|
a7bbad17d7 | ||
|
8ca9423c52 | ||
|
32b9b7e578 | ||
|
f045e10ada | ||
|
560282dc1d | ||
|
9cd6372161 | ||
|
a4551245cb | ||
|
e4f45c1a70 | ||
|
bc44b998dc | ||
|
b89a35ac69 | ||
|
9566bda279 | ||
|
20d4e88fab | ||
|
a8c27be18a | ||
|
af6be759e7 | ||
|
896d814385 |
131
Q-Apps.md
131
Q-Apps.md
@@ -42,10 +42,15 @@ A "default" resource refers to one without an identifier. For example, when a we
|
||||
|
||||
Here is a list of currently available services that can be used in Q-Apps:
|
||||
|
||||
### Public services ###
|
||||
The services below are intended to be used for publicly accessible data.
|
||||
|
||||
IMAGE,
|
||||
THUMBNAIL,
|
||||
VIDEO,
|
||||
AUDIO,
|
||||
PODCAST,
|
||||
VOICE,
|
||||
ARBITRARY_DATA,
|
||||
JSON,
|
||||
DOCUMENT,
|
||||
@@ -55,7 +60,25 @@ METADATA,
|
||||
BLOG,
|
||||
BLOG_POST,
|
||||
BLOG_COMMENT,
|
||||
GIF_REPOSITORY
|
||||
GIF_REPOSITORY,
|
||||
ATTACHMENT,
|
||||
FILE,
|
||||
FILES,
|
||||
CHAIN_DATA,
|
||||
STORE,
|
||||
PRODUCT,
|
||||
OFFER,
|
||||
COUPON,
|
||||
CODE,
|
||||
PLUGIN,
|
||||
EXTENSION,
|
||||
GAME,
|
||||
ITEM,
|
||||
NFT,
|
||||
DATABASE,
|
||||
SNAPSHOT,
|
||||
COMMENT,
|
||||
CHAIN_COMMENT,
|
||||
WEBSITE,
|
||||
APP,
|
||||
QCHAT_ATTACHMENT,
|
||||
@@ -63,6 +86,20 @@ QCHAT_IMAGE,
|
||||
QCHAT_AUDIO,
|
||||
QCHAT_VOICE
|
||||
|
||||
### Private services ###
|
||||
For the services below, data is encrypted for a single recipient, and can only be decrypted using the private key of the recipient's wallet.
|
||||
|
||||
QCHAT_ATTACHMENT_PRIVATE,
|
||||
ATTACHMENT_PRIVATE,
|
||||
FILE_PRIVATE,
|
||||
IMAGE_PRIVATE,
|
||||
VIDEO_PRIVATE,
|
||||
AUDIO_PRIVATE,
|
||||
VOICE_PRIVATE,
|
||||
DOCUMENT_PRIVATE,
|
||||
MAIL_PRIVATE,
|
||||
MESSAGE_PRIVATE
|
||||
|
||||
|
||||
## Single vs multi-file resources
|
||||
|
||||
@@ -215,14 +252,20 @@ Here is a list of currently supported actions:
|
||||
- GET_USER_ACCOUNT
|
||||
- GET_ACCOUNT_DATA
|
||||
- GET_ACCOUNT_NAMES
|
||||
- SEARCH_NAMES
|
||||
- GET_NAME_DATA
|
||||
- LIST_QDN_RESOURCES
|
||||
- SEARCH_QDN_RESOURCES
|
||||
- GET_QDN_RESOURCE_STATUS
|
||||
- GET_QDN_RESOURCE_PROPERTIES
|
||||
- GET_QDN_RESOURCE_METADATA
|
||||
- GET_QDN_RESOURCE_URL
|
||||
- LINK_TO_QDN_RESOURCE
|
||||
- FETCH_QDN_RESOURCE
|
||||
- PUBLISH_QDN_RESOURCE
|
||||
- PUBLISH_MULTIPLE_QDN_RESOURCES
|
||||
- DECRYPT_DATA
|
||||
- SAVE_FILE
|
||||
- GET_WALLET_BALANCE
|
||||
- GET_BALANCE
|
||||
- SEND_COIN
|
||||
@@ -238,8 +281,6 @@ Here is a list of currently supported actions:
|
||||
- FETCH_BLOCK_RANGE
|
||||
- SEARCH_TRANSACTIONS
|
||||
- GET_PRICE
|
||||
- GET_QDN_RESOURCE_URL
|
||||
- LINK_TO_QDN_RESOURCE
|
||||
- GET_LIST_ITEMS
|
||||
- ADD_LIST_ITEMS
|
||||
- DELETE_LIST_ITEM
|
||||
@@ -284,6 +325,18 @@ let res = await qortalRequest({
|
||||
});
|
||||
```
|
||||
|
||||
### Search names
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "SEARCH_NAMES",
|
||||
query: "search query goes here",
|
||||
prefix: false, // Optional - if true, only the beginning of the name is matched
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
reverse: false
|
||||
});
|
||||
```
|
||||
|
||||
### Get name data
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
@@ -320,6 +373,7 @@ let res = await qortalRequest({
|
||||
identifier: "search query goes here", // Optional - searches only the "identifier" field
|
||||
name: "search query goes here", // Optional - searches only the "name" field
|
||||
prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
|
||||
exactMatchNames: true, // Optional - if true, partial name matches are excluded
|
||||
default: false, // Optional - if true, only resources without identifiers are returned
|
||||
includeStatus: false, // Optional - will take time to respond, so only request if necessary
|
||||
includeMetadata: false, // Optional - will take time to respond, so only request if necessary
|
||||
@@ -384,7 +438,8 @@ let res = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_STATUS",
|
||||
name: "QortalDemo",
|
||||
service: "THUMBNAIL",
|
||||
identifier: "qortal_avatar" // Optional
|
||||
identifier: "qortal_avatar", // Optional
|
||||
build: true // Optional - request that the resource is fetched & built in the background
|
||||
});
|
||||
```
|
||||
|
||||
@@ -399,11 +454,21 @@ let res = await qortalRequest({
|
||||
// Returns: filename, size, mimeType (where available)
|
||||
```
|
||||
|
||||
### Get QDN resource metadata
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_METADATA",
|
||||
name: "QortalDemo",
|
||||
service: "THUMBNAIL",
|
||||
identifier: "qortal_avatar" // Optional
|
||||
});
|
||||
```
|
||||
|
||||
### Publish a single file to QDN
|
||||
_Requires user approval_.<br />
|
||||
Note: this publishes a single, base64-encoded file. Multi-file resource publishing (such as a WEBSITE or GIF_REPOSITORY) is not yet supported via a Q-App. It will be added in a future update.
|
||||
```
|
||||
await qortalRequest({
|
||||
let res = await qortalRequest({
|
||||
action: "PUBLISH_QDN_RESOURCE",
|
||||
name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list
|
||||
service: "IMAGE",
|
||||
@@ -417,7 +482,9 @@ await qortalRequest({
|
||||
// tag2: "strings", // Optional
|
||||
// tag3: "can", // Optional
|
||||
// tag4: "go", // Optional
|
||||
// tag5: "here" // Optional
|
||||
// tag5: "here", // Optional
|
||||
// encrypt: true, // Optional - to be used with a private service
|
||||
// recipientPublicKey: "publickeygoeshere" // Only required if `encrypt` is set to true
|
||||
});
|
||||
```
|
||||
|
||||
@@ -425,7 +492,7 @@ await qortalRequest({
|
||||
_Requires user approval_.<br />
|
||||
Note: each resource being published consists of a single, base64-encoded file, each in its own transaction. Useful for publishing two or more related things, such as a video and a video thumbnail.
|
||||
```
|
||||
await qortalRequest({
|
||||
let res = await qortalRequest({
|
||||
action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
|
||||
resources: [
|
||||
name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list
|
||||
@@ -440,7 +507,9 @@ await qortalRequest({
|
||||
// tag2: "strings", // Optional
|
||||
// tag3: "can", // Optional
|
||||
// tag4: "go", // Optional
|
||||
// tag5: "here" // Optional
|
||||
// tag5: "here", // Optional
|
||||
// encrypt: true, // Optional - to be used with a private service
|
||||
// recipientPublicKey: "publickeygoeshere" // Only required if `encrypt` is set to true
|
||||
],
|
||||
[
|
||||
... more resources here if needed ...
|
||||
@@ -448,10 +517,32 @@ await qortalRequest({
|
||||
});
|
||||
```
|
||||
|
||||
### Decrypt encrypted/private data
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "DECRYPT_DATA",
|
||||
encryptedData: 'qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1r',
|
||||
publicKey: 'publickeygoeshere'
|
||||
});
|
||||
// Returns base64 encoded string of plaintext data
|
||||
```
|
||||
|
||||
### Prompt user to save a file to disk
|
||||
Note: mimeType not required but recommended. If not specified, saving will fail if the mimeType is unable to be derived from the Blob.
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "SAVE_FILE",
|
||||
blob: dataBlob,
|
||||
filename: "myfile.pdf",
|
||||
mimeType: "application/pdf" // Optional but recommended
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
### Get wallet balance (QORT)
|
||||
_Requires user approval_
|
||||
```
|
||||
await qortalRequest({
|
||||
let res = await qortalRequest({
|
||||
action: "GET_WALLET_BALANCE",
|
||||
coin: "QORT"
|
||||
});
|
||||
@@ -476,7 +567,7 @@ let res = await qortalRequest({
|
||||
### Send QORT to address
|
||||
_Requires user approval_
|
||||
```
|
||||
await qortalRequest({
|
||||
let res = await qortalRequest({
|
||||
action: "SEND_COIN",
|
||||
coin: "QORT",
|
||||
destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
|
||||
@@ -485,14 +576,15 @@ await qortalRequest({
|
||||
```
|
||||
|
||||
### Send foreign coin to address
|
||||
_Requires user approval_
|
||||
_Requires user approval_<br />
|
||||
Note: default fees can be found [here](https://github.com/Qortal/qortal-ui/blob/master/plugins/plugins/core/qdn/browser/browser.src.js#L205-L209).
|
||||
```
|
||||
await qortalRequest({
|
||||
let res = await qortalRequest({
|
||||
action: "SEND_COIN",
|
||||
coin: "LTC",
|
||||
destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y",
|
||||
amount: 1.00000000, // 1 LTC
|
||||
fee: 0.00000020 // fee per byte
|
||||
fee: 0.00000020 // Optional fee per byte (default fee used if omitted, recommended) - not used for QORT or ARRR
|
||||
});
|
||||
```
|
||||
|
||||
@@ -507,6 +599,7 @@ let res = await qortalRequest({
|
||||
// reference: "reference", // Optional
|
||||
// chatReference: "chatreference", // Optional
|
||||
// hasChatReference: true, // Optional
|
||||
encoding: "BASE64", // Optional (defaults to BASE58 if omitted)
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
reverse: true
|
||||
@@ -516,7 +609,7 @@ let res = await qortalRequest({
|
||||
### Send a group chat message
|
||||
_Requires user approval_
|
||||
```
|
||||
await qortalRequest({
|
||||
let res = await qortalRequest({
|
||||
action: "SEND_CHAT_MESSAGE",
|
||||
groupId: 0,
|
||||
message: "Test"
|
||||
@@ -526,7 +619,7 @@ await qortalRequest({
|
||||
### Send a private chat message
|
||||
_Requires user approval_
|
||||
```
|
||||
await qortalRequest({
|
||||
let res = await qortalRequest({
|
||||
action: "SEND_CHAT_MESSAGE",
|
||||
destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
|
||||
message: "Test"
|
||||
@@ -546,7 +639,7 @@ let res = await qortalRequest({
|
||||
### Join a group
|
||||
_Requires user approval_
|
||||
```
|
||||
await qortalRequest({
|
||||
let res = await qortalRequest({
|
||||
action: "JOIN_GROUP",
|
||||
groupId: 100
|
||||
});
|
||||
@@ -652,6 +745,7 @@ let res = await qortalRequest({
|
||||
```
|
||||
|
||||
### Get URL to load a QDN resource
|
||||
Note: this returns a "Resource does not exist" error if a non-existent resource is requested.
|
||||
```
|
||||
let url = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_URL",
|
||||
@@ -663,6 +757,7 @@ let url = await qortalRequest({
|
||||
```
|
||||
|
||||
### Get URL to load a QDN website
|
||||
Note: this returns a "Resource does not exist" error if a non-existent resource is requested.
|
||||
```
|
||||
let url = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_URL",
|
||||
@@ -672,6 +767,7 @@ let url = await qortalRequest({
|
||||
```
|
||||
|
||||
### Get URL to load a specific file from a QDN website
|
||||
Note: this returns a "Resource does not exist" error if a non-existent resource is requested.
|
||||
```
|
||||
let url = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_URL",
|
||||
@@ -735,6 +831,9 @@ let res = await qortalRequest({
|
||||
|
||||
# Section 4: Examples
|
||||
|
||||
Some example projects can be found [here](https://github.com/Qortal/Q-Apps). These can be cloned and modified, or used as a reference when creating a new app.
|
||||
|
||||
|
||||
## Sample App
|
||||
|
||||
Here is a sample application to display the logged-in user's avatar:
|
||||
|
2
pom.xml
2
pom.xml
@@ -3,7 +3,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.qortal</groupId>
|
||||
<artifactId>qortal</artifactId>
|
||||
<version>3.9.1</version>
|
||||
<version>4.2.2</version>
|
||||
<packaging>jar</packaging>
|
||||
<properties>
|
||||
<skipTests>true</skipTests>
|
||||
|
@@ -96,7 +96,7 @@ public class ApiService {
|
||||
throw new RuntimeException("Failed to start SSL API due to broken keystore");
|
||||
|
||||
// BouncyCastle-specific SSLContext build
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
|
||||
SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE");
|
||||
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
|
||||
|
173
src/main/java/org/qortal/api/DevProxyService.java
Normal file
173
src/main/java/org/qortal/api/DevProxyService.java
Normal file
@@ -0,0 +1,173 @@
|
||||
package org.qortal.api;
|
||||
|
||||
import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource;
|
||||
import org.eclipse.jetty.http.HttpVersion;
|
||||
import org.eclipse.jetty.rewrite.handler.RewriteHandler;
|
||||
import org.eclipse.jetty.server.*;
|
||||
import org.eclipse.jetty.server.handler.ErrorHandler;
|
||||
import org.eclipse.jetty.server.handler.InetAccessHandler;
|
||||
import org.eclipse.jetty.servlet.FilterHolder;
|
||||
import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||
import org.eclipse.jetty.servlet.ServletHolder;
|
||||
import org.eclipse.jetty.servlets.CrossOriginFilter;
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
||||
import org.glassfish.jersey.server.ResourceConfig;
|
||||
import org.glassfish.jersey.servlet.ServletContainer;
|
||||
import org.qortal.api.resource.AnnotationPostProcessor;
|
||||
import org.qortal.api.resource.ApiDefinition;
|
||||
import org.qortal.network.Network;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.KeyStore;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
public class DevProxyService {
|
||||
|
||||
private static DevProxyService instance;
|
||||
|
||||
private final ResourceConfig config;
|
||||
private Server server;
|
||||
|
||||
private DevProxyService() {
|
||||
this.config = new ResourceConfig();
|
||||
this.config.packages("org.qortal.api.proxy.resource", "org.qortal.api.resource");
|
||||
this.config.register(OpenApiResource.class);
|
||||
this.config.register(ApiDefinition.class);
|
||||
this.config.register(AnnotationPostProcessor.class);
|
||||
}
|
||||
|
||||
public static DevProxyService getInstance() {
|
||||
if (instance == null)
|
||||
instance = new DevProxyService();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public Iterable<Class<?>> getResources() {
|
||||
return this.config.getClasses();
|
||||
}
|
||||
|
||||
public void start() throws DataException {
|
||||
try {
|
||||
// Create API server
|
||||
|
||||
// SSL support if requested
|
||||
String keystorePathname = Settings.getInstance().getSslKeystorePathname();
|
||||
String keystorePassword = Settings.getInstance().getSslKeystorePassword();
|
||||
|
||||
if (keystorePathname != null && keystorePassword != null) {
|
||||
// SSL version
|
||||
if (!Files.isReadable(Path.of(keystorePathname)))
|
||||
throw new RuntimeException("Failed to start SSL API due to broken keystore");
|
||||
|
||||
// BouncyCastle-specific SSLContext build
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
|
||||
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
|
||||
|
||||
try (InputStream keystoreStream = Files.newInputStream(Paths.get(keystorePathname))) {
|
||||
keyStore.load(keystoreStream, keystorePassword.toCharArray());
|
||||
}
|
||||
|
||||
keyManagerFactory.init(keyStore, keystorePassword.toCharArray());
|
||||
sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom());
|
||||
|
||||
SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
|
||||
sslContextFactory.setSslContext(sslContext);
|
||||
|
||||
this.server = new Server();
|
||||
|
||||
HttpConfiguration httpConfig = new HttpConfiguration();
|
||||
httpConfig.setSecureScheme("https");
|
||||
httpConfig.setSecurePort(Settings.getInstance().getDevProxyPort());
|
||||
|
||||
SecureRequestCustomizer src = new SecureRequestCustomizer();
|
||||
httpConfig.addCustomizer(src);
|
||||
|
||||
HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(httpConfig);
|
||||
SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString());
|
||||
|
||||
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
|
||||
new DetectorConnectionFactory(sslConnectionFactory),
|
||||
httpConnectionFactory);
|
||||
portUnifiedConnector.setHost(Network.getInstance().getBindAddress());
|
||||
portUnifiedConnector.setPort(Settings.getInstance().getDevProxyPort());
|
||||
|
||||
this.server.addConnector(portUnifiedConnector);
|
||||
} else {
|
||||
// Non-SSL
|
||||
InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress());
|
||||
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDevProxyPort());
|
||||
this.server = new Server(endpoint);
|
||||
}
|
||||
|
||||
// Error handler
|
||||
ErrorHandler errorHandler = new ApiErrorHandler();
|
||||
this.server.setErrorHandler(errorHandler);
|
||||
|
||||
// Request logging
|
||||
if (Settings.getInstance().isDevProxyLoggingEnabled()) {
|
||||
RequestLogWriter logWriter = new RequestLogWriter("devproxy-requests.log");
|
||||
logWriter.setAppend(true);
|
||||
logWriter.setTimeZone("UTC");
|
||||
RequestLog requestLog = new CustomRequestLog(logWriter, CustomRequestLog.EXTENDED_NCSA_FORMAT);
|
||||
this.server.setRequestLog(requestLog);
|
||||
}
|
||||
|
||||
// Access handler (currently no whitelist is used)
|
||||
InetAccessHandler accessHandler = new InetAccessHandler();
|
||||
this.server.setHandler(accessHandler);
|
||||
|
||||
// URL rewriting
|
||||
RewriteHandler rewriteHandler = new RewriteHandler();
|
||||
accessHandler.setHandler(rewriteHandler);
|
||||
|
||||
// Context
|
||||
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
|
||||
context.setContextPath("/");
|
||||
rewriteHandler.setHandler(context);
|
||||
|
||||
// Cross-origin resource sharing
|
||||
FilterHolder corsFilterHolder = new FilterHolder(CrossOriginFilter.class);
|
||||
corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, "*");
|
||||
corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_METHODS_PARAM, "GET, POST, DELETE");
|
||||
corsFilterHolder.setInitParameter(CrossOriginFilter.CHAIN_PREFLIGHT_PARAM, "false");
|
||||
context.addFilter(corsFilterHolder, "/*", null);
|
||||
|
||||
// API servlet
|
||||
ServletContainer container = new ServletContainer(this.config);
|
||||
ServletHolder apiServlet = new ServletHolder(container);
|
||||
apiServlet.setInitOrder(1);
|
||||
context.addServlet(apiServlet, "/*");
|
||||
|
||||
// Start server
|
||||
this.server.start();
|
||||
} catch (Exception e) {
|
||||
// Failed to start
|
||||
throw new DataException("Failed to start developer proxy", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
try {
|
||||
// Stop server
|
||||
this.server.stop();
|
||||
} catch (Exception e) {
|
||||
// Failed to stop
|
||||
}
|
||||
|
||||
this.server = null;
|
||||
instance = null;
|
||||
}
|
||||
|
||||
}
|
@@ -69,7 +69,7 @@ public class DomainMapService {
|
||||
throw new RuntimeException("Failed to start SSL API due to broken keystore");
|
||||
|
||||
// BouncyCastle-specific SSLContext build
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
|
||||
SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE");
|
||||
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
|
||||
|
@@ -69,7 +69,7 @@ public class GatewayService {
|
||||
throw new RuntimeException("Failed to start SSL API due to broken keystore");
|
||||
|
||||
// BouncyCastle-specific SSLContext build
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
|
||||
SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE");
|
||||
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
|
||||
|
@@ -13,7 +13,8 @@ public class HTMLParser {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(HTMLParser.class);
|
||||
|
||||
private String linkPrefix;
|
||||
private String qdnBase;
|
||||
private String qdnBaseWithPath;
|
||||
private byte[] data;
|
||||
private String qdnContext;
|
||||
private String resourceId;
|
||||
@@ -21,10 +22,13 @@ public class HTMLParser {
|
||||
private String identifier;
|
||||
private String path;
|
||||
private String theme;
|
||||
private boolean usingCustomRouting;
|
||||
|
||||
public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data,
|
||||
String qdnContext, Service service, String identifier, String theme) {
|
||||
this.linkPrefix = usePrefix ? String.format("%s/%s", prefix, resourceId) : "";
|
||||
public HTMLParser(String resourceId, String inPath, String prefix, boolean includeResourceIdInPrefix, byte[] data,
|
||||
String qdnContext, Service service, String identifier, String theme, boolean usingCustomRouting) {
|
||||
String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : String.format("/%s",inPath);
|
||||
this.qdnBase = includeResourceIdInPrefix ? String.format("%s/%s", prefix, resourceId) : prefix;
|
||||
this.qdnBaseWithPath = includeResourceIdInPrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : String.format("%s%s", prefix, inPathWithoutFilename);
|
||||
this.data = data;
|
||||
this.qdnContext = qdnContext;
|
||||
this.resourceId = resourceId;
|
||||
@@ -32,12 +36,12 @@ public class HTMLParser {
|
||||
this.identifier = identifier;
|
||||
this.path = inPath;
|
||||
this.theme = theme;
|
||||
this.usingCustomRouting = usingCustomRouting;
|
||||
}
|
||||
|
||||
public void addAdditionalHeaderTags() {
|
||||
String fileContents = new String(data);
|
||||
Document document = Jsoup.parse(fileContents);
|
||||
String baseUrl = this.linkPrefix;
|
||||
Elements head = document.getElementsByTag("head");
|
||||
if (!head.isEmpty()) {
|
||||
// Add q-apps script tag
|
||||
@@ -51,16 +55,21 @@ public class HTMLParser {
|
||||
}
|
||||
|
||||
// Escape and add vars
|
||||
String service = this.service.toString().replace("\"","\\\"");
|
||||
String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"") : "";
|
||||
String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : "";
|
||||
String path = this.path != null ? this.path.replace("\"","\\\"") : "";
|
||||
String theme = this.theme != null ? this.theme.replace("\"","\\\"") : "";
|
||||
String qdnContextVar = String.format("<script>var _qdnContext=\"%s\"; var _qdnTheme=\"%s\"; var _qdnService=\"%s\"; var _qdnName=\"%s\"; var _qdnIdentifier=\"%s\"; var _qdnPath=\"%s\"; var _qdnBase=\"%s\";</script>", this.qdnContext, theme, service, name, identifier, path, baseUrl);
|
||||
String qdnContext = this.qdnContext != null ? this.qdnContext.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String service = this.service.toString().replace("\\", "").replace("\"","\\\"");
|
||||
String name = this.resourceId != null ? this.resourceId.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String identifier = this.identifier != null ? this.identifier.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String path = this.path != null ? this.path.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String theme = this.theme != null ? this.theme.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String qdnContextVar = String.format("<script>var _qdnContext=\"%s\"; var _qdnTheme=\"%s\"; var _qdnService=\"%s\"; var _qdnName=\"%s\"; var _qdnIdentifier=\"%s\"; var _qdnPath=\"%s\"; var _qdnBase=\"%s\"; var _qdnBaseWithPath=\"%s\";</script>", qdnContext, theme, service, name, identifier, path, qdnBase, qdnBaseWithPath);
|
||||
head.get(0).prepend(qdnContextVar);
|
||||
|
||||
// Add base href tag
|
||||
String baseElement = String.format("<base href=\"%s/\">", baseUrl);
|
||||
// Exclude the path if this request was routed back to the index automatically
|
||||
String baseHref = this.usingCustomRouting ? this.qdnBase : this.qdnBaseWithPath;
|
||||
String baseElement = String.format("<base href=\"%s/\">", baseHref);
|
||||
head.get(0).prepend(baseElement);
|
||||
|
||||
// Add meta charset tag
|
||||
@@ -73,7 +82,7 @@ public class HTMLParser {
|
||||
}
|
||||
|
||||
public static boolean isHtmlFile(String path) {
|
||||
if (path.endsWith(".html") || path.endsWith(".htm")) {
|
||||
if (path.endsWith(".html") || path.endsWith(".htm") || path.equals("")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
@@ -48,10 +48,10 @@ public class DomainMapResource {
|
||||
}
|
||||
|
||||
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
|
||||
String inPath, String secret58, String prefix, boolean usePrefix, boolean async) {
|
||||
String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async) {
|
||||
|
||||
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath,
|
||||
secret58, prefix, usePrefix, async, "domainMap", request, response, context);
|
||||
secret58, prefix, includeResourceIdInPrefix, async, "domainMap", request, response, context);
|
||||
return renderer.render();
|
||||
}
|
||||
|
||||
|
@@ -90,7 +90,7 @@ public class GatewayResource {
|
||||
}
|
||||
|
||||
|
||||
private HttpServletResponse parsePath(String inPath, String qdnContext, String secret58, boolean usePrefix, boolean async) {
|
||||
private HttpServletResponse parsePath(String inPath, String qdnContext, String secret58, boolean includeResourceIdInPrefix, boolean async) {
|
||||
|
||||
if (inPath == null || inPath.equals("")) {
|
||||
// Assume not a real file
|
||||
@@ -157,7 +157,7 @@ public class GatewayResource {
|
||||
}
|
||||
|
||||
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(name, ResourceIdType.NAME, service, identifier, outPath,
|
||||
secret58, prefix, usePrefix, async, qdnContext, request, response, context);
|
||||
secret58, prefix, includeResourceIdInPrefix, async, qdnContext, request, response, context);
|
||||
return renderer.render();
|
||||
}
|
||||
|
||||
|
56
src/main/java/org/qortal/api/model/PollVotes.java
Normal file
56
src/main/java/org/qortal/api/model/PollVotes.java
Normal file
@@ -0,0 +1,56 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import java.util.List;
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlElement;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import org.qortal.data.voting.VoteOnPollData;
|
||||
|
||||
@Schema(description = "Poll vote info, including voters")
|
||||
// All properties to be converted to JSON via JAX-RS
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class PollVotes {
|
||||
|
||||
@Schema(description = "List of individual votes")
|
||||
@XmlElement(name = "votes")
|
||||
public List<VoteOnPollData> votes;
|
||||
|
||||
@Schema(description = "Total number of votes")
|
||||
public Integer totalVotes;
|
||||
|
||||
@Schema(description = "List of vote counts for each option")
|
||||
public List<OptionCount> voteCounts;
|
||||
|
||||
// For JAX-RS
|
||||
protected PollVotes() {
|
||||
}
|
||||
|
||||
public PollVotes(List<VoteOnPollData> votes, Integer totalVotes, List<OptionCount> voteCounts) {
|
||||
this.votes = votes;
|
||||
this.totalVotes = totalVotes;
|
||||
this.voteCounts = voteCounts;
|
||||
}
|
||||
|
||||
@Schema(description = "Vote info")
|
||||
// All properties to be converted to JSON via JAX-RS
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public static class OptionCount {
|
||||
@Schema(description = "Option name")
|
||||
public String optionName;
|
||||
|
||||
@Schema(description = "Vote count")
|
||||
public Integer voteCount;
|
||||
|
||||
// For JAX-RS
|
||||
protected OptionCount() {
|
||||
}
|
||||
|
||||
public OptionCount(String optionName, Integer voteCount) {
|
||||
this.optionName = optionName;
|
||||
this.voteCount = voteCount;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,164 @@
|
||||
package org.qortal.api.proxy.resource;
|
||||
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.HTMLParser;
|
||||
import org.qortal.arbitrary.misc.Service;
|
||||
import org.qortal.controller.DevProxyManager;
|
||||
|
||||
import javax.servlet.ServletContext;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.core.Context;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.ConnectException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.util.Enumeration;
|
||||
|
||||
|
||||
@Path("/")
|
||||
public class DevProxyServerResource {
|
||||
|
||||
@Context HttpServletRequest request;
|
||||
@Context HttpServletResponse response;
|
||||
@Context ServletContext context;
|
||||
|
||||
|
||||
@GET
|
||||
public HttpServletResponse getProxyIndex() {
|
||||
return this.proxy("/");
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{path:.*}")
|
||||
public HttpServletResponse getProxyPath(@PathParam("path") String inPath) {
|
||||
return this.proxy(inPath);
|
||||
}
|
||||
|
||||
private HttpServletResponse proxy(String inPath) {
|
||||
try {
|
||||
String source = DevProxyManager.getInstance().getSourceHostAndPort();
|
||||
|
||||
if (!inPath.startsWith("/")) {
|
||||
inPath = "/" + inPath;
|
||||
}
|
||||
|
||||
String queryString = request.getQueryString() != null ? "?" + request.getQueryString() : "";
|
||||
|
||||
// Open URL
|
||||
URL url = new URL(String.format("http://%s%s%s", source, inPath, queryString));
|
||||
HttpURLConnection con = (HttpURLConnection) url.openConnection();
|
||||
|
||||
// Proxy the request data
|
||||
this.proxyRequestToConnection(request, con);
|
||||
|
||||
try {
|
||||
// Make the request and proxy the response code
|
||||
response.setStatus(con.getResponseCode());
|
||||
}
|
||||
catch (ConnectException e) {
|
||||
|
||||
// Tey converting localhost / 127.0.0.1 to IPv6 [::1]
|
||||
if (source.startsWith("localhost") || source.startsWith("127.0.0.1")) {
|
||||
int port = 80;
|
||||
String[] parts = source.split(":");
|
||||
if (parts.length > 1) {
|
||||
port = Integer.parseInt(parts[1]);
|
||||
}
|
||||
source = String.format("[::1]:%d", port);
|
||||
}
|
||||
|
||||
// Retry connection
|
||||
url = new URL(String.format("http://%s%s%s", source, inPath, queryString));
|
||||
con = (HttpURLConnection) url.openConnection();
|
||||
this.proxyRequestToConnection(request, con);
|
||||
response.setStatus(con.getResponseCode());
|
||||
}
|
||||
|
||||
// Proxy the response data back to the caller
|
||||
this.proxyConnectionToResponse(con, response, inPath);
|
||||
|
||||
} catch (IOException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage());
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private void proxyRequestToConnection(HttpServletRequest request, HttpURLConnection con) throws ProtocolException {
|
||||
// Proxy the request method
|
||||
con.setRequestMethod(request.getMethod());
|
||||
|
||||
// Proxy the request headers
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
String headerValue = request.getHeader(headerName);
|
||||
con.setRequestProperty(headerName, headerValue);
|
||||
}
|
||||
|
||||
// TODO: proxy any POST parameters from "request" to "con"
|
||||
}
|
||||
|
||||
private void proxyConnectionToResponse(HttpURLConnection con, HttpServletResponse response, String inPath) throws IOException {
|
||||
// Proxy the response headers
|
||||
for (int i = 0; ; i++) {
|
||||
String headerKey = con.getHeaderFieldKey(i);
|
||||
String headerValue = con.getHeaderField(i);
|
||||
if (headerKey != null && headerValue != null) {
|
||||
response.addHeader(headerKey, headerValue);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Read the response body
|
||||
InputStream inputStream = con.getInputStream();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] data = outputStream.toByteArray(); // TODO: limit file size that can be read into memory
|
||||
|
||||
// Close the streams
|
||||
outputStream.close();
|
||||
inputStream.close();
|
||||
|
||||
// Extract filename
|
||||
String filename = "";
|
||||
if (inPath.contains("/")) {
|
||||
String[] parts = inPath.split("/");
|
||||
if (parts.length > 0) {
|
||||
filename = parts[parts.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and modify output if needed
|
||||
if (HTMLParser.isHtmlFile(filename)) {
|
||||
// HTML file - needs to be parsed
|
||||
HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, "light", true);
|
||||
htmlParser.addAdditionalHeaderTags();
|
||||
response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' ws:; font-src 'self' data:;");
|
||||
response.setContentType(con.getContentType());
|
||||
response.setContentLength(htmlParser.getData().length);
|
||||
response.getOutputStream().write(htmlParser.getData());
|
||||
}
|
||||
else {
|
||||
// Regular file - can be streamed directly
|
||||
response.addHeader("Content-Security-Policy", "default-src 'self'");
|
||||
response.setContentType(con.getContentType());
|
||||
response.setContentLength(data.length);
|
||||
response.getOutputStream().write(data);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -65,10 +65,7 @@ import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.transaction.ArbitraryTransactionTransformer;
|
||||
import org.qortal.transform.transaction.TransactionTransformer;
|
||||
import org.qortal.utils.ArbitraryTransactionUtils;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
import org.qortal.utils.ZipUtils;
|
||||
import org.qortal.utils.*;
|
||||
|
||||
@Path("/arbitrary")
|
||||
@Tag(name = "Arbitrary")
|
||||
@@ -175,6 +172,7 @@ public class ArbitraryResource {
|
||||
@Parameter(description = "Identifier (searches identifier field only)") @QueryParam("identifier") String identifier,
|
||||
@Parameter(description = "Name (searches name field only)") @QueryParam("name") List<String> names,
|
||||
@Parameter(description = "Prefix only (if true, only the beginning of fields are matched)") @QueryParam("prefix") Boolean prefixOnly,
|
||||
@Parameter(description = "Exact match names only (if true, partial name matches are excluded)") @QueryParam("exactmatchnames") Boolean exactMatchNamesOnly,
|
||||
@Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource,
|
||||
@Parameter(description = "Filter names by list (exact matches only)") @QueryParam("namefilter") String nameListFilter,
|
||||
@Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly,
|
||||
@@ -202,6 +200,12 @@ public class ArbitraryResource {
|
||||
}
|
||||
}
|
||||
|
||||
// Move names to exact match list, if requested
|
||||
if (exactMatchNamesOnly != null && exactMatchNamesOnly && names != null) {
|
||||
exactMatchNames.addAll(names);
|
||||
names = null;
|
||||
}
|
||||
|
||||
List<ArbitraryResourceInfo> resources = repository.getArbitraryRepository()
|
||||
.searchArbitraryResources(service, query, identifier, names, usePrefixOnly, exactMatchNames, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse);
|
||||
|
||||
@@ -714,12 +718,9 @@ public class ArbitraryResource {
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public ArbitraryResourceMetadata getMetadata(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
|
||||
@PathParam("service") Service service,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("identifier") String identifier) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
public ArbitraryResourceMetadata getMetadata(@PathParam("service") Service service,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("identifier") String identifier) {
|
||||
ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier);
|
||||
|
||||
try {
|
||||
@@ -1172,7 +1173,11 @@ public class ArbitraryResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, error);
|
||||
}
|
||||
|
||||
final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L);
|
||||
final Long now = NTP.getTime();
|
||||
if (now == null) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NO_TIME_SYNC);
|
||||
}
|
||||
final Long minLatestBlockTimestamp = now - (60 * 60 * 1000L);
|
||||
if (!Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC);
|
||||
}
|
||||
@@ -1230,7 +1235,7 @@ public class ArbitraryResource {
|
||||
// The actual data will be in a randomly-named subfolder of tempDirectory
|
||||
// Remove hidden folders, i.e. starting with "_", as some systems can add them, e.g. "__MACOSX"
|
||||
String[] files = tempDirectory.toFile().list((parent, child) -> !child.startsWith("_"));
|
||||
if (files.length == 1) { // Single directory or file only
|
||||
if (files != null && files.length == 1) { // Single directory or file only
|
||||
path = Paths.get(tempDirectory.toString(), files[0]).toString();
|
||||
}
|
||||
}
|
||||
@@ -1262,7 +1267,8 @@ public class ArbitraryResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||
}
|
||||
|
||||
} catch (DataException | IOException e) {
|
||||
} catch (Exception e) {
|
||||
LOGGER.info("Exception when publishing data: ", e);
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -1310,7 +1316,7 @@ public class ArbitraryResource {
|
||||
if (filepath == null || filepath.isEmpty()) {
|
||||
// No file path supplied - so check if this is a single file resource
|
||||
String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal");
|
||||
if (files.length == 1) {
|
||||
if (files != null && files.length == 1) {
|
||||
// This is a single file resource
|
||||
filepath = files[0];
|
||||
}
|
||||
@@ -1320,20 +1326,50 @@ public class ArbitraryResource {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: limit file size that can be read into memory
|
||||
java.nio.file.Path path = Paths.get(outputPath.toString(), filepath);
|
||||
if (!Files.exists(path)) {
|
||||
String message = String.format("No file exists at filepath: %s", filepath);
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, message);
|
||||
}
|
||||
|
||||
byte[] data = Files.readAllBytes(path);
|
||||
byte[] data;
|
||||
int fileSize = (int)path.toFile().length();
|
||||
int length = fileSize;
|
||||
|
||||
// Parse "Range" header
|
||||
Integer rangeStart = null;
|
||||
Integer rangeEnd = null;
|
||||
String range = request.getHeader("Range");
|
||||
if (range != null) {
|
||||
range = range.replace("bytes=", "");
|
||||
String[] parts = range.split("-");
|
||||
rangeStart = (parts != null && parts.length > 0) ? Integer.parseInt(parts[0]) : null;
|
||||
rangeEnd = (parts != null && parts.length > 1) ? Integer.parseInt(parts[1]) : fileSize;
|
||||
}
|
||||
|
||||
if (rangeStart != null && rangeEnd != null) {
|
||||
// We have a range, so update the requested length
|
||||
length = rangeEnd - rangeStart;
|
||||
}
|
||||
|
||||
if (length < fileSize && encoding == null) {
|
||||
// Partial content requested, and not encoding the data
|
||||
response.setStatus(206);
|
||||
response.addHeader("Content-Range", String.format("bytes %d-%d/%d", rangeStart, rangeEnd-1, fileSize));
|
||||
data = FilesystemUtils.readFromFile(path.toString(), rangeStart, length);
|
||||
}
|
||||
else {
|
||||
// Full content requested (or encoded data)
|
||||
response.setStatus(200);
|
||||
data = Files.readAllBytes(path); // TODO: limit file size that can be read into memory
|
||||
}
|
||||
|
||||
// Encode the data if requested
|
||||
if (encoding != null && Objects.equals(encoding.toLowerCase(), "base64")) {
|
||||
data = Base64.encode(data);
|
||||
}
|
||||
|
||||
response.addHeader("Accept-Ranges", "bytes");
|
||||
response.setContentType(context.getMimeType(path.toString()));
|
||||
response.setContentLength(data.length);
|
||||
response.getOutputStream().write(data);
|
||||
|
@@ -222,14 +222,25 @@ public class BlocksResource {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Check if the block exists in either the database or archive
|
||||
if (repository.getBlockRepository().getHeightFromSignature(signature) == 0 &&
|
||||
repository.getBlockArchiveRepository().getHeightFromSignature(signature) == 0) {
|
||||
// Not found in either the database or archive
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
}
|
||||
// Check if the block exists in either the database or archive
|
||||
int height = repository.getBlockRepository().getHeightFromSignature(signature);
|
||||
if (height == 0) {
|
||||
height = repository.getBlockArchiveRepository().getHeightFromSignature(signature);
|
||||
if (height == 0) {
|
||||
// Not found in either the database or archive
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
}
|
||||
}
|
||||
|
||||
return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse);
|
||||
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height);
|
||||
|
||||
// Expand signatures to transactions
|
||||
List<TransactionData> transactions = new ArrayList<>(signatures.size());
|
||||
for (byte[] s : signatures) {
|
||||
transactions.add(repository.getTransactionRepository().fromSignature(s));
|
||||
}
|
||||
|
||||
return transactions;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
|
@@ -119,6 +119,75 @@ public class ChatResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/messages/count")
|
||||
@Operation(
|
||||
summary = "Count chat messages",
|
||||
description = "Returns count of CHAT messages that match criteria. Must provide EITHER 'txGroupId' OR two 'involving' addresses.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "count of messages",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "integer"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
public int countChatMessages(@QueryParam("before") Long before, @QueryParam("after") Long after,
|
||||
@QueryParam("txGroupId") Integer txGroupId,
|
||||
@QueryParam("involving") List<String> involvingAddresses,
|
||||
@QueryParam("reference") String reference,
|
||||
@QueryParam("chatreference") String chatReference,
|
||||
@QueryParam("haschatreference") Boolean hasChatReference,
|
||||
@QueryParam("sender") String sender,
|
||||
@QueryParam("encoding") Encoding encoding,
|
||||
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
|
||||
// Check args meet expectations
|
||||
if ((txGroupId == null && involvingAddresses.size() != 2)
|
||||
|| (txGroupId != null && !involvingAddresses.isEmpty()))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Check any provided addresses are valid
|
||||
if (involvingAddresses.stream().anyMatch(address -> !Crypto.isValidAddress(address)))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (before != null && before < 1500000000000L)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (after != null && after < 1500000000000L)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] referenceBytes = null;
|
||||
if (reference != null)
|
||||
referenceBytes = Base58.decode(reference);
|
||||
|
||||
byte[] chatReferenceBytes = null;
|
||||
if (chatReference != null)
|
||||
chatReferenceBytes = Base58.decode(chatReference);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getChatRepository().getMessagesMatchingCriteria(
|
||||
before,
|
||||
after,
|
||||
txGroupId,
|
||||
referenceBytes,
|
||||
chatReferenceBytes,
|
||||
hasChatReference,
|
||||
involvingAddresses,
|
||||
sender,
|
||||
encoding,
|
||||
limit, offset, reverse).size();
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/message/{signature}")
|
||||
@Operation(
|
||||
|
@@ -115,6 +115,9 @@ public class CrossChainResource {
|
||||
crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp));
|
||||
}
|
||||
|
||||
// Remove any trades that have had too many failures
|
||||
crossChainTrades = TradeBot.getInstance().removeFailedTrades(repository, crossChainTrades);
|
||||
|
||||
if (limit != null && limit > 0) {
|
||||
// Make sure to not return more than the limit
|
||||
int upperLimit = Math.min(limit, crossChainTrades.size());
|
||||
@@ -129,6 +132,64 @@ public class CrossChainResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/tradeoffers/hidden")
|
||||
@Operation(
|
||||
summary = "Find cross-chain trade offers that have been hidden due to too many failures",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = CrossChainTradeData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public List<CrossChainTradeData> getHiddenTradeOffers(
|
||||
@Parameter(
|
||||
description = "Limit to specific blockchain",
|
||||
example = "LITECOIN",
|
||||
schema = @Schema(implementation = SupportedBlockchain.class)
|
||||
) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain) {
|
||||
|
||||
final boolean isExecutable = true;
|
||||
List<CrossChainTradeData> crossChainTrades = new ArrayList<>();
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain);
|
||||
|
||||
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||
byte[] codeHash = acctInfo.getKey().value;
|
||||
ACCT acct = acctInfo.getValue().get();
|
||||
|
||||
List<ATData> atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, null, null, null);
|
||||
|
||||
for (ATData atData : atsData) {
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
if (crossChainTradeData.mode == AcctMode.OFFERING) {
|
||||
crossChainTrades.add(crossChainTradeData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the trades by timestamp
|
||||
crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp));
|
||||
|
||||
// Remove trades that haven't failed
|
||||
crossChainTrades.removeIf(t -> !TradeBot.getInstance().isFailedTrade(repository, t));
|
||||
|
||||
crossChainTrades.stream().forEach(CrossChainResource::decorateTradeDataWithPresence);
|
||||
|
||||
return crossChainTrades;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/trade/{ataddress}")
|
||||
@Operation(
|
||||
|
96
src/main/java/org/qortal/api/resource/DeveloperResource.java
Normal file
96
src/main/java/org/qortal/api/resource/DeveloperResource.java
Normal file
@@ -0,0 +1,96 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.controller.DevProxyManager;
|
||||
import org.qortal.repository.DataException;
|
||||
|
||||
import javax.servlet.ServletContext;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
|
||||
@Path("/developer")
|
||||
@Tag(name = "Developer Tools")
|
||||
public class DeveloperResource {
|
||||
|
||||
@Context HttpServletRequest request;
|
||||
@Context HttpServletResponse response;
|
||||
@Context ServletContext context;
|
||||
|
||||
|
||||
@POST
|
||||
@Path("/proxy/start")
|
||||
@Operation(
|
||||
summary = "Start proxy server, for real time QDN app/website development",
|
||||
requestBody = @RequestBody(
|
||||
description = "Host and port of source webserver to be proxied",
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
example = "127.0.0.1:5173"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "Port number of running server",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "number"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA})
|
||||
public Integer startProxy(String sourceHostAndPort) {
|
||||
// TODO: API key
|
||||
DevProxyManager devProxyManager = DevProxyManager.getInstance();
|
||||
try {
|
||||
devProxyManager.setSourceHostAndPort(sourceHostAndPort);
|
||||
devProxyManager.start();
|
||||
return devProxyManager.getPort();
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/proxy/stop")
|
||||
@Operation(
|
||||
summary = "Stop proxy server",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "true if stopped",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "boolean"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
public boolean stopProxy() {
|
||||
DevProxyManager devProxyManager = DevProxyManager.getInstance();
|
||||
devProxyManager.stop();
|
||||
return !devProxyManager.isRunning();
|
||||
}
|
||||
|
||||
}
|
@@ -47,6 +47,7 @@ import org.qortal.transform.transaction.RegisterNameTransactionTransformer;
|
||||
import org.qortal.transform.transaction.SellNameTransactionTransformer;
|
||||
import org.qortal.transform.transaction.UpdateNameTransactionTransformer;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.Unicode;
|
||||
|
||||
@Path("/names")
|
||||
@Tag(name = "Names")
|
||||
@@ -63,19 +64,19 @@ public class NamesResource {
|
||||
description = "registered name info",
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
array = @ArraySchema(schema = @Schema(implementation = NameSummary.class))
|
||||
array = @ArraySchema(schema = @Schema(implementation = NameData.class))
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
public List<NameSummary> getAllNames(@Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) {
|
||||
public List<NameData> getAllNames(@Parameter(description = "Return only names registered or updated after timestamp") @QueryParam("after") Long after,
|
||||
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<NameData> names = repository.getNameRepository().getAllNames(limit, offset, reverse);
|
||||
|
||||
// Convert to summary
|
||||
return names.stream().map(NameSummary::new).collect(Collectors.toList());
|
||||
return repository.getNameRepository().getAllNames(after, limit, offset, reverse);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -135,12 +136,13 @@ public class NamesResource {
|
||||
public NameData getName(@PathParam("name") String name) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
NameData nameData;
|
||||
String reducedName = Unicode.sanitize(name);
|
||||
|
||||
if (Settings.getInstance().isLite()) {
|
||||
nameData = LiteNode.getInstance().fetchNameData(name);
|
||||
}
|
||||
else {
|
||||
nameData = repository.getNameRepository().fromName(name);
|
||||
nameData = repository.getNameRepository().fromReducedName(reducedName);
|
||||
}
|
||||
|
||||
if (nameData == null) {
|
||||
@@ -155,6 +157,41 @@ public class NamesResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/search")
|
||||
@Operation(
|
||||
summary = "Search registered names",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "registered name info",
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
array = @ArraySchema(schema = @Schema(implementation = NameData.class))
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.NAME_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public List<NameData> searchNames(@QueryParam("query") String query,
|
||||
@Parameter(description = "Prefix only (if true, only the beginning of the name is matched)") @QueryParam("prefix") Boolean prefixOnly,
|
||||
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
if (query == null) {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Missing query");
|
||||
}
|
||||
|
||||
boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly);
|
||||
|
||||
return repository.getNameRepository().searchNames(query, usePrefixOnly, limit, offset, reverse);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@POST
|
||||
@Path("/register")
|
||||
@@ -410,4 +447,4 @@ public class NamesResource {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@@ -31,12 +31,18 @@ import javax.ws.rs.core.MediaType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import org.qortal.api.ApiException;
|
||||
import org.qortal.api.model.PollVotes;
|
||||
import org.qortal.data.voting.PollData;
|
||||
import org.qortal.data.voting.PollOptionData;
|
||||
import org.qortal.data.voting.VoteOnPollData;
|
||||
|
||||
@Path("/polls")
|
||||
@Tag(name = "Polls")
|
||||
@@ -102,6 +108,61 @@ public class PollsResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/votes/{pollName}")
|
||||
@Operation(
|
||||
summary = "Votes on poll",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "poll votes",
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = PollVotes.class)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
public PollVotes getPollVotes(@PathParam("pollName") String pollName, @QueryParam("onlyCounts") Boolean onlyCounts) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
PollData pollData = repository.getVotingRepository().fromPollName(pollName);
|
||||
if (pollData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.POLL_NO_EXISTS);
|
||||
|
||||
List<VoteOnPollData> votes = repository.getVotingRepository().getVotes(pollName);
|
||||
|
||||
// Initialize map for counting votes
|
||||
Map<String, Integer> voteCountMap = new HashMap<>();
|
||||
for (PollOptionData optionData : pollData.getPollOptions()) {
|
||||
voteCountMap.put(optionData.getOptionName(), 0);
|
||||
}
|
||||
|
||||
int totalVotes = 0;
|
||||
for (VoteOnPollData vote : votes) {
|
||||
String selectedOption = pollData.getPollOptions().get(vote.getOptionIndex()).getOptionName();
|
||||
if (voteCountMap.containsKey(selectedOption)) {
|
||||
voteCountMap.put(selectedOption, voteCountMap.get(selectedOption) + 1);
|
||||
totalVotes++;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to list of VoteInfo
|
||||
List<PollVotes.OptionCount> voteCounts = voteCountMap.entrySet().stream()
|
||||
.map(entry -> new PollVotes.OptionCount(entry.getKey(), entry.getValue()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (onlyCounts != null && onlyCounts) {
|
||||
return new PollVotes(null, totalVotes, voteCounts);
|
||||
} else {
|
||||
return new PollVotes(votes, totalVotes, voteCounts);
|
||||
}
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/create")
|
||||
@Operation(
|
||||
|
70
src/main/java/org/qortal/api/resource/StatsResource.java
Normal file
70
src/main/java/org/qortal/api/resource/StatsResource.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.api.*;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.utils.Amounts;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.*;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@Path("/stats")
|
||||
@Tag(name = "Stats")
|
||||
public class StatsResource {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(StatsResource.class);
|
||||
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/supply/circulating")
|
||||
@Operation(
|
||||
summary = "Fetch circulating QORT supply",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "circulating supply of QORT",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", format = "number"))
|
||||
)
|
||||
}
|
||||
)
|
||||
public BigDecimal circulatingSupply() {
|
||||
long total = 0L;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int currentHeight = repository.getBlockRepository().getBlockchainHeight();
|
||||
|
||||
List<BlockChain.RewardByHeight> rewardsByHeight = BlockChain.getInstance().getBlockRewardsByHeight();
|
||||
int rewardIndex = rewardsByHeight.size() - 1;
|
||||
BlockChain.RewardByHeight rewardInfo = rewardsByHeight.get(rewardIndex);
|
||||
|
||||
for (int height = currentHeight; height > 1; --height) {
|
||||
if (height < rewardInfo.height) {
|
||||
--rewardIndex;
|
||||
rewardInfo = rewardsByHeight.get(rewardIndex);
|
||||
}
|
||||
|
||||
total += rewardInfo.reward;
|
||||
}
|
||||
|
||||
return Amounts.toBigDecimal(total);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -215,10 +215,25 @@ public class TransactionsResource {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
if (repository.getBlockRepository().getHeightFromSignature(signature) == 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
// Check if the block exists in either the database or archive
|
||||
int height = repository.getBlockRepository().getHeightFromSignature(signature);
|
||||
if (height == 0) {
|
||||
height = repository.getBlockArchiveRepository().getHeightFromSignature(signature);
|
||||
if (height == 0) {
|
||||
// Not found in either the database or archive
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
}
|
||||
}
|
||||
|
||||
return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse);
|
||||
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height);
|
||||
|
||||
// Expand signatures to transactions
|
||||
List<TransactionData> transactions = new ArrayList<>(signatures.size());
|
||||
for (byte[] s : signatures) {
|
||||
transactions.add(repository.getTransactionRepository().fromSignature(s));
|
||||
}
|
||||
|
||||
return transactions;
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
|
@@ -20,7 +20,6 @@ import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
@@ -38,7 +37,6 @@ import org.apache.logging.log4j.Logger;
|
||||
import org.apache.logging.log4j.core.LoggerContext;
|
||||
import org.apache.logging.log4j.core.appender.RollingFileAppender;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.account.PrivateKeyAccount;
|
||||
import org.qortal.api.*;
|
||||
@@ -57,6 +55,7 @@ import org.qortal.network.Network;
|
||||
import org.qortal.network.Peer;
|
||||
import org.qortal.network.PeerAddress;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.ReindexManager;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
@@ -184,7 +183,7 @@ public class AdminResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
public Object setting(@PathParam("setting") String setting) {
|
||||
public String setting(@PathParam("setting") String setting) {
|
||||
try {
|
||||
Object settingValue = FieldUtils.readField(Settings.getInstance(), setting, true);
|
||||
if (settingValue == null) {
|
||||
@@ -198,8 +197,8 @@ public class AdminResource {
|
||||
JSONArray array = new JSONArray((List<Object>) settingValue);
|
||||
return array.toString(4);
|
||||
}
|
||||
return settingValue;
|
||||
|
||||
return settingValue.toString();
|
||||
} catch (IllegalAccessException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e);
|
||||
}
|
||||
@@ -876,6 +875,48 @@ public class AdminResource {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@POST
|
||||
@Path("/repository/reindex")
|
||||
@Operation(
|
||||
summary = "Reindex repository",
|
||||
description = "Rebuilds all transactions and balances from archived blocks. Warning: takes around 1 week, and the core will not function normally during this time. If 'false' is returned, the database may be left in an inconsistent state, requiring another reindex or a bootstrap to correct it.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "\"true\"",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String reindex(@HeaderParam(Security.API_KEY_HEADER) String apiKey) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try {
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
ReindexManager reindexManager = new ReindexManager();
|
||||
reindexManager.reindex();
|
||||
return "true";
|
||||
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("DataException when reindexing: {}", e.getMessage());
|
||||
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// We couldn't lock blockchain to perform reindex
|
||||
return "false";
|
||||
}
|
||||
|
||||
return "false";
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/repository")
|
||||
@Operation(
|
||||
|
@@ -157,10 +157,10 @@ public class RenderResource {
|
||||
|
||||
|
||||
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
|
||||
String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String theme) {
|
||||
String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String theme) {
|
||||
|
||||
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath,
|
||||
secret58, prefix, usePrefix, async, "render", request, response, context);
|
||||
secret58, prefix, includeResourceIdInPrefix, async, "render", request, response, context);
|
||||
|
||||
if (theme != null) {
|
||||
renderer.setTheme(theme);
|
||||
|
@@ -24,6 +24,7 @@ import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||
import org.qortal.api.model.CrossChainOfferSummary;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.controller.Synchronizer;
|
||||
import org.qortal.controller.tradebot.TradeBot;
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
import org.qortal.crosschain.ACCT;
|
||||
import org.qortal.crosschain.AcctMode;
|
||||
@@ -315,7 +316,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
||||
throw new DataException("Couldn't fetch historic trades from repository");
|
||||
|
||||
for (ATStateData historicAtState : historicAtStates) {
|
||||
CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null);
|
||||
CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null, null);
|
||||
|
||||
if (!isHistoric.test(historicOfferSummary))
|
||||
continue;
|
||||
@@ -330,8 +331,10 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, Long timestamp) throws DataException {
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState);
|
||||
private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, CrossChainTradeData crossChainTradeData, Long timestamp) throws DataException {
|
||||
if (crossChainTradeData == null) {
|
||||
crossChainTradeData = acct.populateTradeData(repository, atState);
|
||||
}
|
||||
|
||||
long atStateTimestamp;
|
||||
|
||||
@@ -346,9 +349,16 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
||||
|
||||
private static List<CrossChainOfferSummary> produceSummaries(Repository repository, ACCT acct, List<ATStateData> atStates, Long timestamp) throws DataException {
|
||||
List<CrossChainOfferSummary> offerSummaries = new ArrayList<>();
|
||||
for (ATStateData atState : atStates) {
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState);
|
||||
|
||||
for (ATStateData atState : atStates)
|
||||
offerSummaries.add(produceSummary(repository, acct, atState, timestamp));
|
||||
// Ignore trade if it has failed
|
||||
if (TradeBot.getInstance().isFailedTrade(repository, crossChainTradeData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
offerSummaries.add(produceSummary(repository, acct, atState, crossChainTradeData, timestamp));
|
||||
}
|
||||
|
||||
return offerSummaries;
|
||||
}
|
||||
|
@@ -54,10 +54,6 @@ public class ArbitraryDataBuilder {
|
||||
/**
|
||||
* Process transactions, but do not build anything
|
||||
* This is useful for checking the status of a given resource
|
||||
*
|
||||
* @throws DataException
|
||||
* @throws IOException
|
||||
* @throws MissingDataException
|
||||
*/
|
||||
public void process() throws DataException, IOException, MissingDataException {
|
||||
this.fetchTransactions();
|
||||
@@ -69,10 +65,6 @@ public class ArbitraryDataBuilder {
|
||||
|
||||
/**
|
||||
* Build the latest state of a given resource
|
||||
*
|
||||
* @throws DataException
|
||||
* @throws IOException
|
||||
* @throws MissingDataException
|
||||
*/
|
||||
public void build() throws DataException, IOException, MissingDataException {
|
||||
this.process();
|
||||
|
@@ -79,7 +79,7 @@ public class ArbitraryDataFile {
|
||||
this.signature = signature;
|
||||
}
|
||||
|
||||
public ArbitraryDataFile(byte[] fileContent, byte[] signature) throws DataException {
|
||||
public ArbitraryDataFile(byte[] fileContent, byte[] signature, boolean useTemporaryFile) throws DataException {
|
||||
if (fileContent == null) {
|
||||
LOGGER.error("fileContent is null");
|
||||
return;
|
||||
@@ -90,7 +90,20 @@ public class ArbitraryDataFile {
|
||||
this.signature = signature;
|
||||
LOGGER.trace(String.format("File digest: %s, size: %d bytes", this.hash58, fileContent.length));
|
||||
|
||||
Path outputFilePath = getOutputFilePath(this.hash58, signature, true);
|
||||
Path outputFilePath;
|
||||
if (useTemporaryFile) {
|
||||
try {
|
||||
outputFilePath = Files.createTempFile("qortalRawData", null);
|
||||
outputFilePath.toFile().deleteOnExit();
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new DataException(String.format("Unable to write data with hash %s to temporary file: %s", this.hash58, e.getMessage()));
|
||||
}
|
||||
}
|
||||
else {
|
||||
outputFilePath = getOutputFilePath(this.hash58, signature, true);
|
||||
}
|
||||
|
||||
File outputFile = outputFilePath.toFile();
|
||||
try (FileOutputStream outputStream = new FileOutputStream(outputFile)) {
|
||||
outputStream.write(fileContent);
|
||||
@@ -116,7 +129,7 @@ public class ArbitraryDataFile {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
return new ArbitraryDataFile(data, signature);
|
||||
return new ArbitraryDataFile(data, signature, true);
|
||||
}
|
||||
|
||||
public static ArbitraryDataFile fromTransactionData(ArbitraryTransactionData transactionData) throws DataException {
|
||||
|
@@ -18,7 +18,7 @@ public class ArbitraryDataFileChunk extends ArbitraryDataFile {
|
||||
}
|
||||
|
||||
public ArbitraryDataFileChunk(byte[] fileContent, byte[] signature) throws DataException {
|
||||
super(fileContent, signature);
|
||||
super(fileContent, signature, false);
|
||||
}
|
||||
|
||||
public static ArbitraryDataFileChunk fromHash58(String hash58, byte[] signature) throws DataException {
|
||||
|
@@ -9,7 +9,6 @@ import org.qortal.arbitrary.exception.MissingDataException;
|
||||
import org.qortal.arbitrary.misc.Service;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataBuildManager;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataManager;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataStorageManager;
|
||||
import org.qortal.crypto.AES;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData.*;
|
||||
@@ -35,6 +34,9 @@ import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ArbitraryDataReader {
|
||||
|
||||
@@ -60,6 +62,10 @@ public class ArbitraryDataReader {
|
||||
// The resource being read
|
||||
ArbitraryDataResource arbitraryDataResource = null;
|
||||
|
||||
// Track resources that are currently being loaded, to avoid duplicate concurrent builds
|
||||
// TODO: all builds could be handled by the build queue (even synchronous ones), to avoid the need for this
|
||||
private static Map<String, Long> inProgress = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
public ArbitraryDataReader(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) {
|
||||
// Ensure names are always lowercase
|
||||
if (resourceIdType == ResourceIdType.NAME) {
|
||||
@@ -154,9 +160,6 @@ public class ArbitraryDataReader {
|
||||
* If no exception is thrown, you can then use getFilePath() to access the data immediately after returning
|
||||
*
|
||||
* @param overwrite - set to true to force rebuild an existing cache
|
||||
* @throws IOException
|
||||
* @throws DataException
|
||||
* @throws MissingDataException
|
||||
*/
|
||||
public void loadSynchronously(boolean overwrite) throws DataException, IOException, MissingDataException {
|
||||
try {
|
||||
@@ -170,6 +173,12 @@ public class ArbitraryDataReader {
|
||||
|
||||
this.arbitraryDataResource = this.createArbitraryDataResource();
|
||||
|
||||
// Don't allow duplicate loads
|
||||
if (!this.canStartLoading()) {
|
||||
LOGGER.debug("Skipping duplicate load of {}", this.arbitraryDataResource);
|
||||
return;
|
||||
}
|
||||
|
||||
this.preExecute();
|
||||
this.deleteExistingFiles();
|
||||
this.fetch();
|
||||
@@ -197,6 +206,7 @@ public class ArbitraryDataReader {
|
||||
|
||||
private void preExecute() throws DataException {
|
||||
ArbitraryDataBuildManager.getInstance().setBuildInProgress(true);
|
||||
|
||||
this.checkEnabled();
|
||||
this.createWorkingDirectory();
|
||||
this.createUncompressedDirectory();
|
||||
@@ -204,6 +214,9 @@ public class ArbitraryDataReader {
|
||||
|
||||
private void postExecute() {
|
||||
ArbitraryDataBuildManager.getInstance().setBuildInProgress(false);
|
||||
|
||||
this.arbitraryDataResource = this.createArbitraryDataResource();
|
||||
ArbitraryDataReader.inProgress.remove(this.arbitraryDataResource.getUniqueKey());
|
||||
}
|
||||
|
||||
private void checkEnabled() throws DataException {
|
||||
@@ -212,6 +225,17 @@ public class ArbitraryDataReader {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canStartLoading() {
|
||||
// Avoid duplicate builds if we're already loading this resource
|
||||
String uniqueKey = this.arbitraryDataResource.getUniqueKey();
|
||||
if (ArbitraryDataReader.inProgress.containsKey(uniqueKey)) {
|
||||
return false;
|
||||
}
|
||||
ArbitraryDataReader.inProgress.put(uniqueKey, NTP.getTime());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void createWorkingDirectory() throws DataException {
|
||||
try {
|
||||
Files.createDirectories(this.workingPath);
|
||||
@@ -223,7 +247,6 @@ public class ArbitraryDataReader {
|
||||
/**
|
||||
* Working directory should only be deleted on failure, since it is currently used to
|
||||
* serve a cached version of the resource for subsequent requests.
|
||||
* @throws IOException
|
||||
*/
|
||||
private void deleteWorkingDirectory() {
|
||||
try {
|
||||
@@ -303,7 +326,7 @@ public class ArbitraryDataReader {
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new DataException(String.format("Unknown resource ID type specified: %s", resourceIdType.toString()));
|
||||
throw new DataException(String.format("Unknown resource ID type specified: %s", resourceIdType));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +391,9 @@ public class ArbitraryDataReader {
|
||||
// Load data file(s)
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData);
|
||||
ArbitraryTransactionUtils.checkAndRelocateMiscFiles(transactionData);
|
||||
if (arbitraryDataFile == null) {
|
||||
throw new DataException(String.format("arbitraryDataFile is null"));
|
||||
}
|
||||
|
||||
if (!arbitraryDataFile.allFilesExist()) {
|
||||
if (ListUtils.isNameBlocked(transactionData.getName())) {
|
||||
@@ -443,6 +469,7 @@ public class ArbitraryDataReader {
|
||||
Path unencryptedPath = Paths.get(this.workingPath.toString(), "zipped.zip");
|
||||
SecretKey aesKey = new SecretKeySpec(secret, 0, secret.length, "AES");
|
||||
AES.decryptFile(algorithm, aesKey, this.filePath.toString(), unencryptedPath.toString());
|
||||
LOGGER.debug("Finished decrypting {} using algorithm {}", this.arbitraryDataResource, algorithm);
|
||||
|
||||
// Replace filePath pointer with the encrypted file path
|
||||
// Don't delete the original ArbitraryDataFile, as this is handled in the cleanup phase
|
||||
@@ -477,7 +504,9 @@ public class ArbitraryDataReader {
|
||||
|
||||
// Handle each type of compression
|
||||
if (compression == Compression.ZIP) {
|
||||
LOGGER.debug("Unzipping {}...", this.arbitraryDataResource);
|
||||
ZipUtils.unzip(this.filePath.toString(), this.uncompressedPath.getParent().toString());
|
||||
LOGGER.debug("Finished unzipping {}", this.arbitraryDataResource);
|
||||
}
|
||||
else if (compression == Compression.NONE) {
|
||||
Files.createDirectories(this.uncompressedPath);
|
||||
@@ -513,10 +542,12 @@ public class ArbitraryDataReader {
|
||||
|
||||
private void validate() throws IOException, DataException {
|
||||
if (this.service.isValidationRequired()) {
|
||||
LOGGER.debug("Validating {}...", this.arbitraryDataResource);
|
||||
Service.ValidationResult result = this.service.validate(this.filePath);
|
||||
if (result != Service.ValidationResult.OK) {
|
||||
throw new DataException(String.format("Validation of %s failed: %s", this.service, result.toString()));
|
||||
}
|
||||
LOGGER.debug("Finished validating {}", this.arbitraryDataResource);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -40,7 +40,7 @@ public class ArbitraryDataRenderer {
|
||||
private String inPath;
|
||||
private final String secret58;
|
||||
private final String prefix;
|
||||
private final boolean usePrefix;
|
||||
private final boolean includeResourceIdInPrefix;
|
||||
private final boolean async;
|
||||
private final String qdnContext;
|
||||
private final HttpServletRequest request;
|
||||
@@ -48,7 +48,7 @@ public class ArbitraryDataRenderer {
|
||||
private final ServletContext context;
|
||||
|
||||
public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
|
||||
String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String qdnContext,
|
||||
String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String qdnContext,
|
||||
HttpServletRequest request, HttpServletResponse response, ServletContext context) {
|
||||
|
||||
this.resourceId = resourceId;
|
||||
@@ -58,7 +58,7 @@ public class ArbitraryDataRenderer {
|
||||
this.inPath = inPath;
|
||||
this.secret58 = secret58;
|
||||
this.prefix = prefix;
|
||||
this.usePrefix = usePrefix;
|
||||
this.includeResourceIdInPrefix = includeResourceIdInPrefix;
|
||||
this.async = async;
|
||||
this.qdnContext = qdnContext;
|
||||
this.request = request;
|
||||
@@ -67,8 +67,8 @@ public class ArbitraryDataRenderer {
|
||||
}
|
||||
|
||||
public HttpServletResponse render() {
|
||||
if (!inPath.startsWith(File.separator)) {
|
||||
inPath = File.separator + inPath;
|
||||
if (!inPath.startsWith("/")) {
|
||||
inPath = "/" + inPath;
|
||||
}
|
||||
|
||||
// Don't render data if QDN is disabled
|
||||
@@ -126,7 +126,8 @@ public class ArbitraryDataRenderer {
|
||||
try {
|
||||
String filename = this.getFilename(unzippedPath, inPath);
|
||||
Path filePath = Paths.get(unzippedPath, filename);
|
||||
|
||||
boolean usingCustomRouting = false;
|
||||
|
||||
// If the file doesn't exist, we may need to route the request elsewhere, or cleanup
|
||||
if (!Files.exists(filePath)) {
|
||||
if (inPath.equals("/")) {
|
||||
@@ -148,6 +149,7 @@ public class ArbitraryDataRenderer {
|
||||
// Forward request to index file
|
||||
filePath = indexPath;
|
||||
filename = indexFile;
|
||||
usingCustomRouting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -157,7 +159,7 @@ public class ArbitraryDataRenderer {
|
||||
if (HTMLParser.isHtmlFile(filename)) {
|
||||
// HTML file - needs to be parsed
|
||||
byte[] data = Files.readAllBytes(filePath); // TODO: limit file size that can be read into memory
|
||||
HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext, service, identifier, theme);
|
||||
HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, includeResourceIdInPrefix, data, qdnContext, service, identifier, theme, usingCustomRouting);
|
||||
htmlParser.addAdditionalHeaderTags();
|
||||
response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:;");
|
||||
response.setContentType(context.getMimeType(filename));
|
||||
|
@@ -150,6 +150,9 @@ public class ArbitraryDataResource {
|
||||
|
||||
for (ArbitraryTransactionData transactionData : transactionDataList) {
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData);
|
||||
if (arbitraryDataFile == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete any chunks or complete files from each transaction
|
||||
arbitraryDataFile.deleteAll(deleteMetadata);
|
||||
|
@@ -9,6 +9,7 @@ import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@@ -50,7 +51,7 @@ public class ArbitraryDataMetadata {
|
||||
this.readJson();
|
||||
|
||||
} catch (JSONException e) {
|
||||
throw new DataException(String.format("Unable to read JSON: %s", e.getMessage()));
|
||||
throw new DataException(String.format("Unable to read JSON at path %s: %s", this.filePath, e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +65,10 @@ public class ArbitraryDataMetadata {
|
||||
writer.close();
|
||||
}
|
||||
|
||||
public void delete() throws IOException {
|
||||
Files.delete(this.filePath);
|
||||
}
|
||||
|
||||
|
||||
protected void loadJson() throws IOException {
|
||||
File metadataFile = new File(this.filePath.toString());
|
||||
@@ -71,7 +76,7 @@ public class ArbitraryDataMetadata {
|
||||
throw new IOException(String.format("Metadata file doesn't exist: %s", this.filePath.toString()));
|
||||
}
|
||||
|
||||
this.jsonString = new String(Files.readAllBytes(this.filePath));
|
||||
this.jsonString = new String(Files.readAllBytes(this.filePath), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
|
@@ -9,6 +9,7 @@ import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -69,7 +70,7 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata {
|
||||
throw new IOException(String.format("Patch file doesn't exist: %s", path.toString()));
|
||||
}
|
||||
|
||||
this.jsonString = new String(Files.readAllBytes(path));
|
||||
this.jsonString = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
|
@@ -7,6 +7,7 @@ import org.qortal.arbitrary.misc.Category;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -217,6 +218,25 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
|
||||
// Static helper methods
|
||||
|
||||
public static String trimUTF8String(String string, int maxLength) {
|
||||
byte[] inputBytes = string.getBytes(StandardCharsets.UTF_8);
|
||||
int length = Math.min(inputBytes.length, maxLength);
|
||||
byte[] outputBytes = new byte[length];
|
||||
|
||||
System.arraycopy(inputBytes, 0, outputBytes, 0, length);
|
||||
String result = new String(outputBytes, StandardCharsets.UTF_8);
|
||||
|
||||
// check if last character is truncated
|
||||
int lastIndex = result.length() - 1;
|
||||
|
||||
if (lastIndex > 0 && result.charAt(lastIndex) != string.charAt(lastIndex)) {
|
||||
// last character is truncated so remove the last character
|
||||
return result.substring(0, lastIndex);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String limitTitle(String title) {
|
||||
if (title == null) {
|
||||
return null;
|
||||
@@ -225,7 +245,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
return null;
|
||||
}
|
||||
|
||||
return title.substring(0, Math.min(title.length(), MAX_TITLE_LENGTH));
|
||||
return trimUTF8String(title, MAX_TITLE_LENGTH);
|
||||
}
|
||||
|
||||
public static String limitDescription(String description) {
|
||||
@@ -236,7 +256,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
return null;
|
||||
}
|
||||
|
||||
return description.substring(0, Math.min(description.length(), MAX_DESCRIPTION_LENGTH));
|
||||
return trimUTF8String(description, MAX_DESCRIPTION_LENGTH);
|
||||
}
|
||||
|
||||
public static List<String> limitTags(List<String> tags) {
|
||||
|
@@ -9,7 +9,6 @@ import org.qortal.utils.FilesystemUtils;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
@@ -20,9 +19,9 @@ import static java.util.Arrays.stream;
|
||||
import static java.util.stream.Collectors.toMap;
|
||||
|
||||
public enum Service {
|
||||
AUTO_UPDATE(1, false, null, false, null),
|
||||
ARBITRARY_DATA(100, false, null, false, null),
|
||||
QCHAT_ATTACHMENT(120, true, 1024*1024L, true, null) {
|
||||
AUTO_UPDATE(1, false, null, false, false, null),
|
||||
ARBITRARY_DATA(100, false, null, false, false, null),
|
||||
QCHAT_ATTACHMENT(120, true, 1024*1024L, true, false, null) {
|
||||
@Override
|
||||
public ValidationResult validate(Path path) throws IOException {
|
||||
ValidationResult superclassResult = super.validate(path);
|
||||
@@ -47,7 +46,14 @@ public enum Service {
|
||||
return ValidationResult.OK;
|
||||
}
|
||||
},
|
||||
WEBSITE(200, true, null, false, null) {
|
||||
QCHAT_ATTACHMENT_PRIVATE(121, true, 1024*1024L, true, true, null),
|
||||
ATTACHMENT(130, false, 50*1024*1024L, true, false, null),
|
||||
ATTACHMENT_PRIVATE(131, true, 50*1024*1024L, true, true, null),
|
||||
FILE(140, false, null, true, false, null),
|
||||
FILE_PRIVATE(141, true, null, true, true, null),
|
||||
FILES(150, false, null, false, false, null),
|
||||
CHAIN_DATA(160, true, 239L, true, false, null),
|
||||
WEBSITE(200, true, null, false, false, null) {
|
||||
@Override
|
||||
public ValidationResult validate(Path path) throws IOException {
|
||||
ValidationResult superclassResult = super.validate(path);
|
||||
@@ -69,23 +75,30 @@ public enum Service {
|
||||
return ValidationResult.MISSING_INDEX_FILE;
|
||||
}
|
||||
},
|
||||
GIT_REPOSITORY(300, false, null, false, null),
|
||||
IMAGE(400, true, 10*1024*1024L, true, null),
|
||||
THUMBNAIL(410, true, 500*1024L, true, null),
|
||||
QCHAT_IMAGE(420, true, 500*1024L, true, null),
|
||||
VIDEO(500, false, null, true, null),
|
||||
AUDIO(600, false, null, true, null),
|
||||
QCHAT_AUDIO(610, true, 10*1024*1024L, true, null),
|
||||
QCHAT_VOICE(620, true, 10*1024*1024L, true, null),
|
||||
BLOG(700, false, null, false, null),
|
||||
BLOG_POST(777, false, null, true, null),
|
||||
BLOG_COMMENT(778, false, null, true, null),
|
||||
DOCUMENT(800, false, null, true, null),
|
||||
LIST(900, true, null, true, null),
|
||||
PLAYLIST(910, true, null, true, null),
|
||||
APP(1000, true, 50*1024*1024L, false, null),
|
||||
METADATA(1100, false, null, true, null),
|
||||
JSON(1110, true, 25*1024L, true, null) {
|
||||
GIT_REPOSITORY(300, false, null, false, false, null),
|
||||
IMAGE(400, true, 10*1024*1024L, true, false, null),
|
||||
IMAGE_PRIVATE(401, true, 10*1024*1024L, true, true, null),
|
||||
THUMBNAIL(410, true, 500*1024L, true, false, null),
|
||||
QCHAT_IMAGE(420, true, 500*1024L, true, false, null),
|
||||
VIDEO(500, false, null, true, false, null),
|
||||
VIDEO_PRIVATE(501, true, null, true, true, null),
|
||||
AUDIO(600, false, null, true, false, null),
|
||||
AUDIO_PRIVATE(601, true, null, true, true, null),
|
||||
QCHAT_AUDIO(610, true, 10*1024*1024L, true, false, null),
|
||||
QCHAT_VOICE(620, true, 10*1024*1024L, true, false, null),
|
||||
VOICE(630, true, 10*1024*1024L, true, false, null),
|
||||
VOICE_PRIVATE(631, true, 10*1024*1024L, true, true, null),
|
||||
PODCAST(640, false, null, true, false, null),
|
||||
BLOG(700, false, null, false, false, null),
|
||||
BLOG_POST(777, false, null, true, false, null),
|
||||
BLOG_COMMENT(778, true, 500*1024L, true, false, null),
|
||||
DOCUMENT(800, false, null, true, false, null),
|
||||
DOCUMENT_PRIVATE(801, true, null, true, true, null),
|
||||
LIST(900, true, null, true, false, null),
|
||||
PLAYLIST(910, true, null, true, false, null),
|
||||
APP(1000, true, 50*1024*1024L, false, false, null),
|
||||
METADATA(1100, false, null, true, false, null),
|
||||
JSON(1110, true, 25*1024L, true, false, null) {
|
||||
@Override
|
||||
public ValidationResult validate(Path path) throws IOException {
|
||||
ValidationResult superclassResult = super.validate(path);
|
||||
@@ -94,7 +107,7 @@ public enum Service {
|
||||
}
|
||||
|
||||
// Require valid JSON
|
||||
byte[] data = FilesystemUtils.getSingleFileContents(path);
|
||||
byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024);
|
||||
String json = new String(data, StandardCharsets.UTF_8);
|
||||
try {
|
||||
objectMapper.readTree(json);
|
||||
@@ -104,7 +117,7 @@ public enum Service {
|
||||
}
|
||||
}
|
||||
},
|
||||
GIF_REPOSITORY(1200, true, 25*1024*1024L, false, null) {
|
||||
GIF_REPOSITORY(1200, true, 25*1024*1024L, false, false, null) {
|
||||
@Override
|
||||
public ValidationResult validate(Path path) throws IOException {
|
||||
ValidationResult superclassResult = super.validate(path);
|
||||
@@ -139,12 +152,31 @@ public enum Service {
|
||||
}
|
||||
return ValidationResult.OK;
|
||||
}
|
||||
};
|
||||
},
|
||||
STORE(1300, false, null, true, false, null),
|
||||
PRODUCT(1310, false, null, true, false, null),
|
||||
OFFER(1330, false, null, true, false, null),
|
||||
COUPON(1340, false, null, true, false, null),
|
||||
CODE(1400, false, null, true, false, null),
|
||||
PLUGIN(1410, false, null, true, false, null),
|
||||
EXTENSION(1420, false, null, true, false, null),
|
||||
GAME(1500, false, null, false, false, null),
|
||||
ITEM(1510, false, null, true, false, null),
|
||||
NFT(1600, false, null, true, false, null),
|
||||
DATABASE(1700, false, null, false, false, null),
|
||||
SNAPSHOT(1710, false, null, false, false, null),
|
||||
COMMENT(1800, true, 500*1024L, true, false, null),
|
||||
CHAIN_COMMENT(1810, true, 239L, true, false, null),
|
||||
MAIL(1900, true, 1024*1024L, true, false, null),
|
||||
MAIL_PRIVATE(1901, true, 1024*1024L, true, true, null),
|
||||
MESSAGE(1910, true, 1024*1024L, true, false, null),
|
||||
MESSAGE_PRIVATE(1911, true, 1024*1024L, true, true, null);
|
||||
|
||||
public final int value;
|
||||
private final boolean requiresValidation;
|
||||
private final Long maxSize;
|
||||
private final boolean single;
|
||||
private final boolean isPrivate;
|
||||
private final List<String> requiredKeys;
|
||||
|
||||
private static final Map<Integer, Service> map = stream(Service.values())
|
||||
@@ -153,11 +185,15 @@ public enum Service {
|
||||
// For JSON validation
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
Service(int value, boolean requiresValidation, Long maxSize, boolean single, List<String> requiredKeys) {
|
||||
private static final String encryptedDataPrefix = "qortalEncryptedData";
|
||||
private static final String encryptedGroupDataPrefix = "qortalGroupEncryptedData";
|
||||
|
||||
Service(int value, boolean requiresValidation, Long maxSize, boolean single, boolean isPrivate, List<String> requiredKeys) {
|
||||
this.value = value;
|
||||
this.requiresValidation = requiresValidation;
|
||||
this.maxSize = maxSize;
|
||||
this.single = single;
|
||||
this.isPrivate = isPrivate;
|
||||
this.requiredKeys = requiredKeys;
|
||||
}
|
||||
|
||||
@@ -166,7 +202,9 @@ public enum Service {
|
||||
return ValidationResult.OK;
|
||||
}
|
||||
|
||||
byte[] data = FilesystemUtils.getSingleFileContents(path);
|
||||
// Load the first 25KB of data. This only needs to be long enough to check the prefix
|
||||
// and also to allow for possible additional future validation of smaller files.
|
||||
byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024);
|
||||
long size = FilesystemUtils.getDirectorySize(path);
|
||||
|
||||
// Validate max size if needed
|
||||
@@ -181,6 +219,17 @@ public enum Service {
|
||||
return ValidationResult.INVALID_FILE_COUNT;
|
||||
}
|
||||
|
||||
// Validate private data for single file resources
|
||||
if (this.single) {
|
||||
String dataString = new String(data, StandardCharsets.UTF_8);
|
||||
if (this.isPrivate && !dataString.startsWith(encryptedDataPrefix) && !dataString.startsWith(encryptedGroupDataPrefix)) {
|
||||
return ValidationResult.DATA_NOT_ENCRYPTED;
|
||||
}
|
||||
if (!this.isPrivate && (dataString.startsWith(encryptedDataPrefix) || dataString.startsWith(encryptedGroupDataPrefix))) {
|
||||
return ValidationResult.DATA_ENCRYPTED;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required keys if needed
|
||||
if (this.requiredKeys != null) {
|
||||
if (data == null) {
|
||||
@@ -199,7 +248,12 @@ public enum Service {
|
||||
}
|
||||
|
||||
public boolean isValidationRequired() {
|
||||
return this.requiresValidation;
|
||||
// We must always validate single file resources, to ensure they are actually a single file
|
||||
return this.requiresValidation || this.single;
|
||||
}
|
||||
|
||||
public boolean isPrivate() {
|
||||
return this.isPrivate;
|
||||
}
|
||||
|
||||
public static Service valueOf(int value) {
|
||||
@@ -207,10 +261,41 @@ public enum Service {
|
||||
}
|
||||
|
||||
public static JSONObject toJsonObject(byte[] data) {
|
||||
String dataString = new String(data);
|
||||
String dataString = new String(data, StandardCharsets.UTF_8);
|
||||
return new JSONObject(dataString);
|
||||
}
|
||||
|
||||
public static List<Service> publicServices() {
|
||||
List<Service> privateServices = new ArrayList<>();
|
||||
for (Service service : Service.values()) {
|
||||
if (!service.isPrivate) {
|
||||
privateServices.add(service);
|
||||
}
|
||||
}
|
||||
return privateServices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a list of Service objects that require encrypted data.
|
||||
*
|
||||
* These can ultimately be used to help inform the cleanup manager
|
||||
* on the best order to delete files when the node runs out of space.
|
||||
* Public data should be given priority over private data (unless
|
||||
* this node is part of a data market contract for that data - this
|
||||
* isn't developed yet).
|
||||
*
|
||||
* @return a list of Service objects that require encrypted data.
|
||||
*/
|
||||
public static List<Service> privateServices() {
|
||||
List<Service> privateServices = new ArrayList<>();
|
||||
for (Service service : Service.values()) {
|
||||
if (service.isPrivate) {
|
||||
privateServices.add(service);
|
||||
}
|
||||
}
|
||||
return privateServices;
|
||||
}
|
||||
|
||||
public enum ValidationResult {
|
||||
OK(1),
|
||||
MISSING_KEYS(2),
|
||||
@@ -220,7 +305,9 @@ public enum Service {
|
||||
INVALID_FILE_EXTENSION(6),
|
||||
MISSING_DATA(7),
|
||||
INVALID_FILE_COUNT(8),
|
||||
INVALID_CONTENT(9);
|
||||
INVALID_CONTENT(9),
|
||||
DATA_NOT_ENCRYPTED(10),
|
||||
DATA_ENCRYPTED(10);
|
||||
|
||||
public final int value;
|
||||
|
||||
|
@@ -1213,10 +1213,18 @@ public class Block {
|
||||
// Apply fix for block 212937 but fix will be rolled back before we exit method
|
||||
Block212937.processFix(this);
|
||||
}
|
||||
else if (this.blockData.getHeight() == 1333492) {
|
||||
// Apply fix for block 1333492 but fix will be rolled back before we exit method
|
||||
Block1333492.processFix(this);
|
||||
}
|
||||
else if (InvalidNameRegistrationBlocks.isAffectedBlock(this.blockData.getHeight())) {
|
||||
// Apply fix for affected name registration blocks, but fix will be rolled back before we exit method
|
||||
InvalidNameRegistrationBlocks.processFix(this);
|
||||
}
|
||||
else if (InvalidBalanceBlocks.isAffectedBlock(this.blockData.getHeight())) {
|
||||
// Apply fix for affected balance blocks, but fix will be rolled back before we exit method
|
||||
InvalidBalanceBlocks.processFix(this);
|
||||
}
|
||||
|
||||
for (Transaction transaction : this.getTransactions()) {
|
||||
TransactionData transactionData = transaction.getTransactionData();
|
||||
@@ -1464,12 +1472,21 @@ public class Block {
|
||||
// Distribute block rewards, including transaction fees, before transactions processed
|
||||
processBlockRewards();
|
||||
|
||||
if (this.blockData.getHeight() == 212937)
|
||||
if (this.blockData.getHeight() == 212937) {
|
||||
// Apply fix for block 212937
|
||||
Block212937.processFix(this);
|
||||
|
||||
else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height())
|
||||
}
|
||||
else if (this.blockData.getHeight() == 1333492) {
|
||||
// Apply fix for block 1333492
|
||||
Block1333492.processFix(this);
|
||||
}
|
||||
else if (InvalidBalanceBlocks.isAffectedBlock(this.blockData.getHeight())) {
|
||||
// Apply fix for affected balance blocks
|
||||
InvalidBalanceBlocks.processFix(this);
|
||||
}
|
||||
else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) {
|
||||
SelfSponsorshipAlgoV1Block.processAccountPenalties(this);
|
||||
}
|
||||
}
|
||||
|
||||
// We're about to (test-)process a batch of transactions,
|
||||
@@ -1686,12 +1703,14 @@ public class Block {
|
||||
transactionData.getSignature());
|
||||
this.repository.getBlockRepository().save(blockTransactionData);
|
||||
|
||||
// Update transaction's height in repository
|
||||
// Update transaction's height in repository and local transactionData
|
||||
transactionRepository.updateBlockHeight(transactionData.getSignature(), this.blockData.getHeight());
|
||||
|
||||
// Update local transactionData's height too
|
||||
transaction.getTransactionData().setBlockHeight(this.blockData.getHeight());
|
||||
|
||||
// Update transaction's sequence in repository and local transactionData
|
||||
transactionRepository.updateBlockSequence(transactionData.getSignature(), sequence);
|
||||
transaction.getTransactionData().setBlockSequence(sequence);
|
||||
|
||||
// No longer unconfirmed
|
||||
transactionRepository.confirmTransaction(transactionData.getSignature());
|
||||
|
||||
@@ -1724,12 +1743,21 @@ public class Block {
|
||||
// Invalidate expandedAccounts as they may have changed due to orphaning TRANSFER_PRIVS transactions, etc.
|
||||
this.cachedExpandedAccounts = null;
|
||||
|
||||
if (this.blockData.getHeight() == 212937)
|
||||
if (this.blockData.getHeight() == 212937) {
|
||||
// Revert fix for block 212937
|
||||
Block212937.orphanFix(this);
|
||||
|
||||
else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height())
|
||||
}
|
||||
else if (this.blockData.getHeight() == 1333492) {
|
||||
// Revert fix for block 1333492
|
||||
Block1333492.orphanFix(this);
|
||||
}
|
||||
else if (InvalidBalanceBlocks.isAffectedBlock(this.blockData.getHeight())) {
|
||||
// Revert fix for affected balance blocks
|
||||
InvalidBalanceBlocks.orphanFix(this);
|
||||
}
|
||||
else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) {
|
||||
SelfSponsorshipAlgoV1Block.orphanAccountPenalties(this);
|
||||
}
|
||||
|
||||
// Block rewards, including transaction fees, removed after transactions undone
|
||||
orphanBlockRewards();
|
||||
@@ -1778,6 +1806,9 @@ public class Block {
|
||||
|
||||
// Unset height
|
||||
transactionRepository.updateBlockHeight(transactionData.getSignature(), null);
|
||||
|
||||
// Unset sequence
|
||||
transactionRepository.updateBlockSequence(transactionData.getSignature(), null);
|
||||
}
|
||||
|
||||
transactionRepository.deleteParticipants(transactionData);
|
||||
|
101
src/main/java/org/qortal/block/Block1333492.java
Normal file
101
src/main/java/org/qortal/block/Block1333492.java
Normal file
@@ -0,0 +1,101 @@
|
||||
package org.qortal.block;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.eclipse.persistence.jaxb.JAXBContextFactory;
|
||||
import org.eclipse.persistence.jaxb.UnmarshallerProperties;
|
||||
import org.qortal.data.account.AccountBalanceData;
|
||||
import org.qortal.repository.DataException;
|
||||
|
||||
import javax.xml.bind.JAXBContext;
|
||||
import javax.xml.bind.JAXBException;
|
||||
import javax.xml.bind.UnmarshalException;
|
||||
import javax.xml.bind.Unmarshaller;
|
||||
import javax.xml.transform.stream.StreamSource;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Block 1333492
|
||||
* <p>
|
||||
* As described in InvalidBalanceBlocks.java, legacy bugs caused a small drift in account balances.
|
||||
* This block adjusts any remaining differences between a clean reindex/resync and a recent bootstrap.
|
||||
* <p>
|
||||
* The block height 1333492 isn't significant - it's simply the height of a recent bootstrap at the
|
||||
* time of development, so that the account balances could be accessed and compared against the same
|
||||
* block in a reindexed db.
|
||||
* <p>
|
||||
* As with InvalidBalanceBlocks, the discrepancies are insignificant, except for a single
|
||||
* account which has a 3.03 QORT discrepancy. This was due to the account being the first recipient
|
||||
* of a name sale and encountering an early bug in this area.
|
||||
* <p>
|
||||
* The total offset for this block is 3.02816514 QORT.
|
||||
*/
|
||||
public final class Block1333492 {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(Block1333492.class);
|
||||
private static final String ACCOUNT_DELTAS_SOURCE = "block-1333492-deltas.json";
|
||||
|
||||
private static final List<AccountBalanceData> accountDeltas = readAccountDeltas();
|
||||
|
||||
private Block1333492() {
|
||||
/* Do not instantiate */
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<AccountBalanceData> readAccountDeltas() {
|
||||
Unmarshaller unmarshaller;
|
||||
|
||||
try {
|
||||
// Create JAXB context aware of classes we need to unmarshal
|
||||
JAXBContext jc = JAXBContextFactory.createContext(new Class[] {
|
||||
AccountBalanceData.class
|
||||
}, null);
|
||||
|
||||
// Create unmarshaller
|
||||
unmarshaller = jc.createUnmarshaller();
|
||||
|
||||
// Set the unmarshaller media type to JSON
|
||||
unmarshaller.setProperty(UnmarshallerProperties.MEDIA_TYPE, "application/json");
|
||||
|
||||
// Tell unmarshaller that there's no JSON root element in the JSON input
|
||||
unmarshaller.setProperty(UnmarshallerProperties.JSON_INCLUDE_ROOT, false);
|
||||
} catch (JAXBException e) {
|
||||
String message = "Failed to setup unmarshaller to read block 1333492 deltas";
|
||||
LOGGER.error(message, e);
|
||||
throw new RuntimeException(message, e);
|
||||
}
|
||||
|
||||
ClassLoader classLoader = BlockChain.class.getClassLoader();
|
||||
InputStream in = classLoader.getResourceAsStream(ACCOUNT_DELTAS_SOURCE);
|
||||
StreamSource jsonSource = new StreamSource(in);
|
||||
|
||||
try {
|
||||
// Attempt to unmarshal JSON stream to BlockChain config
|
||||
return (List<AccountBalanceData>) unmarshaller.unmarshal(jsonSource, AccountBalanceData.class).getValue();
|
||||
} catch (UnmarshalException e) {
|
||||
String message = "Failed to parse block 1333492 deltas";
|
||||
LOGGER.error(message, e);
|
||||
throw new RuntimeException(message, e);
|
||||
} catch (JAXBException e) {
|
||||
String message = "Unexpected JAXB issue while processing block 1333492 deltas";
|
||||
LOGGER.error(message, e);
|
||||
throw new RuntimeException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void processFix(Block block) throws DataException {
|
||||
block.repository.getAccountRepository().modifyAssetBalances(accountDeltas);
|
||||
}
|
||||
|
||||
public static void orphanFix(Block block) throws DataException {
|
||||
// Create inverse deltas
|
||||
List<AccountBalanceData> inverseDeltas = accountDeltas.stream()
|
||||
.map(delta -> new AccountBalanceData(delta.getAddress(), delta.getAssetId(), 0 - delta.getBalance()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
block.repository.getAccountRepository().modifyAssetBalances(inverseDeltas);
|
||||
}
|
||||
|
||||
}
|
@@ -79,7 +79,8 @@ public class BlockChain {
|
||||
selfSponsorshipAlgoV1Height,
|
||||
feeValidationFixTimestamp,
|
||||
chatReferenceTimestamp,
|
||||
arbitraryOptionalFeeTimestamp;
|
||||
arbitraryOptionalFeeTimestamp,
|
||||
cancelSellNameValidationTimestamp;
|
||||
}
|
||||
|
||||
// Custom transaction fees
|
||||
@@ -527,6 +528,10 @@ public class BlockChain {
|
||||
return this.featureTriggers.get(FeatureTrigger.arbitraryOptionalFeeTimestamp.name()).longValue();
|
||||
}
|
||||
|
||||
public long getCancelSellNameValidationTimestamp() {
|
||||
return this.featureTriggers.get(FeatureTrigger.cancelSellNameValidationTimestamp.name()).longValue();
|
||||
}
|
||||
|
||||
|
||||
// More complex getters for aspects that change by height or timestamp
|
||||
|
||||
@@ -871,6 +876,9 @@ public class BlockChain {
|
||||
BlockData orphanBlockData = repository.getBlockRepository().fromHeight(height);
|
||||
|
||||
while (height > targetHeight) {
|
||||
if (Controller.isStopping()) {
|
||||
return false;
|
||||
}
|
||||
LOGGER.info(String.format("Forcably orphaning block %d", height));
|
||||
|
||||
Block block = new Block(repository, orphanBlockData);
|
||||
|
134
src/main/java/org/qortal/block/InvalidBalanceBlocks.java
Normal file
134
src/main/java/org/qortal/block/InvalidBalanceBlocks.java
Normal file
@@ -0,0 +1,134 @@
|
||||
package org.qortal.block;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.eclipse.persistence.jaxb.JAXBContextFactory;
|
||||
import org.eclipse.persistence.jaxb.UnmarshallerProperties;
|
||||
import org.qortal.data.account.AccountBalanceData;
|
||||
import org.qortal.repository.DataException;
|
||||
|
||||
import javax.xml.bind.JAXBContext;
|
||||
import javax.xml.bind.JAXBException;
|
||||
import javax.xml.bind.UnmarshalException;
|
||||
import javax.xml.bind.Unmarshaller;
|
||||
import javax.xml.transform.stream.StreamSource;
|
||||
import java.io.InputStream;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
||||
/**
|
||||
* Due to various bugs - which have been fixed - a small amount of balance drift occurred
|
||||
* in the chainstate of running nodes and bootstraps, when compared with a clean sync from genesis.
|
||||
* This resulted in a significant number of invalid transactions in the chain history due to
|
||||
* subtle balance discrepancies. The sum of all discrepancies that resulted in an invalid
|
||||
* transaction is 0.00198322 QORT, so despite the large quantity of transactions, they
|
||||
* represent an insignificant amount when summed.
|
||||
* <p>
|
||||
* This class is responsible for retroactively fixing all the past transactions which
|
||||
* are invalid due to the balance discrepancies.
|
||||
*/
|
||||
|
||||
|
||||
public final class InvalidBalanceBlocks {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(InvalidBalanceBlocks.class);
|
||||
|
||||
private static final String ACCOUNT_DELTAS_SOURCE = "invalid-transaction-balance-deltas.json";
|
||||
|
||||
private static final List<AccountBalanceData> accountDeltas = readAccountDeltas();
|
||||
private static final List<Integer> affectedHeights = getAffectedHeights();
|
||||
|
||||
private InvalidBalanceBlocks() {
|
||||
/* Do not instantiate */
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<AccountBalanceData> readAccountDeltas() {
|
||||
Unmarshaller unmarshaller;
|
||||
|
||||
try {
|
||||
// Create JAXB context aware of classes we need to unmarshal
|
||||
JAXBContext jc = JAXBContextFactory.createContext(new Class[] {
|
||||
AccountBalanceData.class
|
||||
}, null);
|
||||
|
||||
// Create unmarshaller
|
||||
unmarshaller = jc.createUnmarshaller();
|
||||
|
||||
// Set the unmarshaller media type to JSON
|
||||
unmarshaller.setProperty(UnmarshallerProperties.MEDIA_TYPE, "application/json");
|
||||
|
||||
// Tell unmarshaller that there's no JSON root element in the JSON input
|
||||
unmarshaller.setProperty(UnmarshallerProperties.JSON_INCLUDE_ROOT, false);
|
||||
} catch (JAXBException e) {
|
||||
String message = "Failed to setup unmarshaller to read block 212937 deltas";
|
||||
LOGGER.error(message, e);
|
||||
throw new RuntimeException(message, e);
|
||||
}
|
||||
|
||||
ClassLoader classLoader = BlockChain.class.getClassLoader();
|
||||
InputStream in = classLoader.getResourceAsStream(ACCOUNT_DELTAS_SOURCE);
|
||||
StreamSource jsonSource = new StreamSource(in);
|
||||
|
||||
try {
|
||||
// Attempt to unmarshal JSON stream to BlockChain config
|
||||
return (List<AccountBalanceData>) unmarshaller.unmarshal(jsonSource, AccountBalanceData.class).getValue();
|
||||
} catch (UnmarshalException e) {
|
||||
String message = "Failed to parse balance deltas";
|
||||
LOGGER.error(message, e);
|
||||
throw new RuntimeException(message, e);
|
||||
} catch (JAXBException e) {
|
||||
String message = "Unexpected JAXB issue while processing balance deltas";
|
||||
LOGGER.error(message, e);
|
||||
throw new RuntimeException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Integer> getAffectedHeights() {
|
||||
List<Integer> heights = new ArrayList<>();
|
||||
for (AccountBalanceData accountBalanceData : accountDeltas) {
|
||||
if (!heights.contains(accountBalanceData.getHeight())) {
|
||||
heights.add(accountBalanceData.getHeight());
|
||||
}
|
||||
}
|
||||
return heights;
|
||||
}
|
||||
|
||||
private static List<AccountBalanceData> getAccountDeltasAtHeight(int height) {
|
||||
return accountDeltas.stream().filter(a -> a.getHeight() == height).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public static boolean isAffectedBlock(int height) {
|
||||
return affectedHeights.contains(Integer.valueOf(height));
|
||||
}
|
||||
|
||||
public static void processFix(Block block) throws DataException {
|
||||
Integer blockHeight = block.getBlockData().getHeight();
|
||||
List<AccountBalanceData> deltas = getAccountDeltasAtHeight(blockHeight);
|
||||
if (deltas == null) {
|
||||
throw new DataException(String.format("Unable to lookup invalid balance data for block height %d", blockHeight));
|
||||
}
|
||||
|
||||
block.repository.getAccountRepository().modifyAssetBalances(deltas);
|
||||
|
||||
LOGGER.info("Applied balance patch for block {}", blockHeight);
|
||||
}
|
||||
|
||||
public static void orphanFix(Block block) throws DataException {
|
||||
Integer blockHeight = block.getBlockData().getHeight();
|
||||
List<AccountBalanceData> deltas = getAccountDeltasAtHeight(blockHeight);
|
||||
if (deltas == null) {
|
||||
throw new DataException(String.format("Unable to lookup invalid balance data for block height %d", blockHeight));
|
||||
}
|
||||
|
||||
// Create inverse delta(s)
|
||||
for (AccountBalanceData accountBalanceData : deltas) {
|
||||
AccountBalanceData inverseBalanceData = new AccountBalanceData(accountBalanceData.getAddress(), accountBalanceData.getAssetId(), -accountBalanceData.getBalance());
|
||||
block.repository.getAccountRepository().modifyAssetBalances(List.of(inverseBalanceData));
|
||||
}
|
||||
|
||||
LOGGER.info("Reverted balance patch for block {}", blockHeight);
|
||||
}
|
||||
|
||||
}
|
@@ -380,9 +380,13 @@ public class BlockMinter extends Thread {
|
||||
parentSignatureForLastLowWeightBlock = null;
|
||||
timeOfLastLowWeightBlock = null;
|
||||
|
||||
Long unconfirmedStartTime = NTP.getTime();
|
||||
|
||||
// Add unconfirmed transactions
|
||||
addUnconfirmedTransactions(repository, newBlock);
|
||||
|
||||
LOGGER.info(String.format("Adding %d unconfirmed transactions took %d ms", newBlock.getTransactions().size(), (NTP.getTime()-unconfirmedStartTime)));
|
||||
|
||||
// Sign to create block's signature
|
||||
newBlock.sign();
|
||||
|
||||
@@ -484,6 +488,9 @@ public class BlockMinter extends Thread {
|
||||
// Sign to create block's signature, needed by Block.isValid()
|
||||
newBlock.sign();
|
||||
|
||||
// User-defined limit per block
|
||||
int limit = Settings.getInstance().getMaxTransactionsPerBlock();
|
||||
|
||||
// Attempt to add transactions until block is full, or we run out
|
||||
// If a transaction makes the block invalid then skip it and it'll either expire or be in next block.
|
||||
for (TransactionData transactionData : unconfirmedTransactions) {
|
||||
@@ -496,6 +503,12 @@ public class BlockMinter extends Thread {
|
||||
LOGGER.debug(() -> String.format("Skipping invalid transaction %s during block minting", Base58.encode(transactionData.getSignature())));
|
||||
newBlock.deleteTransaction(transactionData);
|
||||
}
|
||||
|
||||
// User-defined limit per block
|
||||
List<Transaction> transactions = newBlock.getTransactions();
|
||||
if (transactions != null && transactions.size() >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -400,10 +400,13 @@ public class Controller extends Thread {
|
||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl());
|
||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||
RepositoryManager.setRequestedCheckpoint(Boolean.TRUE);
|
||||
}
|
||||
catch (DataException e) {
|
||||
// If exception has no cause then repository is in use by some other process.
|
||||
if (e.getCause() == null) {
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
RepositoryManager.rebuildTransactionSequences(repository);
|
||||
}
|
||||
} catch (DataException e) {
|
||||
// If exception has no cause or message then repository is in use by some other process.
|
||||
if (e.getCause() == null && e.getMessage() == null) {
|
||||
LOGGER.info("Repository in use by another process?");
|
||||
Gui.getInstance().fatalError("Repository issue", "Repository in use by another process?");
|
||||
} else {
|
||||
@@ -437,6 +440,19 @@ public class Controller extends Thread {
|
||||
}
|
||||
}
|
||||
|
||||
try (Repository repository = RepositoryManager.getRepository()) {
|
||||
if (RepositoryManager.needsTransactionSequenceRebuild(repository)) {
|
||||
// Don't allow the node to start if transaction sequences haven't been built yet
|
||||
// This is needed to handle a case when bootstrapping
|
||||
LOGGER.error("Database upgrade needed. Please restart the core to complete the upgrade process.");
|
||||
Gui.getInstance().fatalError("Database upgrade needed", "Please restart the core to complete the upgrade process.");
|
||||
return;
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Error checking transaction sequences in repository", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Import current trade bot states and minting accounts if they exist
|
||||
Controller.importRepositoryData();
|
||||
|
||||
@@ -1262,13 +1278,6 @@ public class Controller extends Thread {
|
||||
TransactionImporter.getInstance().onNetworkTransactionSignaturesMessage(peer, message);
|
||||
break;
|
||||
|
||||
case GET_ONLINE_ACCOUNTS:
|
||||
case ONLINE_ACCOUNTS:
|
||||
case GET_ONLINE_ACCOUNTS_V2:
|
||||
case ONLINE_ACCOUNTS_V2:
|
||||
// No longer supported - to be eventually removed
|
||||
break;
|
||||
|
||||
case GET_ONLINE_ACCOUNTS_V3:
|
||||
OnlineAccountsManager.getInstance().onNetworkGetOnlineAccountsV3Message(peer, message);
|
||||
break;
|
||||
|
74
src/main/java/org/qortal/controller/DevProxyManager.java
Normal file
74
src/main/java/org/qortal/controller/DevProxyManager.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package org.qortal.controller;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.api.DevProxyService;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
public class DevProxyManager {
|
||||
|
||||
protected static final Logger LOGGER = LogManager.getLogger(DevProxyManager.class);
|
||||
|
||||
private static DevProxyManager instance;
|
||||
|
||||
private boolean running = false;
|
||||
|
||||
private String sourceHostAndPort = "127.0.0.1:5173"; // Default for React/Vite
|
||||
|
||||
private DevProxyManager() {
|
||||
|
||||
}
|
||||
|
||||
public static DevProxyManager getInstance() {
|
||||
if (instance == null)
|
||||
instance = new DevProxyManager();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void start() throws DataException {
|
||||
synchronized(this) {
|
||||
if (this.running) {
|
||||
// Already running
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.info(String.format("Starting developer proxy service on port %d", Settings.getInstance().getDevProxyPort()));
|
||||
DevProxyService devProxyService = DevProxyService.getInstance();
|
||||
devProxyService.start();
|
||||
this.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
synchronized(this) {
|
||||
if (!this.running) {
|
||||
// Not running
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.info(String.format("Shutting down developer proxy service"));
|
||||
DevProxyService devProxyService = DevProxyService.getInstance();
|
||||
devProxyService.stop();
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void setSourceHostAndPort(String sourceHostAndPort) {
|
||||
this.sourceHostAndPort = sourceHostAndPort;
|
||||
}
|
||||
|
||||
public String getSourceHostAndPort() {
|
||||
return this.sourceHostAndPort;
|
||||
}
|
||||
|
||||
public Integer getPort() {
|
||||
return Settings.getInstance().getDevProxyPort();
|
||||
}
|
||||
|
||||
public boolean isRunning() {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
}
|
@@ -414,7 +414,7 @@ public class OnlineAccountsManager {
|
||||
boolean isSuperiorEntry = isOnlineAccountsDataSuperior(onlineAccountData);
|
||||
if (isSuperiorEntry)
|
||||
// Remove existing inferior entry so it can be re-added below (it's likely the existing copy is missing a nonce value)
|
||||
onlineAccounts.remove(onlineAccountData);
|
||||
onlineAccounts.removeIf(a -> Objects.equals(a.getPublicKey(), onlineAccountData.getPublicKey()));
|
||||
|
||||
boolean isNewEntry = onlineAccounts.add(onlineAccountData);
|
||||
|
||||
@@ -504,110 +504,118 @@ public class OnlineAccountsManager {
|
||||
computeOurAccountsForTimestamp(onlineAccountsTimestamp);
|
||||
}
|
||||
|
||||
private boolean computeOurAccountsForTimestamp(long onlineAccountsTimestamp) {
|
||||
List<MintingAccountData> mintingAccounts;
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
mintingAccounts = repository.getAccountRepository().getMintingAccounts();
|
||||
private boolean computeOurAccountsForTimestamp(Long onlineAccountsTimestamp) {
|
||||
if (onlineAccountsTimestamp != null) {
|
||||
List<MintingAccountData> mintingAccounts;
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
mintingAccounts = repository.getAccountRepository().getMintingAccounts();
|
||||
|
||||
// We have no accounts to send
|
||||
if (mintingAccounts.isEmpty())
|
||||
// We have no accounts to send
|
||||
if (mintingAccounts.isEmpty())
|
||||
return false;
|
||||
|
||||
// Only active reward-shares allowed
|
||||
Iterator<MintingAccountData> iterator = mintingAccounts.iterator();
|
||||
int i = 0;
|
||||
while (iterator.hasNext()) {
|
||||
MintingAccountData mintingAccountData = iterator.next();
|
||||
|
||||
RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey());
|
||||
if (rewardShareData == null) {
|
||||
// Reward-share doesn't even exist - probably not a good sign
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
Account mintingAccount = new Account(repository, rewardShareData.getMinter());
|
||||
if (!mintingAccount.canMint()) {
|
||||
// Minting-account component of reward-share can no longer mint - disregard
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (++i > 1 + 1) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to fetch minting accounts: %s", e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only active reward-shares allowed
|
||||
Iterator<MintingAccountData> iterator = mintingAccounts.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
MintingAccountData mintingAccountData = iterator.next();
|
||||
byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp);
|
||||
List<OnlineAccountData> ourOnlineAccounts = new ArrayList<>();
|
||||
|
||||
RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey());
|
||||
if (rewardShareData == null) {
|
||||
// Reward-share doesn't even exist - probably not a good sign
|
||||
iterator.remove();
|
||||
int remaining = mintingAccounts.size();
|
||||
for (MintingAccountData mintingAccountData : mintingAccounts) {
|
||||
remaining--;
|
||||
byte[] privateKey = mintingAccountData.getPrivateKey();
|
||||
byte[] publicKey = Crypto.toPublicKey(privateKey);
|
||||
|
||||
// We don't want to compute the online account nonce and signature again if it already exists
|
||||
Set<OnlineAccountData> onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountsTimestamp, k -> ConcurrentHashMap.newKeySet());
|
||||
boolean alreadyExists = onlineAccounts.stream().anyMatch(a -> Arrays.equals(a.getPublicKey(), publicKey));
|
||||
if (alreadyExists) {
|
||||
this.hasOurOnlineAccounts = true;
|
||||
|
||||
if (remaining > 0) {
|
||||
// Move on to next account
|
||||
continue;
|
||||
} else {
|
||||
// Everything exists, so return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate bytes for mempow
|
||||
byte[] mempowBytes;
|
||||
try {
|
||||
mempowBytes = this.getMemoryPoWBytes(publicKey, onlineAccountsTimestamp);
|
||||
} catch (IOException e) {
|
||||
LOGGER.info("Unable to create bytes for MemoryPoW. Moving on to next account...");
|
||||
continue;
|
||||
}
|
||||
|
||||
Account mintingAccount = new Account(repository, rewardShareData.getMinter());
|
||||
if (!mintingAccount.canMint()) {
|
||||
// Minting-account component of reward-share can no longer mint - disregard
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to fetch minting accounts: %s", e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp);
|
||||
List<OnlineAccountData> ourOnlineAccounts = new ArrayList<>();
|
||||
|
||||
int remaining = mintingAccounts.size();
|
||||
for (MintingAccountData mintingAccountData : mintingAccounts) {
|
||||
remaining--;
|
||||
byte[] privateKey = mintingAccountData.getPrivateKey();
|
||||
byte[] publicKey = Crypto.toPublicKey(privateKey);
|
||||
|
||||
// We don't want to compute the online account nonce and signature again if it already exists
|
||||
Set<OnlineAccountData> onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountsTimestamp, k -> ConcurrentHashMap.newKeySet());
|
||||
boolean alreadyExists = onlineAccounts.stream().anyMatch(a -> Arrays.equals(a.getPublicKey(), publicKey));
|
||||
if (alreadyExists) {
|
||||
this.hasOurOnlineAccounts = true;
|
||||
|
||||
if (remaining > 0) {
|
||||
// Move on to next account
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
// Everything exists, so return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate bytes for mempow
|
||||
byte[] mempowBytes;
|
||||
try {
|
||||
mempowBytes = this.getMemoryPoWBytes(publicKey, onlineAccountsTimestamp);
|
||||
}
|
||||
catch (IOException e) {
|
||||
LOGGER.info("Unable to create bytes for MemoryPoW. Moving on to next account...");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute nonce
|
||||
Integer nonce;
|
||||
try {
|
||||
nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp);
|
||||
if (nonce == null) {
|
||||
// A nonce is required
|
||||
// Compute nonce
|
||||
Integer nonce;
|
||||
try {
|
||||
nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp);
|
||||
if (nonce == null) {
|
||||
// A nonce is required
|
||||
return false;
|
||||
}
|
||||
} catch (TimeoutException e) {
|
||||
LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey)));
|
||||
return false;
|
||||
}
|
||||
} catch (TimeoutException e) {
|
||||
LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey)));
|
||||
|
||||
byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes);
|
||||
|
||||
// Our account is online
|
||||
OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce);
|
||||
|
||||
// Make sure to verify before adding
|
||||
if (verifyMemoryPoW(ourOnlineAccountData, null)) {
|
||||
ourOnlineAccounts.add(ourOnlineAccountData);
|
||||
}
|
||||
}
|
||||
|
||||
this.hasOurOnlineAccounts = !ourOnlineAccounts.isEmpty();
|
||||
|
||||
boolean hasInfoChanged = addAccounts(ourOnlineAccounts);
|
||||
|
||||
if (!hasInfoChanged)
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes);
|
||||
Network.getInstance().broadcast(peer -> new OnlineAccountsV3Message(ourOnlineAccounts));
|
||||
|
||||
// Our account is online
|
||||
OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce);
|
||||
LOGGER.debug("Broadcasted {} online account{} with timestamp {}", ourOnlineAccounts.size(), (ourOnlineAccounts.size() != 1 ? "s" : ""), onlineAccountsTimestamp);
|
||||
|
||||
// Make sure to verify before adding
|
||||
if (verifyMemoryPoW(ourOnlineAccountData, null)) {
|
||||
ourOnlineAccounts.add(ourOnlineAccountData);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
this.hasOurOnlineAccounts = !ourOnlineAccounts.isEmpty();
|
||||
|
||||
boolean hasInfoChanged = addAccounts(ourOnlineAccounts);
|
||||
|
||||
if (!hasInfoChanged)
|
||||
return false;
|
||||
|
||||
Network.getInstance().broadcast(peer -> new OnlineAccountsV3Message(ourOnlineAccounts));
|
||||
|
||||
LOGGER.debug("Broadcasted {} online account{} with timestamp {}", ourOnlineAccounts.size(), (ourOnlineAccounts.size() != 1 ? "s" : ""), onlineAccountsTimestamp);
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
@@ -346,6 +346,10 @@ public class ArbitraryDataCleanupManager extends Thread {
|
||||
/**
|
||||
* Iteratively walk through given directory and delete a single random file
|
||||
*
|
||||
* TODO: public data should be prioritized over private data
|
||||
* (unless this node is part of a data market contract for that data).
|
||||
* See: Service.privateServices() for a list of services containing private data.
|
||||
*
|
||||
* @param directory - the base directory
|
||||
* @return boolean - whether a file was deleted
|
||||
*/
|
||||
|
@@ -124,29 +124,29 @@ public class ArbitraryDataFileListManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Then allow another 3 attempts, each 5 minutes apart
|
||||
if (timeSinceLastAttempt > 5 * 60 * 1000L) {
|
||||
// We haven't tried for at least 5 minutes
|
||||
// Then allow another 5 attempts, each 1 minute apart
|
||||
if (timeSinceLastAttempt > 60 * 1000L) {
|
||||
// We haven't tried for at least 1 minute
|
||||
|
||||
if (networkBroadcastCount < 6) {
|
||||
// We've made less than 6 total attempts
|
||||
if (networkBroadcastCount < 8) {
|
||||
// We've made less than 8 total attempts
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Then allow another 4 attempts, each 30 minutes apart
|
||||
if (timeSinceLastAttempt > 30 * 60 * 1000L) {
|
||||
// We haven't tried for at least 5 minutes
|
||||
// Then allow another 8 attempts, each 15 minutes apart
|
||||
if (timeSinceLastAttempt > 15 * 60 * 1000L) {
|
||||
// We haven't tried for at least 15 minutes
|
||||
|
||||
if (networkBroadcastCount < 10) {
|
||||
// We've made less than 10 total attempts
|
||||
if (networkBroadcastCount < 16) {
|
||||
// We've made less than 16 total attempts
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// From then on, only try once every 24 hours, to reduce network spam
|
||||
if (timeSinceLastAttempt > 24 * 60 * 60 * 1000L) {
|
||||
// We haven't tried for at least 24 hours
|
||||
// From then on, only try once every 6 hours, to reduce network spam
|
||||
if (timeSinceLastAttempt > 6 * 60 * 60 * 1000L) {
|
||||
// We haven't tried for at least 6 hours
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@@ -16,7 +16,6 @@ import org.qortal.arbitrary.misc.Service;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.list.ResourceListManager;
|
||||
import org.qortal.network.Network;
|
||||
import org.qortal.network.Peer;
|
||||
import org.qortal.repository.DataException;
|
||||
|
@@ -57,6 +57,8 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
* This must be higher than STORAGE_FULL_THRESHOLD in order to avoid a fetch/delete loop. */
|
||||
public static final double DELETION_THRESHOLD = 0.98f; // 98%
|
||||
|
||||
private static final long PER_NAME_STORAGE_MULTIPLIER = 4L;
|
||||
|
||||
public ArbitraryDataStorageManager() {
|
||||
}
|
||||
|
||||
@@ -488,6 +490,11 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Settings.getInstance().getStoragePolicy() == StoragePolicy.ALL) {
|
||||
// Using storage policy ALL, so don't limit anything per name
|
||||
return true;
|
||||
}
|
||||
|
||||
if (name == null) {
|
||||
// This transaction doesn't have a name, so fall back to total space limitations
|
||||
return true;
|
||||
@@ -530,7 +537,9 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
}
|
||||
|
||||
double maxStorageCapacity = (double)this.storageCapacity * threshold;
|
||||
long maxStoragePerName = (long)(maxStorageCapacity / (double)followedNamesCount);
|
||||
|
||||
// Some names won't need/use much space, so give all names a 4x multiplier to compensate
|
||||
long maxStoragePerName = (long)(maxStorageCapacity / (double)followedNamesCount) * PER_NAME_STORAGE_MULTIPLIER;
|
||||
|
||||
return maxStoragePerName;
|
||||
}
|
||||
|
@@ -102,7 +102,14 @@ public class ArbitraryMetadataManager {
|
||||
if (metadataFile.exists()) {
|
||||
// Use local copy
|
||||
ArbitraryDataTransactionMetadata transactionMetadata = new ArbitraryDataTransactionMetadata(metadataFile.getFilePath());
|
||||
transactionMetadata.read();
|
||||
try {
|
||||
transactionMetadata.read();
|
||||
} catch (DataException e) {
|
||||
// Invalid file, so delete it
|
||||
LOGGER.info("Deleting invalid metadata file due to exception: {}", e.getMessage());
|
||||
transactionMetadata.delete();
|
||||
return null;
|
||||
}
|
||||
return transactionMetadata;
|
||||
}
|
||||
}
|
||||
|
@@ -25,7 +25,8 @@ public class NamesDatabaseIntegrityCheck {
|
||||
TransactionType.REGISTER_NAME,
|
||||
TransactionType.UPDATE_NAME,
|
||||
TransactionType.BUY_NAME,
|
||||
TransactionType.SELL_NAME
|
||||
TransactionType.SELL_NAME,
|
||||
TransactionType.CANCEL_SELL_NAME
|
||||
);
|
||||
|
||||
private List<TransactionData> nameTransactions = new ArrayList<>();
|
||||
|
@@ -10,6 +10,7 @@ import org.apache.logging.log4j.Logger;
|
||||
import org.bitcoinj.core.ECKey;
|
||||
import org.qortal.account.PrivateKeyAccount;
|
||||
import org.qortal.api.model.crosschain.TradeBotCreateRequest;
|
||||
import org.qortal.api.resource.TransactionsResource;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.controller.Synchronizer;
|
||||
import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult;
|
||||
@@ -19,6 +20,7 @@ import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.data.crosschain.TradeBotData;
|
||||
import org.qortal.data.network.TradePresenceData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.event.Event;
|
||||
import org.qortal.event.EventBus;
|
||||
import org.qortal.event.Listener;
|
||||
@@ -33,6 +35,7 @@ import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.repository.hsqldb.HSQLDBImportExport;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.utils.ByteArray;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
@@ -113,6 +116,9 @@ public class TradeBot implements Listener {
|
||||
private Map<ByteArray, TradePresenceData> safeAllTradePresencesByPubkey = Collections.emptyMap();
|
||||
private long nextTradePresenceBroadcastTimestamp = 0L;
|
||||
|
||||
private Map<String, Long> failedTrades = new HashMap<>();
|
||||
private Map<String, Long> validTrades = new HashMap<>();
|
||||
|
||||
private TradeBot() {
|
||||
EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event));
|
||||
}
|
||||
@@ -674,6 +680,78 @@ public class TradeBot implements Listener {
|
||||
});
|
||||
}
|
||||
|
||||
/** Removes any trades that have had multiple failures */
|
||||
public List<CrossChainTradeData> removeFailedTrades(Repository repository, List<CrossChainTradeData> crossChainTrades) {
|
||||
Long now = NTP.getTime();
|
||||
if (now == null) {
|
||||
return crossChainTrades;
|
||||
}
|
||||
|
||||
List<CrossChainTradeData> updatedCrossChainTrades = new ArrayList<>(crossChainTrades);
|
||||
int getMaxTradeOfferAttempts = Settings.getInstance().getMaxTradeOfferAttempts();
|
||||
|
||||
for (CrossChainTradeData crossChainTradeData : crossChainTrades) {
|
||||
// We only care about trades in the OFFERING state
|
||||
if (crossChainTradeData.mode != AcctMode.OFFERING) {
|
||||
failedTrades.remove(crossChainTradeData.qortalAtAddress);
|
||||
validTrades.remove(crossChainTradeData.qortalAtAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Return recently cached values if they exist
|
||||
Long failedTimestamp = failedTrades.get(crossChainTradeData.qortalAtAddress);
|
||||
if (failedTimestamp != null && now - failedTimestamp < 60 * 60 * 1000L) {
|
||||
updatedCrossChainTrades.remove(crossChainTradeData);
|
||||
//LOGGER.info("Removing cached failed trade AT {}", crossChainTradeData.qortalAtAddress);
|
||||
continue;
|
||||
}
|
||||
Long validTimestamp = validTrades.get(crossChainTradeData.qortalAtAddress);
|
||||
if (validTimestamp != null && now - validTimestamp < 60 * 60 * 1000L) {
|
||||
//LOGGER.info("NOT removing cached valid trade AT {}", crossChainTradeData.qortalAtAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, crossChainTradeData.qortalCreatorTradeAddress, TransactionsResource.ConfirmationStatus.CONFIRMED, null, null, null);
|
||||
if (signatures.size() < getMaxTradeOfferAttempts) {
|
||||
// Less than 3 (or user-specified number of) MESSAGE transactions relate to this trade, so assume it is ok
|
||||
validTrades.put(crossChainTradeData.qortalAtAddress, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
List<TransactionData> transactions = new ArrayList<>(signatures.size());
|
||||
for (byte[] signature : signatures) {
|
||||
transactions.add(repository.getTransactionRepository().fromSignature(signature));
|
||||
}
|
||||
transactions.sort(Transaction.getDataComparator());
|
||||
|
||||
// Get timestamp of the first MESSAGE transaction
|
||||
long firstMessageTimestamp = transactions.get(0).getTimestamp();
|
||||
|
||||
// Treat as failed if first buy attempt was more than 60 mins ago (as it's still in the OFFERING state)
|
||||
boolean isFailed = (now - firstMessageTimestamp > 60*60*1000L);
|
||||
if (isFailed) {
|
||||
failedTrades.put(crossChainTradeData.qortalAtAddress, now);
|
||||
updatedCrossChainTrades.remove(crossChainTradeData);
|
||||
}
|
||||
else {
|
||||
validTrades.put(crossChainTradeData.qortalAtAddress, now);
|
||||
}
|
||||
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("Unable to determine failed state of AT {}", crossChainTradeData.qortalAtAddress);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedCrossChainTrades;
|
||||
}
|
||||
|
||||
public boolean isFailedTrade(Repository repository, CrossChainTradeData crossChainTradeData) {
|
||||
List<CrossChainTradeData> results = removeFailedTrades(repository, Arrays.asList(crossChainTradeData));
|
||||
return results.isEmpty();
|
||||
}
|
||||
|
||||
private long generateExpiry(long timestamp) {
|
||||
return ((timestamp - 1) / EXPIRY_ROUNDING) * EXPIRY_ROUNDING + PRESENCE_LIFETIME;
|
||||
}
|
||||
|
@@ -28,7 +28,7 @@ public abstract class TrustlessSSLSocketFactory {
|
||||
private static final SSLContext sc;
|
||||
static {
|
||||
try {
|
||||
sc = SSLContext.getInstance("SSL");
|
||||
sc = SSLContext.getInstance("TLSv1.3");
|
||||
sc.init(null, TRUSTLESS_MANAGER, new java.security.SecureRandom());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
|
@@ -46,7 +46,7 @@ public class ArbitraryResourceStatus {
|
||||
this.description = status.description;
|
||||
this.localChunkCount = localChunkCount;
|
||||
this.totalChunkCount = totalChunkCount;
|
||||
this.percentLoaded = (this.localChunkCount != null && this.totalChunkCount != null) ? this.localChunkCount / (float)this.totalChunkCount * 100.0f : null;
|
||||
this.percentLoaded = (this.localChunkCount != null && this.totalChunkCount != null && this.totalChunkCount > 0) ? this.localChunkCount / (float)this.totalChunkCount * 100.0f : null;
|
||||
}
|
||||
|
||||
public ArbitraryResourceStatus(Status status) {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package org.qortal.data.network;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
@@ -34,10 +35,6 @@ public class OnlineAccountData {
|
||||
this.nonce = nonce;
|
||||
}
|
||||
|
||||
public OnlineAccountData(long timestamp, byte[] signature, byte[] publicKey) {
|
||||
this(timestamp, signature, publicKey, null);
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return this.timestamp;
|
||||
}
|
||||
@@ -76,6 +73,10 @@ public class OnlineAccountData {
|
||||
if (otherOnlineAccountData.timestamp != this.timestamp)
|
||||
return false;
|
||||
|
||||
// Almost as quick
|
||||
if (!Objects.equals(otherOnlineAccountData.nonce, this.nonce))
|
||||
return false;
|
||||
|
||||
if (!Arrays.equals(otherOnlineAccountData.publicKey, this.publicKey))
|
||||
return false;
|
||||
|
||||
@@ -88,9 +89,10 @@ public class OnlineAccountData {
|
||||
public int hashCode() {
|
||||
int h = this.hash;
|
||||
if (h == 0) {
|
||||
this.hash = h = Long.hashCode(this.timestamp)
|
||||
^ Arrays.hashCode(this.publicKey);
|
||||
h = Objects.hash(timestamp, nonce);
|
||||
h = 31 * h + Arrays.hashCode(publicKey);
|
||||
// We don't use signature because newer aggregate signatures use random nonces
|
||||
this.hash = h;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorNode;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.voting.PollData;
|
||||
import org.qortal.data.voting.VoteOnPollData;
|
||||
import org.qortal.transaction.Transaction.ApprovalStatus;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
|
||||
@@ -30,7 +31,7 @@ import io.swagger.v3.oas.annotations.media.Schema.AccessMode;
|
||||
@XmlSeeAlso({GenesisTransactionData.class, PaymentTransactionData.class, RegisterNameTransactionData.class, UpdateNameTransactionData.class,
|
||||
SellNameTransactionData.class, CancelSellNameTransactionData.class, BuyNameTransactionData.class,
|
||||
CreatePollTransactionData.class, VoteOnPollTransactionData.class, ArbitraryTransactionData.class,
|
||||
PollData.class,
|
||||
PollData.class, VoteOnPollData.class,
|
||||
IssueAssetTransactionData.class, TransferAssetTransactionData.class,
|
||||
CreateAssetOrderTransactionData.class, CancelAssetOrderTransactionData.class,
|
||||
MultiPaymentTransactionData.class, DeployAtTransactionData.class, MessageTransactionData.class, ATTransactionData.class,
|
||||
@@ -78,6 +79,10 @@ public abstract class TransactionData {
|
||||
@Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "height of block containing transaction")
|
||||
protected Integer blockHeight;
|
||||
|
||||
// Not always present
|
||||
@Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "sequence in block containing transaction")
|
||||
protected Integer blockSequence;
|
||||
|
||||
// Not always present
|
||||
@Schema(accessMode = AccessMode.READ_ONLY, description = "group-approval status")
|
||||
protected ApprovalStatus approvalStatus;
|
||||
@@ -108,6 +113,7 @@ public abstract class TransactionData {
|
||||
this.fee = baseTransactionData.fee;
|
||||
this.signature = baseTransactionData.signature;
|
||||
this.blockHeight = baseTransactionData.blockHeight;
|
||||
this.blockSequence = baseTransactionData.blockSequence;
|
||||
this.approvalStatus = baseTransactionData.approvalStatus;
|
||||
this.approvalHeight = baseTransactionData.approvalHeight;
|
||||
}
|
||||
@@ -176,6 +182,15 @@ public abstract class TransactionData {
|
||||
this.blockHeight = blockHeight;
|
||||
}
|
||||
|
||||
public Integer getBlockSequence() {
|
||||
return this.blockSequence;
|
||||
}
|
||||
|
||||
@XmlTransient
|
||||
public void setBlockSequence(Integer blockSequence) {
|
||||
this.blockSequence = blockSequence;
|
||||
}
|
||||
|
||||
public ApprovalStatus getApprovalStatus() {
|
||||
return approvalStatus;
|
||||
}
|
||||
|
@@ -9,6 +9,11 @@ public class VoteOnPollData {
|
||||
|
||||
// Constructors
|
||||
|
||||
// For JAXB
|
||||
protected VoteOnPollData() {
|
||||
super();
|
||||
}
|
||||
|
||||
public VoteOnPollData(String pollName, byte[] voterPublicKey, int optionIndex) {
|
||||
this.pollName = pollName;
|
||||
this.voterPublicKey = voterPublicKey;
|
||||
@@ -21,12 +26,24 @@ public class VoteOnPollData {
|
||||
return this.pollName;
|
||||
}
|
||||
|
||||
public void setPollName(String pollName) {
|
||||
this.pollName = pollName;
|
||||
}
|
||||
|
||||
public byte[] getVoterPublicKey() {
|
||||
return this.voterPublicKey;
|
||||
}
|
||||
|
||||
public void setVoterPublicKey(byte[] voterPublicKey) {
|
||||
this.voterPublicKey = voterPublicKey;
|
||||
}
|
||||
|
||||
public int getOptionIndex() {
|
||||
return this.optionIndex;
|
||||
}
|
||||
|
||||
public void setOptionIndex(int optionIndex) {
|
||||
this.optionIndex = optionIndex;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@ import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -81,7 +82,7 @@ public class ResourceList {
|
||||
}
|
||||
|
||||
try {
|
||||
String jsonString = new String(Files.readAllBytes(path));
|
||||
String jsonString = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
||||
this.list = ResourceList.listFromJSONString(jsonString);
|
||||
} catch (IOException e) {
|
||||
throw new IOException(String.format("Couldn't read contents from file %s", path.toString()));
|
||||
|
@@ -265,7 +265,7 @@ public enum Handshake {
|
||||
private static final long PEER_VERSION_131 = 0x0100030001L;
|
||||
|
||||
/** Minimum peer version that we are allowed to communicate with */
|
||||
private static final String MIN_PEER_VERSION = "3.8.2";
|
||||
private static final String MIN_PEER_VERSION = "4.1.1";
|
||||
|
||||
private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes
|
||||
private static final int POW_DIFFICULTY_PRE_131 = 8; // leading zero bits
|
||||
|
@@ -187,7 +187,7 @@ public class Network {
|
||||
|
||||
this.bindAddress = bindAddress; // Store the selected address, so that it can be used by other parts of the app
|
||||
break; // We don't want to bind to more than one address
|
||||
} catch (UnknownHostException e) {
|
||||
} catch (UnknownHostException | UnsupportedAddressTypeException e) {
|
||||
LOGGER.error("Can't bind listen socket to address {}", Settings.getInstance().getBindAddress());
|
||||
if (i == bindAddresses.size()-1) { // Only throw an exception if all addresses have been tried
|
||||
throw new IOException("Can't bind listen socket to address", e);
|
||||
|
@@ -68,7 +68,7 @@ public class ArbitraryDataFileMessage extends Message {
|
||||
byteBuffer.get(data);
|
||||
|
||||
try {
|
||||
ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(data, signature);
|
||||
ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(data, signature, false);
|
||||
return new ArbitraryDataFileMessage(id, signature, arbitraryDataFile);
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("Unable to process received file: {}", e.getMessage());
|
||||
|
@@ -64,7 +64,7 @@ public class ArbitraryMetadataMessage extends Message {
|
||||
byteBuffer.get(data);
|
||||
|
||||
try {
|
||||
ArbitraryDataFile arbitraryMetadataFile = new ArbitraryDataFile(data, signature);
|
||||
ArbitraryDataFile arbitraryMetadataFile = new ArbitraryDataFile(data, signature, false);
|
||||
return new ArbitraryMetadataMessage(id, signature, arbitraryMetadataFile);
|
||||
} catch (DataException e) {
|
||||
throw new MessageException("Unable to process arbitrary metadata message: " + e.getMessage(), e);
|
||||
|
@@ -1,69 +0,0 @@
|
||||
package org.qortal.network.message;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.qortal.data.network.OnlineAccountData;
|
||||
import org.qortal.transform.Transformer;
|
||||
|
||||
import com.google.common.primitives.Ints;
|
||||
import com.google.common.primitives.Longs;
|
||||
|
||||
public class GetOnlineAccountsMessage extends Message {
|
||||
private static final int MAX_ACCOUNT_COUNT = 5000;
|
||||
|
||||
private List<OnlineAccountData> onlineAccounts;
|
||||
|
||||
public GetOnlineAccountsMessage(List<OnlineAccountData> onlineAccounts) {
|
||||
super(MessageType.GET_ONLINE_ACCOUNTS);
|
||||
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
|
||||
try {
|
||||
bytes.write(Ints.toByteArray(onlineAccounts.size()));
|
||||
|
||||
for (OnlineAccountData onlineAccountData : onlineAccounts) {
|
||||
bytes.write(Longs.toByteArray(onlineAccountData.getTimestamp()));
|
||||
|
||||
bytes.write(onlineAccountData.getPublicKey());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
|
||||
}
|
||||
|
||||
this.dataBytes = bytes.toByteArray();
|
||||
this.checksumBytes = Message.generateChecksum(this.dataBytes);
|
||||
}
|
||||
|
||||
private GetOnlineAccountsMessage(int id, List<OnlineAccountData> onlineAccounts) {
|
||||
super(id, MessageType.GET_ONLINE_ACCOUNTS);
|
||||
|
||||
this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<OnlineAccountData> getOnlineAccounts() {
|
||||
return this.onlineAccounts;
|
||||
}
|
||||
|
||||
public static Message fromByteBuffer(int id, ByteBuffer bytes) {
|
||||
final int accountCount = bytes.getInt();
|
||||
|
||||
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
|
||||
|
||||
for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) {
|
||||
long timestamp = bytes.getLong();
|
||||
|
||||
byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH];
|
||||
bytes.get(publicKey);
|
||||
|
||||
onlineAccounts.add(new OnlineAccountData(timestamp, null, publicKey));
|
||||
}
|
||||
|
||||
return new GetOnlineAccountsMessage(id, onlineAccounts);
|
||||
}
|
||||
|
||||
}
|
@@ -1,109 +0,0 @@
|
||||
package org.qortal.network.message;
|
||||
|
||||
import com.google.common.primitives.Ints;
|
||||
import com.google.common.primitives.Longs;
|
||||
import org.qortal.data.network.OnlineAccountData;
|
||||
import org.qortal.transform.Transformer;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* For requesting online accounts info from remote peer, given our list of online accounts.
|
||||
*
|
||||
* Different format to V1:
|
||||
* V1 is: number of entries, then timestamp + pubkey for each entry
|
||||
* V2 is: groups of: number of entries, timestamp, then pubkey for each entry
|
||||
*
|
||||
* Also V2 only builds online accounts message once!
|
||||
*/
|
||||
public class GetOnlineAccountsV2Message extends Message {
|
||||
|
||||
private List<OnlineAccountData> onlineAccounts;
|
||||
|
||||
public GetOnlineAccountsV2Message(List<OnlineAccountData> onlineAccounts) {
|
||||
super(MessageType.GET_ONLINE_ACCOUNTS_V2);
|
||||
|
||||
// If we don't have ANY online accounts then it's an easier construction...
|
||||
if (onlineAccounts.isEmpty()) {
|
||||
// Always supply a number of accounts
|
||||
this.dataBytes = Ints.toByteArray(0);
|
||||
this.checksumBytes = Message.generateChecksum(this.dataBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
// How many of each timestamp
|
||||
Map<Long, Integer> countByTimestamp = new HashMap<>();
|
||||
|
||||
for (OnlineAccountData onlineAccountData : onlineAccounts) {
|
||||
Long timestamp = onlineAccountData.getTimestamp();
|
||||
countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v);
|
||||
}
|
||||
|
||||
// We should know exactly how many bytes to allocate now
|
||||
int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH)
|
||||
+ onlineAccounts.size() * Transformer.PUBLIC_KEY_LENGTH;
|
||||
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize);
|
||||
|
||||
try {
|
||||
for (long timestamp : countByTimestamp.keySet()) {
|
||||
bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp)));
|
||||
|
||||
bytes.write(Longs.toByteArray(timestamp));
|
||||
|
||||
for (OnlineAccountData onlineAccountData : onlineAccounts) {
|
||||
if (onlineAccountData.getTimestamp() == timestamp)
|
||||
bytes.write(onlineAccountData.getPublicKey());
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
|
||||
}
|
||||
|
||||
this.dataBytes = bytes.toByteArray();
|
||||
this.checksumBytes = Message.generateChecksum(this.dataBytes);
|
||||
}
|
||||
|
||||
private GetOnlineAccountsV2Message(int id, List<OnlineAccountData> onlineAccounts) {
|
||||
super(id, MessageType.GET_ONLINE_ACCOUNTS_V2);
|
||||
|
||||
this.onlineAccounts = onlineAccounts;
|
||||
}
|
||||
|
||||
public List<OnlineAccountData> getOnlineAccounts() {
|
||||
return this.onlineAccounts;
|
||||
}
|
||||
|
||||
public static Message fromByteBuffer(int id, ByteBuffer bytes) {
|
||||
int accountCount = bytes.getInt();
|
||||
|
||||
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
|
||||
|
||||
while (accountCount > 0) {
|
||||
long timestamp = bytes.getLong();
|
||||
|
||||
for (int i = 0; i < accountCount; ++i) {
|
||||
byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH];
|
||||
bytes.get(publicKey);
|
||||
|
||||
onlineAccounts.add(new OnlineAccountData(timestamp, null, publicKey));
|
||||
}
|
||||
|
||||
if (bytes.hasRemaining()) {
|
||||
accountCount = bytes.getInt();
|
||||
} else {
|
||||
// we've finished
|
||||
accountCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return new GetOnlineAccountsV2Message(id, onlineAccounts);
|
||||
}
|
||||
|
||||
}
|
@@ -43,11 +43,7 @@ public enum MessageType {
|
||||
BLOCK_SUMMARIES(70, BlockSummariesMessage::fromByteBuffer),
|
||||
GET_BLOCK_SUMMARIES(71, GetBlockSummariesMessage::fromByteBuffer),
|
||||
BLOCK_SUMMARIES_V2(72, BlockSummariesV2Message::fromByteBuffer),
|
||||
|
||||
ONLINE_ACCOUNTS(80, OnlineAccountsMessage::fromByteBuffer),
|
||||
GET_ONLINE_ACCOUNTS(81, GetOnlineAccountsMessage::fromByteBuffer),
|
||||
ONLINE_ACCOUNTS_V2(82, OnlineAccountsV2Message::fromByteBuffer),
|
||||
GET_ONLINE_ACCOUNTS_V2(83, GetOnlineAccountsV2Message::fromByteBuffer),
|
||||
|
||||
ONLINE_ACCOUNTS_V3(84, OnlineAccountsV3Message::fromByteBuffer),
|
||||
GET_ONLINE_ACCOUNTS_V3(85, GetOnlineAccountsV3Message::fromByteBuffer),
|
||||
|
||||
|
@@ -1,75 +0,0 @@
|
||||
package org.qortal.network.message;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.qortal.data.network.OnlineAccountData;
|
||||
import org.qortal.transform.Transformer;
|
||||
|
||||
import com.google.common.primitives.Ints;
|
||||
import com.google.common.primitives.Longs;
|
||||
|
||||
public class OnlineAccountsMessage extends Message {
|
||||
private static final int MAX_ACCOUNT_COUNT = 5000;
|
||||
|
||||
private List<OnlineAccountData> onlineAccounts;
|
||||
|
||||
public OnlineAccountsMessage(List<OnlineAccountData> onlineAccounts) {
|
||||
super(MessageType.ONLINE_ACCOUNTS);
|
||||
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
|
||||
try {
|
||||
bytes.write(Ints.toByteArray(onlineAccounts.size()));
|
||||
|
||||
for (OnlineAccountData onlineAccountData : onlineAccounts) {
|
||||
bytes.write(Longs.toByteArray(onlineAccountData.getTimestamp()));
|
||||
|
||||
bytes.write(onlineAccountData.getSignature());
|
||||
|
||||
bytes.write(onlineAccountData.getPublicKey());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
|
||||
}
|
||||
|
||||
this.dataBytes = bytes.toByteArray();
|
||||
this.checksumBytes = Message.generateChecksum(this.dataBytes);
|
||||
}
|
||||
|
||||
private OnlineAccountsMessage(int id, List<OnlineAccountData> onlineAccounts) {
|
||||
super(id, MessageType.ONLINE_ACCOUNTS);
|
||||
|
||||
this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<OnlineAccountData> getOnlineAccounts() {
|
||||
return this.onlineAccounts;
|
||||
}
|
||||
|
||||
public static Message fromByteBuffer(int id, ByteBuffer bytes) {
|
||||
final int accountCount = bytes.getInt();
|
||||
|
||||
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
|
||||
|
||||
for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) {
|
||||
long timestamp = bytes.getLong();
|
||||
|
||||
byte[] signature = new byte[Transformer.SIGNATURE_LENGTH];
|
||||
bytes.get(signature);
|
||||
|
||||
byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH];
|
||||
bytes.get(publicKey);
|
||||
|
||||
OnlineAccountData onlineAccountData = new OnlineAccountData(timestamp, signature, publicKey);
|
||||
onlineAccounts.add(onlineAccountData);
|
||||
}
|
||||
|
||||
return new OnlineAccountsMessage(id, onlineAccounts);
|
||||
}
|
||||
|
||||
}
|
@@ -1,113 +0,0 @@
|
||||
package org.qortal.network.message;
|
||||
|
||||
import com.google.common.primitives.Ints;
|
||||
import com.google.common.primitives.Longs;
|
||||
import org.qortal.data.network.OnlineAccountData;
|
||||
import org.qortal.transform.Transformer;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* For sending online accounts info to remote peer.
|
||||
*
|
||||
* Different format to V1:
|
||||
* V1 is: number of entries, then timestamp + sig + pubkey for each entry
|
||||
* V2 is: groups of: number of entries, timestamp, then sig + pubkey for each entry
|
||||
*
|
||||
* Also V2 only builds online accounts message once!
|
||||
*/
|
||||
public class OnlineAccountsV2Message extends Message {
|
||||
|
||||
private List<OnlineAccountData> onlineAccounts;
|
||||
|
||||
public OnlineAccountsV2Message(List<OnlineAccountData> onlineAccounts) {
|
||||
super(MessageType.ONLINE_ACCOUNTS_V2);
|
||||
|
||||
// Shortcut in case we have no online accounts
|
||||
if (onlineAccounts.isEmpty()) {
|
||||
this.dataBytes = Ints.toByteArray(0);
|
||||
this.checksumBytes = Message.generateChecksum(this.dataBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
// How many of each timestamp
|
||||
Map<Long, Integer> countByTimestamp = new HashMap<>();
|
||||
|
||||
for (OnlineAccountData onlineAccountData : onlineAccounts) {
|
||||
Long timestamp = onlineAccountData.getTimestamp();
|
||||
countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v);
|
||||
}
|
||||
|
||||
// We should know exactly how many bytes to allocate now
|
||||
int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH)
|
||||
+ onlineAccounts.size() * (Transformer.SIGNATURE_LENGTH + Transformer.PUBLIC_KEY_LENGTH);
|
||||
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize);
|
||||
|
||||
try {
|
||||
for (long timestamp : countByTimestamp.keySet()) {
|
||||
bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp)));
|
||||
|
||||
bytes.write(Longs.toByteArray(timestamp));
|
||||
|
||||
for (OnlineAccountData onlineAccountData : onlineAccounts) {
|
||||
if (onlineAccountData.getTimestamp() == timestamp) {
|
||||
bytes.write(onlineAccountData.getSignature());
|
||||
bytes.write(onlineAccountData.getPublicKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
|
||||
}
|
||||
|
||||
this.dataBytes = bytes.toByteArray();
|
||||
this.checksumBytes = Message.generateChecksum(this.dataBytes);
|
||||
}
|
||||
|
||||
private OnlineAccountsV2Message(int id, List<OnlineAccountData> onlineAccounts) {
|
||||
super(id, MessageType.ONLINE_ACCOUNTS_V2);
|
||||
|
||||
this.onlineAccounts = onlineAccounts;
|
||||
}
|
||||
|
||||
public List<OnlineAccountData> getOnlineAccounts() {
|
||||
return this.onlineAccounts;
|
||||
}
|
||||
|
||||
public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException {
|
||||
int accountCount = bytes.getInt();
|
||||
|
||||
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
|
||||
|
||||
while (accountCount > 0) {
|
||||
long timestamp = bytes.getLong();
|
||||
|
||||
for (int i = 0; i < accountCount; ++i) {
|
||||
byte[] signature = new byte[Transformer.SIGNATURE_LENGTH];
|
||||
bytes.get(signature);
|
||||
|
||||
byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH];
|
||||
bytes.get(publicKey);
|
||||
|
||||
onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey));
|
||||
}
|
||||
|
||||
if (bytes.hasRemaining()) {
|
||||
accountCount = bytes.getInt();
|
||||
} else {
|
||||
// we've finished
|
||||
accountCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return new OnlineAccountsV2Message(id, onlineAccounts);
|
||||
}
|
||||
|
||||
}
|
@@ -99,9 +99,10 @@ public class OnlineAccountsV3Message extends Message {
|
||||
bytes.get(publicKey);
|
||||
|
||||
// Nonce is optional - will be -1 if missing
|
||||
// ... but we should skip/ignore an online account if it has no nonce
|
||||
Integer nonce = bytes.getInt();
|
||||
if (nonce < 0) {
|
||||
nonce = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey, nonce));
|
||||
|
@@ -14,10 +14,12 @@ public interface NameRepository {
|
||||
|
||||
public boolean reducedNameExists(String reducedName) throws DataException;
|
||||
|
||||
public List<NameData> getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException;
|
||||
public List<NameData> searchNames(String query, boolean prefixOnly, Integer limit, Integer offset, Boolean reverse) throws DataException;
|
||||
|
||||
public List<NameData> getAllNames(Long after, Integer limit, Integer offset, Boolean reverse) throws DataException;
|
||||
|
||||
public default List<NameData> getAllNames() throws DataException {
|
||||
return getAllNames(null, null, null);
|
||||
return getAllNames(null, null, null, null);
|
||||
}
|
||||
|
||||
public List<NameData> getNamesForSale(Integer limit, Integer offset, Boolean reverse) throws DataException;
|
||||
|
213
src/main/java/org/qortal/repository/ReindexManager.java
Normal file
213
src/main/java/org/qortal/repository/ReindexManager.java
Normal file
@@ -0,0 +1,213 @@
|
||||
package org.qortal.repository;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.block.GenesisBlock;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockArchiveData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.transform.block.BlockTransformation;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public class ReindexManager {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(ReindexManager.class);
|
||||
|
||||
private Repository repository;
|
||||
|
||||
private final int pruneAndTrimBlockInterval = 2000;
|
||||
private final int maintenanceBlockInterval = 50000;
|
||||
|
||||
private boolean resume = false;
|
||||
|
||||
public ReindexManager() {
|
||||
|
||||
}
|
||||
|
||||
public void reindex() throws DataException {
|
||||
try {
|
||||
this.runPreChecks();
|
||||
this.rebuildRepository();
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
this.repository = repository;
|
||||
this.requestCheckpoint();
|
||||
this.processGenesisBlock();
|
||||
this.processBlocks();
|
||||
}
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
throw new DataException("Interrupted before complete");
|
||||
}
|
||||
}
|
||||
|
||||
private void runPreChecks() throws DataException, InterruptedException {
|
||||
LOGGER.info("Running pre-checks...");
|
||||
if (Settings.getInstance().isTopOnly()) {
|
||||
throw new DataException("Reindexing not supported in top-only mode. Please bootstrap or resync from genesis.");
|
||||
}
|
||||
if (Settings.getInstance().isLite()) {
|
||||
throw new DataException("Reindexing not supported in lite mode.");
|
||||
}
|
||||
|
||||
while (NTP.getTime() == null) {
|
||||
LOGGER.info("Waiting for NTP...");
|
||||
Thread.sleep(5000L);
|
||||
}
|
||||
}
|
||||
|
||||
private void rebuildRepository() throws DataException {
|
||||
if (resume) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.info("Rebuilding repository...");
|
||||
RepositoryManager.rebuild();
|
||||
}
|
||||
|
||||
private void requestCheckpoint() {
|
||||
RepositoryManager.setRequestedCheckpoint(Boolean.TRUE);
|
||||
}
|
||||
|
||||
private void processGenesisBlock() throws DataException, InterruptedException {
|
||||
if (resume) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.info("Processing genesis block...");
|
||||
|
||||
GenesisBlock genesisBlock = GenesisBlock.getInstance(repository);
|
||||
|
||||
// Add Genesis Block to blockchain
|
||||
genesisBlock.process();
|
||||
|
||||
this.repository.saveChanges();
|
||||
}
|
||||
|
||||
private void processBlocks() throws DataException {
|
||||
LOGGER.info("Processing blocks...");
|
||||
|
||||
int height = this.repository.getBlockRepository().getBlockchainHeight();
|
||||
while (true) {
|
||||
height++;
|
||||
|
||||
boolean processed = this.processBlock(height);
|
||||
if (!processed) {
|
||||
LOGGER.info("Block {} couldn't be processed. If this is the last archived block, then the process is complete.", height);
|
||||
break; // TODO: check if complete
|
||||
}
|
||||
|
||||
// Prune and trim regularly, leaving a buffer
|
||||
if (height >= pruneAndTrimBlockInterval*2 && height % pruneAndTrimBlockInterval == 0) {
|
||||
int startHeight = Math.max(height - pruneAndTrimBlockInterval*2, 2);
|
||||
int endHeight = height - pruneAndTrimBlockInterval;
|
||||
LOGGER.info("Pruning and trimming blocks {} to {}...", startHeight, endHeight);
|
||||
this.repository.getATRepository().rebuildLatestAtStates(height - 250);
|
||||
this.repository.saveChanges();
|
||||
this.prune(startHeight, endHeight);
|
||||
this.trim(startHeight, endHeight);
|
||||
}
|
||||
|
||||
// Run repository maintenance regularly, to keep blockchain.data size down
|
||||
if (height % maintenanceBlockInterval == 0) {
|
||||
this.runRepositoryMaintenance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean processBlock(int height) throws DataException {
|
||||
Block block = this.fetchBlock(height);
|
||||
if (block == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Transactions are stored without approval status so determine that now
|
||||
for (Transaction transaction : block.getTransactions())
|
||||
transaction.setInitialApprovalStatus();
|
||||
|
||||
// It's best not to run preProcess() until there is a reason to
|
||||
// block.preProcess();
|
||||
|
||||
Block.ValidationResult validationResult = block.isValid();
|
||||
if (validationResult != Block.ValidationResult.OK) {
|
||||
throw new DataException(String.format("Invalid block at height %d: %s", height, validationResult));
|
||||
}
|
||||
|
||||
// Save transactions attached to this block
|
||||
for (Transaction transaction : block.getTransactions()) {
|
||||
TransactionData transactionData = transaction.getTransactionData();
|
||||
this.repository.getTransactionRepository().save(transactionData);
|
||||
}
|
||||
|
||||
block.process();
|
||||
|
||||
LOGGER.info(String.format("Reindexed block height %d, sig %.8s", block.getBlockData().getHeight(), Base58.encode(block.getBlockData().getSignature())));
|
||||
|
||||
// Add to block archive table, since this originated from the archive but the chainstate has to be rebuilt
|
||||
this.addToBlockArchive(block.getBlockData());
|
||||
|
||||
this.repository.saveChanges();
|
||||
|
||||
Controller.getInstance().onNewBlock(block.getBlockData());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private Block fetchBlock(int height) {
|
||||
BlockTransformation b = BlockArchiveReader.getInstance().fetchBlockAtHeight(height);
|
||||
if (b != null) {
|
||||
if (b.getAtStatesHash() != null) {
|
||||
return new Block(this.repository, b.getBlockData(), b.getTransactions(), b.getAtStatesHash());
|
||||
}
|
||||
else {
|
||||
return new Block(this.repository, b.getBlockData(), b.getTransactions(), b.getAtStates());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void addToBlockArchive(BlockData blockData) throws DataException {
|
||||
// Write the signature and height into the BlockArchive table
|
||||
BlockArchiveData blockArchiveData = new BlockArchiveData(blockData);
|
||||
this.repository.getBlockArchiveRepository().save(blockArchiveData);
|
||||
this.repository.getBlockArchiveRepository().setBlockArchiveHeight(blockData.getHeight()+1);
|
||||
this.repository.saveChanges();
|
||||
}
|
||||
|
||||
private void prune(int startHeight, int endHeight) throws DataException {
|
||||
this.repository.getBlockRepository().pruneBlocks(startHeight, endHeight);
|
||||
this.repository.getATRepository().pruneAtStates(startHeight, endHeight);
|
||||
this.repository.getATRepository().setAtPruneHeight(endHeight+1);
|
||||
this.repository.saveChanges();
|
||||
}
|
||||
|
||||
private void trim(int startHeight, int endHeight) throws DataException {
|
||||
this.repository.getBlockRepository().trimOldOnlineAccountsSignatures(startHeight, endHeight);
|
||||
|
||||
int count = 1; // Any number greater than 0
|
||||
while (count > 0) {
|
||||
count = this.repository.getATRepository().trimAtStates(startHeight, endHeight, Settings.getInstance().getAtStatesTrimLimit());
|
||||
}
|
||||
|
||||
this.repository.getBlockRepository().setBlockPruneHeight(endHeight+1);
|
||||
this.repository.getATRepository().setAtTrimHeight(endHeight+1);
|
||||
this.repository.saveChanges();
|
||||
}
|
||||
|
||||
private void runRepositoryMaintenance() throws DataException {
|
||||
try {
|
||||
this.repository.performPeriodicMaintenance(1000L);
|
||||
} catch (TimeoutException e) {
|
||||
LOGGER.info("Timed out waiting for repository before running maintenance");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -2,9 +2,23 @@ package org.qortal.repository;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.transaction.ATTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.gui.SplashFrame;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.transform.block.BlockTransformation;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.qortal.transaction.Transaction.TransactionType.AT;
|
||||
|
||||
public abstract class RepositoryManager {
|
||||
private static final Logger LOGGER = LogManager.getLogger(RepositoryManager.class);
|
||||
@@ -56,6 +70,164 @@ public abstract class RepositoryManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean needsTransactionSequenceRebuild(Repository repository) throws DataException {
|
||||
// Check if we have any transactions without a block_sequence
|
||||
List<byte[]> testSignatures = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria(
|
||||
null, Arrays.asList("block_height IS NOT NULL AND block_sequence IS NULL"), new ArrayList<>(), 100);
|
||||
if (testSignatures.isEmpty()) {
|
||||
// block_sequence intact, so assume complete
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean rebuildTransactionSequences(Repository repository) throws DataException {
|
||||
if (Settings.getInstance().isLite()) {
|
||||
// Lite nodes have no blockchain
|
||||
return false;
|
||||
}
|
||||
if (Settings.getInstance().isTopOnly()) {
|
||||
// topOnly nodes are unable to perform this reindex, and so are temporarily unsupported
|
||||
throw new DataException("topOnly nodes are now unsupported, as they are missing data required for a db reshape");
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if we have any unpopulated block_sequence values for the first 1000 blocks
|
||||
if (!needsTransactionSequenceRebuild(repository)) {
|
||||
// block_sequence already populated for the first 1000 blocks, so assume complete.
|
||||
// We avoid checkpointing and prevent the node from starting up in the case of a rebuild failure, so
|
||||
// we shouldn't ever be left in a partially rebuilt state.
|
||||
return false;
|
||||
}
|
||||
|
||||
LOGGER.info("Rebuilding transaction sequences - this will take a while...");
|
||||
|
||||
SplashFrame.getInstance().updateStatus("Rebuilding transactions - please wait...");
|
||||
|
||||
int blockchainHeight = repository.getBlockRepository().getBlockchainHeight();
|
||||
int totalTransactionCount = 0;
|
||||
|
||||
for (int height = 1; height <= blockchainHeight; ++height) {
|
||||
List<TransactionData> inputTransactions = new ArrayList<>();
|
||||
|
||||
// Fetch block and transactions
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
boolean loadedFromArchive = false;
|
||||
if (blockData == null) {
|
||||
// Get (non-AT) transactions from the archive
|
||||
BlockTransformation blockTransformation = BlockArchiveReader.getInstance().fetchBlockAtHeight(height);
|
||||
blockData = blockTransformation.getBlockData();
|
||||
inputTransactions = blockTransformation.getTransactions(); // This doesn't include AT transactions
|
||||
loadedFromArchive = true;
|
||||
}
|
||||
else {
|
||||
// Get transactions from db
|
||||
Block block = new Block(repository, blockData);
|
||||
for (Transaction transaction : block.getTransactions()) {
|
||||
inputTransactions.add(transaction.getTransactionData());
|
||||
}
|
||||
}
|
||||
|
||||
if (blockData == null) {
|
||||
throw new DataException("Missing block data");
|
||||
}
|
||||
|
||||
List<TransactionData> transactions = new ArrayList<>();
|
||||
|
||||
if (loadedFromArchive) {
|
||||
List<TransactionData> transactionDataList = new ArrayList<>(blockData.getTransactionCount());
|
||||
// Fetch any AT transactions in this block
|
||||
List<byte[]> atSignatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height);
|
||||
for (byte[] s : atSignatures) {
|
||||
TransactionData transactionData = repository.getTransactionRepository().fromSignature(s);
|
||||
if (transactionData.getType() == AT) {
|
||||
transactionDataList.add(transactionData);
|
||||
}
|
||||
}
|
||||
|
||||
List<ATTransactionData> atTransactions = new ArrayList<>();
|
||||
for (TransactionData transactionData : transactionDataList) {
|
||||
ATTransactionData atTransactionData = (ATTransactionData) transactionData;
|
||||
atTransactions.add(atTransactionData);
|
||||
}
|
||||
|
||||
// Create sorted list of ATs by creation time
|
||||
List<ATData> ats = new ArrayList<>();
|
||||
|
||||
for (ATTransactionData atTransactionData : atTransactions) {
|
||||
ATData atData = repository.getATRepository().fromATAddress(atTransactionData.getATAddress());
|
||||
boolean hasExistingEntry = ats.stream().anyMatch(a -> Objects.equals(a.getATAddress(), atTransactionData.getATAddress()));
|
||||
if (!hasExistingEntry) {
|
||||
ats.add(atData);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort list of ATs by creation date
|
||||
ats.sort(Comparator.comparingLong(ATData::getCreation));
|
||||
|
||||
// Loop through unique ATs
|
||||
for (ATData atData : ats) {
|
||||
List<ATTransactionData> thisAtTransactions = atTransactions.stream()
|
||||
.filter(t -> Objects.equals(t.getATAddress(), atData.getATAddress()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int count = thisAtTransactions.size();
|
||||
|
||||
if (count == 1) {
|
||||
ATTransactionData atTransactionData = thisAtTransactions.get(0);
|
||||
transactions.add(atTransactionData);
|
||||
}
|
||||
else if (count == 2) {
|
||||
String atCreatorAddress = Crypto.toAddress(atData.getCreatorPublicKey());
|
||||
|
||||
ATTransactionData atTransactionData1 = thisAtTransactions.stream()
|
||||
.filter(t -> !Objects.equals(t.getRecipient(), atCreatorAddress))
|
||||
.findFirst().orElse(null);
|
||||
transactions.add(atTransactionData1);
|
||||
|
||||
ATTransactionData atTransactionData2 = thisAtTransactions.stream()
|
||||
.filter(t -> Objects.equals(t.getRecipient(), atCreatorAddress))
|
||||
.findFirst().orElse(null);
|
||||
transactions.add(atTransactionData2);
|
||||
}
|
||||
else if (count > 2) {
|
||||
LOGGER.info("Error: AT has more than 2 output transactions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add all the regular transactions now that AT transactions have been handled
|
||||
transactions.addAll(inputTransactions);
|
||||
totalTransactionCount += transactions.size();
|
||||
|
||||
// Loop through and update sequences
|
||||
for (int sequence = 0; sequence < transactions.size(); ++sequence) {
|
||||
TransactionData transactionData = transactions.get(sequence);
|
||||
|
||||
// Update transaction's sequence in repository
|
||||
repository.getTransactionRepository().updateBlockSequence(transactionData.getSignature(), sequence);
|
||||
}
|
||||
|
||||
if (height % 10000 == 0) {
|
||||
LOGGER.info("Rebuilt sequences for {} blocks (total transactions: {})", height, totalTransactionCount);
|
||||
}
|
||||
|
||||
repository.saveChanges();
|
||||
}
|
||||
|
||||
LOGGER.info("Completed rebuild of transaction sequences.");
|
||||
return true;
|
||||
}
|
||||
catch (DataException e) {
|
||||
LOGGER.info("Unable to rebuild transaction sequences: {}. The database may have been left in an inconsistent state.", e.getMessage());
|
||||
|
||||
// Throw an exception so that the node startup is halted, allowing for a retry next time.
|
||||
repository.discardChanges();
|
||||
throw new DataException("Rebuild of transaction sequences failed.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void setRequestedCheckpoint(Boolean quick) {
|
||||
quickCheckpointRequested = quick;
|
||||
}
|
||||
|
@@ -125,6 +125,23 @@ public interface TransactionRepository {
|
||||
public List<byte[]> getSignaturesMatchingCustomCriteria(TransactionType txType, List<String> whereClauses,
|
||||
List<Object> bindParams) throws DataException;
|
||||
|
||||
/**
|
||||
* Returns signatures for transactions that match search criteria, with optional limit.
|
||||
* <p>
|
||||
* Alternate version that allows for custom where clauses and bind params.
|
||||
* Only use for very specific use cases, such as the names integrity check.
|
||||
* Not advised to be used otherwise, given that it could be possible for
|
||||
* unsanitized inputs to be passed in if not careful.
|
||||
*
|
||||
* @param txType
|
||||
* @param whereClauses
|
||||
* @param bindParams
|
||||
* @return
|
||||
* @throws DataException
|
||||
*/
|
||||
public List<byte[]> getSignaturesMatchingCustomCriteria(TransactionType txType, List<String> whereClauses,
|
||||
List<Object> bindParams, Integer limit) throws DataException;
|
||||
|
||||
/**
|
||||
* Returns signature for latest auto-update transaction.
|
||||
* <p>
|
||||
@@ -297,7 +314,7 @@ public interface TransactionRepository {
|
||||
* @return list of transactions, or empty if none.
|
||||
* @throws DataException
|
||||
*/
|
||||
public List<TransactionData> getUnconfirmedTransactions(EnumSet<TransactionType> excludedTxTypes) throws DataException;
|
||||
public List<TransactionData> getUnconfirmedTransactions(EnumSet<TransactionType> excludedTxTypes, Integer limit) throws DataException;
|
||||
|
||||
/**
|
||||
* Remove transaction from unconfirmed transactions pile.
|
||||
@@ -309,6 +326,8 @@ public interface TransactionRepository {
|
||||
|
||||
public void updateBlockHeight(byte[] signature, Integer height) throws DataException;
|
||||
|
||||
public void updateBlockSequence(byte[] signature, Integer sequence) throws DataException;
|
||||
|
||||
public void updateApprovalHeight(byte[] signature, Integer approvalHeight) throws DataException;
|
||||
|
||||
/**
|
||||
|
@@ -296,10 +296,9 @@ public class HSQLDBATRepository implements ATRepository {
|
||||
|
||||
@Override
|
||||
public Integer getATCreationBlockHeight(String atAddress) throws DataException {
|
||||
String sql = "SELECT height "
|
||||
String sql = "SELECT block_height "
|
||||
+ "FROM DeployATTransactions "
|
||||
+ "JOIN BlockTransactions ON transaction_signature = signature "
|
||||
+ "JOIN Blocks ON Blocks.signature = block_signature "
|
||||
+ "JOIN Transactions USING (signature) "
|
||||
+ "WHERE AT_address = ? "
|
||||
+ "LIMIT 1";
|
||||
|
||||
@@ -877,18 +876,17 @@ public class HSQLDBATRepository implements ATRepository {
|
||||
public NextTransactionInfo findNextTransaction(String recipient, int height, int sequence) throws DataException {
|
||||
// We only need to search for a subset of transaction types: MESSAGE, PAYMENT or AT
|
||||
|
||||
String sql = "SELECT height, sequence, Transactions.signature "
|
||||
String sql = "SELECT block_height, block_sequence, Transactions.signature "
|
||||
+ "FROM ("
|
||||
+ "SELECT signature FROM PaymentTransactions WHERE recipient = ? "
|
||||
+ "UNION "
|
||||
+ "SELECT signature FROM MessageTransactions WHERE recipient = ? "
|
||||
+ "UNION "
|
||||
+ "SELECT signature FROM ATTransactions WHERE recipient = ?"
|
||||
+ ") AS Transactions "
|
||||
+ "JOIN BlockTransactions ON BlockTransactions.transaction_signature = Transactions.signature "
|
||||
+ "JOIN Blocks ON Blocks.signature = BlockTransactions.block_signature "
|
||||
+ "WHERE (height > ? OR (height = ? AND sequence > ?)) "
|
||||
+ "ORDER BY height ASC, sequence ASC "
|
||||
+ ") AS SelectedTransactions "
|
||||
+ "JOIN Transactions USING (signature)"
|
||||
+ "WHERE (block_height > ? OR (block_height = ? AND block_sequence > ?)) "
|
||||
+ "ORDER BY block_height ASC, block_sequence ASC "
|
||||
+ "LIMIT 1";
|
||||
|
||||
Object[] bindParams = new Object[] { recipient, recipient, recipient, height, height, sequence };
|
||||
|
@@ -452,12 +452,12 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
|
||||
|
||||
// Handle name exact matches
|
||||
if (exactMatchNames != null && !exactMatchNames.isEmpty()) {
|
||||
sql.append(" AND name IN (?");
|
||||
bindParams.add(exactMatchNames.get(0));
|
||||
sql.append(" AND LCASE(name) IN (?");
|
||||
bindParams.add(exactMatchNames.get(0).toLowerCase());
|
||||
|
||||
for (int i = 1; i < exactMatchNames.size(); ++i) {
|
||||
sql.append(", ?");
|
||||
bindParams.add(exactMatchNames.get(i));
|
||||
bindParams.add(exactMatchNames.get(i).toLowerCase());
|
||||
}
|
||||
sql.append(")");
|
||||
}
|
||||
@@ -466,12 +466,12 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
|
||||
if (followedOnly != null && followedOnly) {
|
||||
List<String> followedNames = ListUtils.followedNames();
|
||||
if (followedNames != null && !followedNames.isEmpty()) {
|
||||
sql.append(" AND name IN (?");
|
||||
bindParams.add(followedNames.get(0));
|
||||
sql.append(" AND LCASE(name) IN (?");
|
||||
bindParams.add(followedNames.get(0).toLowerCase());
|
||||
|
||||
for (int i = 1; i < followedNames.size(); ++i) {
|
||||
sql.append(", ?");
|
||||
bindParams.add(followedNames.get(i));
|
||||
bindParams.add(followedNames.get(i).toLowerCase());
|
||||
}
|
||||
sql.append(")");
|
||||
}
|
||||
@@ -481,12 +481,12 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
|
||||
if (excludeBlocked != null && excludeBlocked) {
|
||||
List<String> blockedNames = ListUtils.blockedNames();
|
||||
if (blockedNames != null && !blockedNames.isEmpty()) {
|
||||
sql.append(" AND name NOT IN (?");
|
||||
bindParams.add(blockedNames.get(0));
|
||||
sql.append(" AND LCASE(name) NOT IN (?");
|
||||
bindParams.add(blockedNames.get(0).toLowerCase());
|
||||
|
||||
for (int i = 1; i < blockedNames.size(); ++i) {
|
||||
sql.append(", ?");
|
||||
bindParams.add(blockedNames.get(i));
|
||||
bindParams.add(blockedNames.get(i).toLowerCase());
|
||||
}
|
||||
sql.append(")");
|
||||
}
|
||||
|
@@ -993,6 +993,17 @@ public class HSQLDBDatabaseUpdates {
|
||||
stmt.execute("ALTER TABLE CancelSellNameTransactions ADD sale_price QortalAmount");
|
||||
break;
|
||||
|
||||
case 47:
|
||||
// Add `block_sequence` to the Transaction table, as the BlockTransactions table is pruned for
|
||||
// older blocks and therefore the sequence becomes unavailable
|
||||
LOGGER.info("Reshaping Transactions table - this can take a while...");
|
||||
stmt.execute("ALTER TABLE Transactions ADD block_sequence INTEGER");
|
||||
|
||||
// For finding transactions by height and sequence
|
||||
LOGGER.info("Adding index to Transactions table - this can take a while...");
|
||||
stmt.execute("CREATE INDEX TransactionHeightSequenceIndex on Transactions (block_height, block_sequence)");
|
||||
break;
|
||||
|
||||
default:
|
||||
// nothing to do
|
||||
return false;
|
||||
|
@@ -103,12 +103,18 @@ public class HSQLDBNameRepository implements NameRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NameData> getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException {
|
||||
StringBuilder sql = new StringBuilder(256);
|
||||
public List<NameData> searchNames(String query, boolean prefixOnly, Integer limit, Integer offset, Boolean reverse) throws DataException {
|
||||
StringBuilder sql = new StringBuilder(512);
|
||||
List<Object> bindParams = new ArrayList<>();
|
||||
|
||||
sql.append("SELECT name, reduced_name, owner, data, registered_when, updated_when, "
|
||||
+ "is_for_sale, sale_price, reference, creation_group_id FROM Names ORDER BY name");
|
||||
+ "is_for_sale, sale_price, reference, creation_group_id FROM Names "
|
||||
+ "WHERE LCASE(name) LIKE ? ORDER BY name");
|
||||
|
||||
// Search anywhere in the name, unless "prefixOnly" has been requested
|
||||
// Note that without prefixOnly it will bypass any indexes
|
||||
String queryWildcard = prefixOnly ? String.format("%s%%", query.toLowerCase()) : String.format("%%%s%%", query.toLowerCase());
|
||||
bindParams.add(queryWildcard);
|
||||
|
||||
if (reverse != null && reverse)
|
||||
sql.append(" DESC");
|
||||
@@ -117,7 +123,64 @@ public class HSQLDBNameRepository implements NameRepository {
|
||||
|
||||
List<NameData> names = new ArrayList<>();
|
||||
|
||||
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) {
|
||||
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
|
||||
if (resultSet == null)
|
||||
return names;
|
||||
|
||||
do {
|
||||
String name = resultSet.getString(1);
|
||||
String reducedName = resultSet.getString(2);
|
||||
String owner = resultSet.getString(3);
|
||||
String data = resultSet.getString(4);
|
||||
long registered = resultSet.getLong(5);
|
||||
|
||||
// Special handling for possibly-NULL "updated" column
|
||||
Long updated = resultSet.getLong(6);
|
||||
if (updated == 0 && resultSet.wasNull())
|
||||
updated = null;
|
||||
|
||||
boolean isForSale = resultSet.getBoolean(7);
|
||||
|
||||
Long salePrice = resultSet.getLong(8);
|
||||
if (salePrice == 0 && resultSet.wasNull())
|
||||
salePrice = null;
|
||||
|
||||
byte[] reference = resultSet.getBytes(9);
|
||||
int creationGroupId = resultSet.getInt(10);
|
||||
|
||||
names.add(new NameData(name, reducedName, owner, data, registered, updated, isForSale, salePrice, reference, creationGroupId));
|
||||
} while (resultSet.next());
|
||||
|
||||
return names;
|
||||
} catch (SQLException e) {
|
||||
throw new DataException("Unable to search names in repository", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NameData> getAllNames(Long after, Integer limit, Integer offset, Boolean reverse) throws DataException {
|
||||
StringBuilder sql = new StringBuilder(256);
|
||||
List<Object> bindParams = new ArrayList<>();
|
||||
|
||||
sql.append("SELECT name, reduced_name, owner, data, registered_when, updated_when, "
|
||||
+ "is_for_sale, sale_price, reference, creation_group_id FROM Names");
|
||||
|
||||
if (after != null) {
|
||||
sql.append(" WHERE registered_when > ? OR updated_when > ?");
|
||||
bindParams.add(after);
|
||||
bindParams.add(after);
|
||||
}
|
||||
|
||||
sql.append(" ORDER BY name");
|
||||
|
||||
if (reverse != null && reverse)
|
||||
sql.append(" DESC");
|
||||
|
||||
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
|
||||
|
||||
List<NameData> names = new ArrayList<>();
|
||||
|
||||
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
|
||||
if (resultSet == null)
|
||||
return names;
|
||||
|
||||
|
@@ -194,8 +194,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
|
||||
|
||||
@Override
|
||||
public TransactionData fromHeightAndSequence(int height, int sequence) throws DataException {
|
||||
String sql = "SELECT transaction_signature FROM BlockTransactions JOIN Blocks ON signature = block_signature "
|
||||
+ "WHERE height = ? AND sequence = ?";
|
||||
String sql = "SELECT signature FROM Transactions WHERE block_height = ? AND block_sequence = ?";
|
||||
|
||||
try (ResultSet resultSet = this.repository.checkedExecute(sql, height, sequence)) {
|
||||
if (resultSet == null)
|
||||
@@ -657,8 +656,13 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
|
||||
List<Object> bindParams) throws DataException {
|
||||
List<byte[]> signatures = new ArrayList<>();
|
||||
|
||||
String txTypeClassName = "";
|
||||
if (txType != null) {
|
||||
txTypeClassName = txType.className;
|
||||
}
|
||||
|
||||
StringBuilder sql = new StringBuilder(1024);
|
||||
sql.append(String.format("SELECT signature FROM %sTransactions", txType.className));
|
||||
sql.append(String.format("SELECT signature FROM %sTransactions", txTypeClassName));
|
||||
|
||||
if (!whereClauses.isEmpty()) {
|
||||
sql.append(" WHERE ");
|
||||
@@ -690,6 +694,53 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public List<byte[]> getSignaturesMatchingCustomCriteria(TransactionType txType, List<String> whereClauses,
|
||||
List<Object> bindParams, Integer limit) throws DataException {
|
||||
List<byte[]> signatures = new ArrayList<>();
|
||||
|
||||
String txTypeClassName = "";
|
||||
if (txType != null) {
|
||||
txTypeClassName = txType.className;
|
||||
}
|
||||
|
||||
StringBuilder sql = new StringBuilder(1024);
|
||||
sql.append(String.format("SELECT signature FROM %sTransactions", txTypeClassName));
|
||||
|
||||
if (!whereClauses.isEmpty()) {
|
||||
sql.append(" WHERE ");
|
||||
|
||||
final int whereClausesSize = whereClauses.size();
|
||||
for (int wci = 0; wci < whereClausesSize; ++wci) {
|
||||
if (wci != 0)
|
||||
sql.append(" AND ");
|
||||
|
||||
sql.append(whereClauses.get(wci));
|
||||
}
|
||||
}
|
||||
|
||||
if (limit != null) {
|
||||
sql.append(" LIMIT ?");
|
||||
bindParams.add(limit);
|
||||
}
|
||||
|
||||
LOGGER.trace(() -> String.format("Transaction search SQL: %s", sql));
|
||||
|
||||
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
|
||||
if (resultSet == null)
|
||||
return signatures;
|
||||
|
||||
do {
|
||||
byte[] signature = resultSet.getBytes(1);
|
||||
|
||||
signatures.add(signature);
|
||||
} while (resultSet.next());
|
||||
|
||||
return signatures;
|
||||
} catch (SQLException e) {
|
||||
throw new DataException("Unable to fetch matching transaction signatures from repository", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getLatestAutoUpdateTransaction(TransactionType txType, int txGroupId, Integer service) throws DataException {
|
||||
StringBuilder sql = new StringBuilder(1024);
|
||||
@@ -1378,8 +1429,10 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TransactionData> getUnconfirmedTransactions(EnumSet<TransactionType> excludedTxTypes) throws DataException {
|
||||
public List<TransactionData> getUnconfirmedTransactions(EnumSet<TransactionType> excludedTxTypes, Integer limit) throws DataException {
|
||||
StringBuilder sql = new StringBuilder(1024);
|
||||
List<Object> bindParams = new ArrayList<>();
|
||||
|
||||
sql.append("SELECT signature FROM UnconfirmedTransactions ");
|
||||
sql.append("JOIN Transactions USING (signature) ");
|
||||
sql.append("WHERE type NOT IN (");
|
||||
@@ -1395,12 +1448,17 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
|
||||
}
|
||||
|
||||
sql.append(")");
|
||||
sql.append("ORDER BY created_when, signature");
|
||||
sql.append("ORDER BY created_when, signature ");
|
||||
|
||||
if (limit != null) {
|
||||
sql.append("LIMIT ?");
|
||||
bindParams.add(limit);
|
||||
}
|
||||
|
||||
List<TransactionData> transactions = new ArrayList<>();
|
||||
|
||||
// Find transactions with no corresponding row in BlockTransactions
|
||||
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) {
|
||||
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
|
||||
if (resultSet == null)
|
||||
return transactions;
|
||||
|
||||
@@ -1444,6 +1502,19 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateBlockSequence(byte[] signature, Integer blockSequence) throws DataException {
|
||||
HSQLDBSaver saver = new HSQLDBSaver("Transactions");
|
||||
|
||||
saver.bind("signature", signature).bind("block_sequence", blockSequence);
|
||||
|
||||
try {
|
||||
saver.execute(repository);
|
||||
} catch (SQLException e) {
|
||||
throw new DataException("Unable to update transaction's block sequence in repository", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateApprovalHeight(byte[] signature, Integer approvalHeight) throws DataException {
|
||||
HSQLDBSaver saver = new HSQLDBSaver("Transactions");
|
||||
|
@@ -47,6 +47,9 @@ public class Settings {
|
||||
private static final int MAINNET_GATEWAY_PORT = 80;
|
||||
private static final int TESTNET_GATEWAY_PORT = 8080;
|
||||
|
||||
private static final int MAINNET_DEV_PROXY_PORT = 12393;
|
||||
private static final int TESTNET_DEV_PROXY_PORT = 62393;
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(Settings.class);
|
||||
private static final String SETTINGS_FILENAME = "settings.json";
|
||||
|
||||
@@ -107,6 +110,11 @@ public class Settings {
|
||||
private boolean gatewayLoggingEnabled = false;
|
||||
private boolean gatewayLoopbackEnabled = false;
|
||||
|
||||
// Developer Proxy
|
||||
private Integer devProxyPort;
|
||||
private boolean devProxyLoggingEnabled = false;
|
||||
|
||||
|
||||
// Specific to this node
|
||||
private boolean wipeUnconfirmedOnStart = false;
|
||||
/** Maximum number of unconfirmed transactions allowed per account */
|
||||
@@ -138,6 +146,9 @@ public class Settings {
|
||||
/* How many blocks to cache locally. Defaulted to 10, which covers a typical Synchronizer request + a few spare */
|
||||
private int blockCacheSize = 10;
|
||||
|
||||
/** Maximum number of transactions for the block minter to include in a block */
|
||||
private int maxTransactionsPerBlock = 25;
|
||||
|
||||
/** How long to keep old, full, AT state data (ms). */
|
||||
private long atStatesMaxLifetime = 5 * 24 * 60 * 60 * 1000L; // milliseconds
|
||||
/** How often to attempt AT state trimming (ms). */
|
||||
@@ -181,7 +192,7 @@ public class Settings {
|
||||
/** How often to attempt archiving (ms). */
|
||||
private long archiveInterval = 7171L; // milliseconds
|
||||
/** Serialization version to use when building an archive */
|
||||
private int defaultArchiveVersion = 1;
|
||||
private int defaultArchiveVersion = 2;
|
||||
|
||||
|
||||
/** Whether to automatically bootstrap instead of syncing from genesis */
|
||||
@@ -201,25 +212,25 @@ public class Settings {
|
||||
/** Whether to attempt to open the listen port via UPnP */
|
||||
private boolean uPnPEnabled = true;
|
||||
/** Minimum number of peers to allow block minting / synchronization. */
|
||||
private int minBlockchainPeers = 5;
|
||||
private int minBlockchainPeers = 3;
|
||||
/** Target number of outbound connections to peers we should make. */
|
||||
private int minOutboundPeers = 16;
|
||||
/** Maximum number of peer connections we allow. */
|
||||
private int maxPeers = 36;
|
||||
private int maxPeers = 40;
|
||||
/** Number of slots to reserve for short-lived QDN data transfers */
|
||||
private int maxDataPeers = 4;
|
||||
/** Maximum number of threads for network engine. */
|
||||
private int maxNetworkThreadPoolSize = 32;
|
||||
private int maxNetworkThreadPoolSize = 120;
|
||||
/** Maximum number of threads for network proof-of-work compute, used during handshaking. */
|
||||
private int networkPoWComputePoolSize = 2;
|
||||
/** Maximum number of retry attempts if a peer fails to respond with the requested data */
|
||||
private int maxRetries = 2;
|
||||
|
||||
/** The number of seconds of no activity before recovery mode begins */
|
||||
public long recoveryModeTimeout = 10 * 60 * 1000L;
|
||||
public long recoveryModeTimeout = 24 * 60 * 60 * 1000L;
|
||||
|
||||
/** Minimum peer version number required in order to sync with them */
|
||||
private String minPeerVersion = "3.8.7";
|
||||
private String minPeerVersion = "4.1.2";
|
||||
/** Whether to allow connections with peers below minPeerVersion
|
||||
* If true, we won't sync with them but they can still sync with us, and will show in the peers list
|
||||
* If false, sync will be blocked both ways, and they will not appear in the peers list */
|
||||
@@ -253,6 +264,9 @@ public class Settings {
|
||||
/** Whether to show SysTray pop-up notifications when trade-bot entries change state */
|
||||
private boolean tradebotSystrayEnabled = false;
|
||||
|
||||
/** Maximum buy attempts for each trade offer before it is considered failed, and hidden from the list */
|
||||
private int maxTradeOfferAttempts = 3;
|
||||
|
||||
/** Wallets path - used for storing encrypted wallet caches for coins that require them */
|
||||
private String walletsPath = "wallets";
|
||||
|
||||
@@ -264,7 +278,7 @@ public class Settings {
|
||||
/** Repository storage path. */
|
||||
private String repositoryPath = "db";
|
||||
/** Repository connection pool size. Needs to be a bit bigger than maxNetworkThreadPoolSize */
|
||||
private int repositoryConnectionPoolSize = 100;
|
||||
private int repositoryConnectionPoolSize = 240;
|
||||
private List<String> fixedNetwork;
|
||||
|
||||
// Export/import
|
||||
@@ -505,6 +519,9 @@ public class Settings {
|
||||
if (this.minBlockchainPeers < 1 && !singleNodeTestnet)
|
||||
throwValidationError("minBlockchainPeers must be at least 1");
|
||||
|
||||
if (this.topOnly)
|
||||
throwValidationError("topOnly mode is no longer supported");
|
||||
|
||||
if (this.apiKey != null && this.apiKey.trim().length() < 8)
|
||||
throwValidationError("apiKey must be at least 8 characters");
|
||||
|
||||
@@ -643,6 +660,18 @@ public class Settings {
|
||||
}
|
||||
|
||||
|
||||
public int getDevProxyPort() {
|
||||
if (this.devProxyPort != null)
|
||||
return this.devProxyPort;
|
||||
|
||||
return this.isTestNet ? TESTNET_DEV_PROXY_PORT : MAINNET_DEV_PROXY_PORT;
|
||||
}
|
||||
|
||||
public boolean isDevProxyLoggingEnabled() {
|
||||
return this.devProxyLoggingEnabled;
|
||||
}
|
||||
|
||||
|
||||
public boolean getWipeUnconfirmedOnStart() {
|
||||
return this.wipeUnconfirmedOnStart;
|
||||
}
|
||||
@@ -667,6 +696,10 @@ public class Settings {
|
||||
return this.blockCacheSize;
|
||||
}
|
||||
|
||||
public int getMaxTransactionsPerBlock() {
|
||||
return this.maxTransactionsPerBlock;
|
||||
}
|
||||
|
||||
public boolean isTestNet() {
|
||||
return this.isTestNet;
|
||||
}
|
||||
@@ -771,6 +804,10 @@ public class Settings {
|
||||
return this.pirateChainNet;
|
||||
}
|
||||
|
||||
public int getMaxTradeOfferAttempts() {
|
||||
return this.maxTradeOfferAttempts;
|
||||
}
|
||||
|
||||
public String getWalletsPath() {
|
||||
return this.walletsPath;
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.controller.repository.NamesDatabaseIntegrityCheck;
|
||||
import org.qortal.data.naming.NameData;
|
||||
import org.qortal.data.transaction.CancelSellNameTransactionData;
|
||||
@@ -65,7 +66,9 @@ public class CancelSellNameTransaction extends Transaction {
|
||||
|
||||
// Check name is currently for sale
|
||||
if (!nameData.isForSale())
|
||||
return ValidationResult.NAME_NOT_FOR_SALE;
|
||||
// Only validate after feature-trigger timestamp, due to a small number of double cancelations in the chain history
|
||||
if (this.cancelSellNameTransactionData.getTimestamp() > BlockChain.getInstance().getCancelSellNameValidationTimestamp())
|
||||
return ValidationResult.NAME_NOT_FOR_SALE;
|
||||
|
||||
// Check transaction creator matches name's current owner
|
||||
Account owner = getOwner();
|
||||
|
@@ -641,7 +641,7 @@ public abstract class Transaction {
|
||||
BlockData latestBlockData = repository.getBlockRepository().getLastBlock();
|
||||
|
||||
EnumSet<TransactionType> excludedTxTypes = EnumSet.of(TransactionType.CHAT, TransactionType.PRESENCE);
|
||||
List<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes);
|
||||
List<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes, null);
|
||||
|
||||
unconfirmedTransactions.sort(getDataComparator());
|
||||
|
||||
|
@@ -1,5 +1,7 @@
|
||||
package org.qortal.utils;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
@@ -12,6 +14,8 @@ import java.util.List;
|
||||
|
||||
public class BlockArchiveUtils {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(BlockArchiveUtils.class);
|
||||
|
||||
/**
|
||||
* importFromArchive
|
||||
* <p>
|
||||
@@ -87,7 +91,8 @@ public class BlockArchiveUtils {
|
||||
|
||||
} catch (DataException e) {
|
||||
repository.discardChanges();
|
||||
throw new IllegalStateException("Unable to import blocks from archive");
|
||||
LOGGER.info("Unable to import blocks from archive", e);
|
||||
throw(e);
|
||||
}
|
||||
}
|
||||
repository.saveChanges();
|
||||
|
@@ -228,12 +228,18 @@ public class FilesystemUtils {
|
||||
* @throws IOException
|
||||
*/
|
||||
public static byte[] getSingleFileContents(Path path) throws IOException {
|
||||
return getSingleFileContents(path, null);
|
||||
}
|
||||
|
||||
public static byte[] getSingleFileContents(Path path, Integer maxLength) throws IOException {
|
||||
byte[] data = null;
|
||||
// TODO: limit the file size that can be loaded into memory
|
||||
|
||||
// If the path is a file, read the contents directly
|
||||
if (path.toFile().isFile()) {
|
||||
data = Files.readAllBytes(path);
|
||||
int fileSize = (int)path.toFile().length();
|
||||
maxLength = maxLength != null ? Math.min(maxLength, fileSize) : fileSize;
|
||||
data = FilesystemUtils.readFromFile(path.toString(), 0, maxLength);
|
||||
}
|
||||
|
||||
// Or if it's a directory, only load file contents if there is a single file inside it
|
||||
@@ -242,7 +248,9 @@ public class FilesystemUtils {
|
||||
if (files.length == 1) {
|
||||
Path filePath = Paths.get(path.toString(), files[0]);
|
||||
if (filePath.toFile().isFile()) {
|
||||
data = Files.readAllBytes(filePath);
|
||||
int fileSize = (int)filePath.toFile().length();
|
||||
maxLength = maxLength != null ? Math.min(maxLength, fileSize) : fileSize;
|
||||
data = FilesystemUtils.readFromFile(filePath.toString(), 0, maxLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
6106
src/main/resources/block-1333492-deltas.json
Normal file
6106
src/main/resources/block-1333492-deltas.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,8 @@
|
||||
"selfSponsorshipAlgoV1Height": 1092400,
|
||||
"feeValidationFixTimestamp": 1671918000000,
|
||||
"chatReferenceTimestamp": 1674316800000,
|
||||
"arbitraryOptionalFeeTimestamp": 1680278400000
|
||||
"arbitraryOptionalFeeTimestamp": 1680278400000,
|
||||
"cancelSellNameValidationTimestamp": 1676986362069
|
||||
},
|
||||
"checkpoints": [
|
||||
{ "height": 1136300, "signature": "3BbwawEF2uN8Ni5ofpJXkukoU8ctAPxYoFB7whq9pKfBnjfZcpfEJT4R95NvBDoTP8WDyWvsUvbfHbcr9qSZuYpSKZjUQTvdFf6eqznHGEwhZApWfvXu6zjGCxYCp65F4jsVYYJjkzbjmkCg5WAwN5voudngA23kMK6PpTNygapCzXt" }
|
||||
|
83
src/main/resources/i18n/ApiError_jp.properties
Normal file
83
src/main/resources/i18n/ApiError_jp.properties
Normal file
@@ -0,0 +1,83 @@
|
||||
#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/)
|
||||
# Keys are from api.ApiError enum
|
||||
|
||||
# "localeLang": "jp",
|
||||
|
||||
### Common ###
|
||||
JSON = JSON メッセージの解析に失敗しました
|
||||
|
||||
INSUFFICIENT_BALANCE = 残高不足
|
||||
|
||||
UNAUTHORIZED = APIコール未承認
|
||||
|
||||
REPOSITORY_ISSUE = リポジトリエラー
|
||||
|
||||
NON_PRODUCTION = この APIコールはプロダクションシステムでは許可されていません
|
||||
|
||||
BLOCKCHAIN_NEEDS_SYNC = ブロックチェーンをまず同期する必要があります
|
||||
|
||||
NO_TIME_SYNC = 時刻が未同期
|
||||
|
||||
### Validation ###
|
||||
INVALID_SIGNATURE = 無効な署名
|
||||
|
||||
INVALID_ADDRESS = 無効なアドレス
|
||||
|
||||
INVALID_PUBLIC_KEY = 無効な公開鍵
|
||||
|
||||
INVALID_DATA = 無効なデータ
|
||||
|
||||
INVALID_NETWORK_ADDRESS = 無効なネットワーク アドレス
|
||||
|
||||
ADDRESS_UNKNOWN = 不明なアカウントアドレス
|
||||
|
||||
INVALID_CRITERIA = 無効な検索条件
|
||||
|
||||
INVALID_REFERENCE = 無効な参照
|
||||
|
||||
TRANSFORMATION_ERROR = JSONをトランザクションに変換出来ませんでした
|
||||
|
||||
INVALID_PRIVATE_KEY = 無効な秘密鍵
|
||||
|
||||
INVALID_HEIGHT = 無効なブロック高
|
||||
|
||||
CANNOT_MINT = アカウントはミント出来ません
|
||||
|
||||
### Blocks ###
|
||||
BLOCK_UNKNOWN = 不明なブロック
|
||||
|
||||
### Transactions ###
|
||||
TRANSACTION_UNKNOWN = 不明なトランザクション
|
||||
|
||||
PUBLIC_KEY_NOT_FOUND = 公開鍵が見つかりません
|
||||
|
||||
# this one is special in that caller expected to pass two additional strings, hence the two %s
|
||||
TRANSACTION_INVALID = 無効なトランザクション: %s (%s)
|
||||
|
||||
### Naming ###
|
||||
NAME_UNKNOWN = 不明な名前
|
||||
|
||||
### Asset ###
|
||||
INVALID_ASSET_ID = 無効なアセット ID
|
||||
|
||||
INVALID_ORDER_ID = 無効なアセット注文 ID
|
||||
|
||||
ORDER_UNKNOWN = 不明なアセット注文 ID
|
||||
|
||||
### Groups ###
|
||||
GROUP_UNKNOWN = 不明なグループ
|
||||
|
||||
### Foreign Blockchain ###
|
||||
FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = 外部ブロックチェーンまたはElectrumXネットワークの問題
|
||||
|
||||
FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = 外部ブロックチェーンの残高が不足しています
|
||||
|
||||
FOREIGN_BLOCKCHAIN_TOO_SOON = 外部ブロックチェーン トランザクションのブロードキャストが時期尚早 (ロックタイム/ブロック時間の中央値)
|
||||
|
||||
### Trade Portal ###
|
||||
ORDER_SIZE_TOO_SMALL = 注文金額が低すぎます
|
||||
|
||||
### Data ###
|
||||
FILE_NOT_FOUND = ファイルが見つかりません
|
||||
|
||||
NO_REPLY = ピアが制限時間内に応答しませんでした
|
48
src/main/resources/i18n/SysTray_jp.properties
Normal file
48
src/main/resources/i18n/SysTray_jp.properties
Normal file
@@ -0,0 +1,48 @@
|
||||
#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/)
|
||||
# SysTray pop-up menu # Japanese translation by R M 2023
|
||||
|
||||
APPLYING_UPDATE_AND_RESTARTING = 自動更新を適用して再起動しています...
|
||||
|
||||
AUTO_UPDATE = 自動更新
|
||||
|
||||
BLOCK_HEIGHT = ブロック高
|
||||
|
||||
BLOCKS_REMAINING = 残りのブロック
|
||||
|
||||
BUILD_VERSION = ビルドバージョン
|
||||
|
||||
CHECK_TIME_ACCURACY = 時刻の精度を確認
|
||||
|
||||
CONNECTING = 接続中
|
||||
|
||||
CONNECTION = 接続
|
||||
|
||||
CONNECTIONS = 接続
|
||||
|
||||
CREATING_BACKUP_OF_DB_FILES = データベース ファイルのバックアップを作成中...
|
||||
|
||||
DB_BACKUP = データベースのバックアップ
|
||||
|
||||
DB_CHECKPOINT = データベースのチェックポイント
|
||||
|
||||
DB_MAINTENANCE = データベースのメンテナンス
|
||||
|
||||
EXIT = 終了
|
||||
|
||||
LITE_NODE = ライトノード
|
||||
|
||||
MINTING_DISABLED = ミント一時中止中
|
||||
|
||||
MINTING_ENABLED = \u2714 ミント
|
||||
|
||||
OPEN_UI = UIを開く
|
||||
|
||||
PERFORMING_DB_CHECKPOINT = コミットされていないデータベースの変更を保存中...
|
||||
|
||||
PERFORMING_DB_MAINTENANCE = 定期メンテナンスを実行中...
|
||||
|
||||
SYNCHRONIZE_CLOCK = 時刻を同期
|
||||
|
||||
SYNCHRONIZING_BLOCKCHAIN = ブロックチェーンを同期中
|
||||
|
||||
SYNCHRONIZING_CLOCK = 時刻を同期中
|
195
src/main/resources/i18n/TransactionValidity_jp.properties
Normal file
195
src/main/resources/i18n/TransactionValidity_jp.properties
Normal file
@@ -0,0 +1,195 @@
|
||||
#
|
||||
|
||||
ACCOUNT_ALREADY_EXISTS = 既にアカウントは存在します
|
||||
|
||||
ACCOUNT_CANNOT_REWARD_SHARE = アカウントは報酬シェアが出来ません
|
||||
|
||||
ADDRESS_ABOVE_RATE_LIMIT = アドレスが指定されたレート制限に達しました
|
||||
|
||||
ADDRESS_BLOCKED = このアドレスはブロックされています
|
||||
|
||||
ALREADY_GROUP_ADMIN = 既ににグループ管理者です
|
||||
|
||||
ALREADY_GROUP_MEMBER = 既にグループメンバーです
|
||||
|
||||
ALREADY_VOTED_FOR_THAT_OPTION = 既にそのオプションに投票しています
|
||||
|
||||
ASSET_ALREADY_EXISTS = 既にアセットは存在します
|
||||
|
||||
ASSET_DOES_NOT_EXIST = アセットが存在しません
|
||||
|
||||
ASSET_DOES_NOT_MATCH_AT = アセットがATのアセットと一致しません
|
||||
|
||||
ASSET_NOT_SPENDABLE = 資産が使用不可です
|
||||
|
||||
AT_ALREADY_EXISTS = 既にATが存在します
|
||||
|
||||
AT_IS_FINISHED = ATが終了しました
|
||||
|
||||
AT_UNKNOWN = 不明なAT
|
||||
|
||||
BAN_EXISTS = 既にバンされてます
|
||||
|
||||
BAN_UNKNOWN = 不明なバン
|
||||
|
||||
BANNED_FROM_GROUP = グループからのバンされています
|
||||
|
||||
BUYER_ALREADY_OWNER = 既に購入者が所有者です
|
||||
|
||||
CLOCK_NOT_SYNCED = 時刻が未同期
|
||||
|
||||
DUPLICATE_MESSAGE = このアドレスは重複メッセージを送信しました
|
||||
|
||||
DUPLICATE_OPTION = 重複したオプション
|
||||
|
||||
GROUP_ALREADY_EXISTS = 既にグループは存在します
|
||||
|
||||
GROUP_APPROVAL_DECIDED = 既にグループの承認は決定されています
|
||||
|
||||
GROUP_APPROVAL_NOT_REQUIRED = グループ承認が不必要
|
||||
|
||||
GROUP_DOES_NOT_EXIST = グループが存在しません
|
||||
|
||||
GROUP_ID_MISMATCH = グループ ID が不一致
|
||||
|
||||
GROUP_OWNER_CANNOT_LEAVE = グループ所有者はグループを退会出来ません
|
||||
|
||||
HAVE_EQUALS_WANT = 持っている資産は欲しい資産と同じです
|
||||
|
||||
INCORRECT_NONCE = 不正な PoW ナンス
|
||||
|
||||
INSUFFICIENT_FEE = 手数料が不十分です
|
||||
|
||||
INVALID_ADDRESS = 無効なアドレス
|
||||
|
||||
INVALID_AMOUNT = 無効な金額
|
||||
|
||||
INVALID_ASSET_OWNER = 無効なアセット所有者
|
||||
|
||||
INVALID_AT_TRANSACTION = 無効なATトランザクション
|
||||
|
||||
INVALID_AT_TYPE_LENGTH = 無効なATの「タイプ」の長さです
|
||||
|
||||
INVALID_BUT_OK = 無効だがOK
|
||||
|
||||
INVALID_CREATION_BYTES = 無効な作成バイト数
|
||||
|
||||
INVALID_DATA_LENGTH = 無効なデータ長
|
||||
|
||||
INVALID_DESCRIPTION_LENGTH = 無効な概要の長さ
|
||||
|
||||
INVALID_GROUP_APPROVAL_THRESHOLD = 無効なグループ承認のしきい値
|
||||
|
||||
INVALID_GROUP_BLOCK_DELAY = 無効なグループ承認のブロック遅延
|
||||
|
||||
INVALID_GROUP_ID = 無効なグループ ID
|
||||
|
||||
INVALID_GROUP_OWNER = 無効なグループ所有者
|
||||
|
||||
INVALID_LIFETIME = 無効な有効期間
|
||||
|
||||
INVALID_NAME_LENGTH = 無効な名前の長さです
|
||||
|
||||
INVALID_NAME_OWNER = 無効な名前の所有者
|
||||
|
||||
INVALID_OPTION_LENGTH = 無効なオプションの長さ
|
||||
|
||||
INVALID_OPTIONS_COUNT = 無効なオプションの数
|
||||
|
||||
INVALID_ORDER_CREATOR = 無効な注文作成者
|
||||
|
||||
INVALID_PAYMENTS_COUNT = 無効な入出金数
|
||||
|
||||
INVALID_PUBLIC_KEY = 無効な公開鍵
|
||||
|
||||
INVALID_QUANTITY = 無効な数量
|
||||
|
||||
INVALID_REFERENCE = 無効な参照
|
||||
|
||||
INVALID_RETURN = 無効な返品
|
||||
|
||||
INVALID_REWARD_SHARE_PERCENT = 無効な報酬シェア率
|
||||
|
||||
INVALID_SELLER = 無効な販売者
|
||||
|
||||
INVALID_TAGS_LENGTH = 無効な「タグ」の長さ
|
||||
|
||||
INVALID_TIMESTAMP_SIGNATURE = 無効なタイムスタンプ署名
|
||||
|
||||
INVALID_TX_GROUP_ID = 無効なトランザクション グループ ID
|
||||
|
||||
INVALID_VALUE_LENGTH = 無効な「値」の長さ
|
||||
|
||||
INVITE_UNKNOWN = 不明なグループ招待
|
||||
|
||||
JOIN_REQUEST_EXISTS = 既にグループ参加リクエストが存在します
|
||||
|
||||
MAXIMUM_REWARD_SHARES = 既にこのアカウントの報酬シェアは最大です
|
||||
|
||||
MISSING_CREATOR = 作成者が見つかりません
|
||||
|
||||
MULTIPLE_NAMES_FORBIDDEN = アカウントごとに複数の登録名は禁止されています
|
||||
|
||||
NAME_ALREADY_FOR_SALE = 既に名前は販売中です
|
||||
|
||||
NAME_ALREADY_REGISTERED = 既に名前は登録されています
|
||||
|
||||
NAME_BLOCKED = この名前はブロックされています
|
||||
|
||||
NAME_DOES_NOT_EXIST = 名前は存在しません
|
||||
|
||||
NAME_NOT_FOR_SALE = 名前は非売品です
|
||||
|
||||
NAME_NOT_NORMALIZED = 名前は Unicode の「正規化」形式ではありません
|
||||
|
||||
NEGATIVE_AMOUNT = 無効な/負の金額
|
||||
|
||||
NEGATIVE_FEE = 無効な/負の料金
|
||||
|
||||
NEGATIVE_PRICE = 無効な/負の価格
|
||||
|
||||
NO_BALANCE = 残高が不足しています
|
||||
|
||||
NO_BLOCKCHAIN_LOCK = ノードのブロックチェーンは現在ビジーです
|
||||
|
||||
NO_FLAG_PERMISSION = アカウントにはその権限がありません
|
||||
|
||||
NOT_GROUP_ADMIN = アカウントはグループ管理者ではありません
|
||||
|
||||
NOT_GROUP_MEMBER = アカウントはグループメンバーではありません
|
||||
|
||||
NOT_MINTING_ACCOUNT = アカウントはミント出来ません
|
||||
|
||||
NOT_YET_RELEASED = 機能はまだリリースされていません
|
||||
|
||||
OK = OK
|
||||
|
||||
ORDER_ALREADY_CLOSED = 既に資産取引注文は終了しています
|
||||
|
||||
ORDER_DOES_NOT_EXIST = 資産取引注文が存在しません
|
||||
|
||||
POLL_ALREADY_EXISTS = 既に投票は存在します
|
||||
|
||||
POLL_DOES_NOT_EXIST = 投票は存在しません
|
||||
|
||||
POLL_OPTION_DOES_NOT_EXIST = 投票オプションが存在しません
|
||||
|
||||
PUBLIC_KEY_UNKNOWN = 不明な公開鍵
|
||||
|
||||
REWARD_SHARE_UNKNOWN = 不明な報酬シェア
|
||||
|
||||
SELF_SHARE_EXISTS = 既に自己シェア(報酬シェア)が存在します
|
||||
|
||||
TIMESTAMP_TOO_NEW = タイムスタンプが新しすぎます
|
||||
|
||||
TIMESTAMP_TOO_OLD = タイムスタンプが古すぎます
|
||||
|
||||
TOO_MANY_UNCONFIRMED = アカウントに保留中の未承認トランザクションが多すぎます
|
||||
|
||||
TRANSACTION_ALREADY_CONFIRMED = 既にトランザクションは承認されています
|
||||
|
||||
TRANSACTION_ALREADY_EXISTS = 既にトランザクションは存在します
|
||||
|
||||
TRANSACTION_UNKNOWN = 不明なトランザクション
|
||||
|
||||
TX_GROUP_ID_MISMATCH = トランザクションのグループIDが一致しません
|
1796
src/main/resources/invalid-transaction-balance-deltas.json
Normal file
1796
src/main/resources/invalid-transaction-balance-deltas.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,12 +50,16 @@ window.addEventListener("message", (event) => {
|
||||
switch (data.action) {
|
||||
case "GET_USER_ACCOUNT":
|
||||
case "PUBLISH_QDN_RESOURCE":
|
||||
case "PUBLISH_MULTIPLE_QDN_RESOURCES":
|
||||
case "SEND_CHAT_MESSAGE":
|
||||
case "JOIN_GROUP":
|
||||
case "DEPLOY_AT":
|
||||
case "GET_WALLET_BALANCE":
|
||||
case "SEND_COIN":
|
||||
const errorString = "Authentication was requested, but this is not yet supported when viewing via a gateway. To use interactive features, please access using the Qortal UI desktop app. More info at: https://qortal.org";
|
||||
case "GET_LIST_ITEMS":
|
||||
case "ADD_LIST_ITEMS":
|
||||
case "DELETE_LIST_ITEM":
|
||||
const errorString = "Interactive features were requested, but these are not yet supported when viewing via a gateway. To use interactive features, please access using the Qortal UI desktop app. More info at: https://qortal.org";
|
||||
response = "{\"error\": \"" + errorString + "\"}"
|
||||
|
||||
const modalText = "This app is powered by the Qortal blockchain. You are viewing in read-only mode. To use interactive features, please access using the Qortal UI desktop app.";
|
||||
|
@@ -1,4 +1,4 @@
|
||||
function httpGet(event, url) {
|
||||
function httpGet(url) {
|
||||
var request = new XMLHttpRequest();
|
||||
request.open("GET", url, false);
|
||||
request.send(null);
|
||||
@@ -23,7 +23,7 @@ function httpGetAsyncWithEvent(event, url) {
|
||||
.catch((error) => {
|
||||
let res = {};
|
||||
res.error = error;
|
||||
handleResponse(JSON.stringify(res), responseText);
|
||||
handleResponse(event, JSON.stringify(res));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,6 +46,18 @@ function handleResponse(event, response) {
|
||||
responseObj = response;
|
||||
}
|
||||
|
||||
// GET_QDN_RESOURCE_URL has custom handling
|
||||
const data = event.data;
|
||||
if (data.action == "GET_QDN_RESOURCE_URL") {
|
||||
if (responseObj == null || responseObj.status == null || responseObj.status == "NOT_PUBLISHED") {
|
||||
responseObj = {};
|
||||
responseObj.error = "Resource does not exist";
|
||||
}
|
||||
else {
|
||||
responseObj = buildResourceUrl(data.service, data.name, data.identifier, data.path, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to app
|
||||
if (responseObj.error != null) {
|
||||
event.ports[0].postMessage({
|
||||
@@ -157,7 +169,7 @@ window.addEventListener("message", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Core received event: " + JSON.stringify(event.data));
|
||||
console.log("Core received action: " + JSON.stringify(event.data.action));
|
||||
|
||||
let url;
|
||||
let data = event.data;
|
||||
@@ -169,13 +181,23 @@ window.addEventListener("message", (event) => {
|
||||
case "GET_ACCOUNT_NAMES":
|
||||
return httpGetAsyncWithEvent(event, "/names/address/" + data.address);
|
||||
|
||||
case "SEARCH_NAMES":
|
||||
url = "/names/search?";
|
||||
if (data.query != null) url = url.concat("&query=" + data.query);
|
||||
if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString());
|
||||
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
||||
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
||||
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
||||
return httpGetAsyncWithEvent(event, url);
|
||||
|
||||
case "GET_NAME_DATA":
|
||||
return httpGetAsyncWithEvent(event, "/names/" + data.name);
|
||||
|
||||
case "GET_QDN_RESOURCE_URL":
|
||||
const response = buildResourceUrl(data.service, data.name, data.identifier, data.path, false);
|
||||
handleResponse(event, response);
|
||||
return;
|
||||
// Check status first; URL is built and returned automatically after status check
|
||||
url = "/arbitrary/resource/status/" + data.service + "/" + data.name;
|
||||
if (data.identifier != null) url = url.concat("/" + data.identifier);
|
||||
return httpGetAsyncWithEvent(event, url);
|
||||
|
||||
case "LINK_TO_QDN_RESOURCE":
|
||||
if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE
|
||||
@@ -206,6 +228,7 @@ window.addEventListener("message", (event) => {
|
||||
if (data.name != null) url = url.concat("&name=" + data.name);
|
||||
if (data.names != null) data.names.forEach((x, i) => url = url.concat("&name=" + x));
|
||||
if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString());
|
||||
if (data.exactMatchNames != null) url = url.concat("&exactmatchnames=" + new Boolean(data.exactMatchNames).toString());
|
||||
if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString());
|
||||
if (data.includeStatus != null) url = url.concat("&includestatus=" + new Boolean(data.includeStatus).toString());
|
||||
if (data.includeMetadata != null) url = url.concat("&includemetadata=" + new Boolean(data.includeMetadata).toString());
|
||||
@@ -222,13 +245,15 @@ window.addEventListener("message", (event) => {
|
||||
if (data.identifier != null) url = url.concat("/" + data.identifier);
|
||||
url = url.concat("?");
|
||||
if (data.filepath != null) url = url.concat("&filepath=" + data.filepath);
|
||||
if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString())
|
||||
if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString());
|
||||
if (data.encoding != null) url = url.concat("&encoding=" + data.encoding);
|
||||
return httpGetAsyncWithEvent(event, url);
|
||||
|
||||
case "GET_QDN_RESOURCE_STATUS":
|
||||
url = "/arbitrary/resource/status/" + data.service + "/" + data.name;
|
||||
if (data.identifier != null) url = url.concat("/" + data.identifier);
|
||||
url = url.concat("?");
|
||||
if (data.build != null) url = url.concat("&build=" + new Boolean(data.build).toString());
|
||||
return httpGetAsyncWithEvent(event, url);
|
||||
|
||||
case "GET_QDN_RESOURCE_PROPERTIES":
|
||||
@@ -236,6 +261,11 @@ window.addEventListener("message", (event) => {
|
||||
url = "/arbitrary/resource/properties/" + data.service + "/" + data.name + "/" + identifier;
|
||||
return httpGetAsyncWithEvent(event, url);
|
||||
|
||||
case "GET_QDN_RESOURCE_METADATA":
|
||||
identifier = (data.identifier != null) ? data.identifier : "default";
|
||||
url = "/arbitrary/metadata/" + data.service + "/" + data.name + "/" + identifier;
|
||||
return httpGetAsyncWithEvent(event, url);
|
||||
|
||||
case "SEARCH_CHAT_MESSAGES":
|
||||
url = "/chat/messages?";
|
||||
if (data.before != null) url = url.concat("&before=" + data.before);
|
||||
@@ -245,6 +275,7 @@ window.addEventListener("message", (event) => {
|
||||
if (data.reference != null) url = url.concat("&reference=" + data.reference);
|
||||
if (data.chatReference != null) url = url.concat("&chatreference=" + data.chatReference);
|
||||
if (data.hasChatReference != null) url = url.concat("&haschatreference=" + new Boolean(data.hasChatReference).toString());
|
||||
if (data.encoding != null) url = url.concat("&encoding=" + data.encoding);
|
||||
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
||||
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
||||
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
||||
@@ -412,16 +443,22 @@ function getDefaultTimeout(action) {
|
||||
// Some actions need longer default timeouts, especially those that create transactions
|
||||
switch (action) {
|
||||
case "GET_USER_ACCOUNT":
|
||||
case "SAVE_FILE":
|
||||
case "DECRYPT_DATA":
|
||||
// User may take a long time to accept/deny the popup
|
||||
return 60 * 60 * 1000;
|
||||
|
||||
case "SEARCH_QDN_RESOURCES":
|
||||
// Searching for data can be slow, especially when metadata and statuses are also being included
|
||||
return 30 * 1000;
|
||||
|
||||
case "FETCH_QDN_RESOURCE":
|
||||
// Fetching data can take a while, especially if the status hasn't been checked first
|
||||
return 60 * 1000;
|
||||
|
||||
case "PUBLISH_QDN_RESOURCE":
|
||||
case "PUBLISH_MULTIPLE_QDN_RESOURCES":
|
||||
// Publishing could take a very long time on slow system, due to the proof-of-work computation
|
||||
// It's best not to timeout
|
||||
return 60 * 60 * 1000;
|
||||
|
||||
case "SEND_CHAT_MESSAGE":
|
||||
@@ -434,6 +471,10 @@ function getDefaultTimeout(action) {
|
||||
// Allow extra time for other actions that create transactions, even if there is no PoW
|
||||
return 5 * 60 * 1000;
|
||||
|
||||
case "GET_WALLET_BALANCE":
|
||||
// Getting a wallet balance can take a while, if there are many transactions
|
||||
return 2 * 60 * 1000;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@@ -212,7 +212,7 @@ public class BootstrapTests extends Common {
|
||||
@Test
|
||||
public void testBootstrapHosts() throws IOException {
|
||||
String[] bootstrapHosts = Settings.getInstance().getBootstrapHosts();
|
||||
String[] bootstrapTypes = { "archive", "toponly" };
|
||||
String[] bootstrapTypes = { "archive" }; // , "toponly"
|
||||
|
||||
for (String host : bootstrapHosts) {
|
||||
for (String type : bootstrapTypes) {
|
||||
|
@@ -37,8 +37,8 @@ public class NamesApiTests extends ApiCommon {
|
||||
|
||||
@Test
|
||||
public void testGetAllNames() {
|
||||
assertNotNull(this.namesResource.getAllNames(null, null, null));
|
||||
assertNotNull(this.namesResource.getAllNames(1, 1, true));
|
||||
assertNotNull(this.namesResource.getAllNames(null, null, null, null));
|
||||
assertNotNull(this.namesResource.getAllNames(1L, 1, 1, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@@ -20,7 +20,7 @@ public class ArbitraryDataFileTests extends Common {
|
||||
@Test
|
||||
public void testSplitAndJoin() throws DataException {
|
||||
String dummyDataString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
|
||||
ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(dummyDataString.getBytes(), null);
|
||||
ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(dummyDataString.getBytes(), null, false);
|
||||
assertTrue(arbitraryDataFile.exists());
|
||||
assertEquals(62, arbitraryDataFile.size());
|
||||
assertEquals("3eyjYjturyVe61grRX42bprGr3Cvw6ehTy4iknVnosDj", arbitraryDataFile.digest58());
|
||||
@@ -50,7 +50,7 @@ public class ArbitraryDataFileTests extends Common {
|
||||
byte[] randomData = new byte[fileSize];
|
||||
new Random().nextBytes(randomData); // No need for SecureRandom here
|
||||
|
||||
ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(randomData, null);
|
||||
ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(randomData, null, false);
|
||||
assertTrue(arbitraryDataFile.exists());
|
||||
assertEquals(fileSize, arbitraryDataFile.size());
|
||||
String originalFileDigest = arbitraryDataFile.digest58();
|
||||
|
@@ -113,13 +113,16 @@ public class ArbitraryDataStorageCapacityTests extends Common {
|
||||
assertTrue(resourceListManager.addToList("followedNames", "Test2", false));
|
||||
assertTrue(resourceListManager.addToList("followedNames", "Test3", false));
|
||||
assertTrue(resourceListManager.addToList("followedNames", "Test4", false));
|
||||
assertTrue(resourceListManager.addToList("followedNames", "Test5", false));
|
||||
assertTrue(resourceListManager.addToList("followedNames", "Test6", false));
|
||||
|
||||
// Ensure the followed name count is correct
|
||||
assertEquals(4, resourceListManager.getItemCountForList("followedNames"));
|
||||
assertEquals(4, ListUtils.followedNamesCount());
|
||||
assertEquals(6, resourceListManager.getItemCountForList("followedNames"));
|
||||
assertEquals(6, ListUtils.followedNamesCount());
|
||||
|
||||
// Storage space per name should be the total storage capacity divided by the number of names
|
||||
long expectedStorageCapacityPerName = (long)(totalStorageCapacity / 4.0f);
|
||||
// then multiplied by 4, to allow for names that don't use much space
|
||||
long expectedStorageCapacityPerName = (long)(totalStorageCapacity / 6.0f) * 4L;
|
||||
assertEquals(expectedStorageCapacityPerName, storageManager.storageCapacityPerName(storageFullThreshold));
|
||||
}
|
||||
|
||||
|
@@ -29,6 +29,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
@@ -436,4 +437,106 @@ public class ArbitraryServiceTests extends Common {
|
||||
assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidPrivateData() throws IOException {
|
||||
String dataString = "qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1rfla5UBoEcoxbtjgZ9G7FLPb8V/Qfr0bfKWfvMmN06U/pgUdLuv2mGL2V0D3qYd1011MUzGdNG1qERjaCDz8GAi63+KnHHjfMtPgYt6bcqjs4CNV+ZZ4dIt3xxHYyVEBNc=";
|
||||
|
||||
// Write the data a single file in a temp path
|
||||
Path path = Files.createTempDirectory("testValidPrivateData");
|
||||
Path filePath = Paths.get(path.toString(), "test");
|
||||
filePath.toFile().deleteOnExit();
|
||||
|
||||
BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile()));
|
||||
writer.write(dataString);
|
||||
writer.close();
|
||||
|
||||
Service service = Service.FILE_PRIVATE;
|
||||
assertTrue(service.isValidationRequired());
|
||||
|
||||
assertEquals(ValidationResult.OK, service.validate(filePath));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidPrivateGroupData() throws IOException {
|
||||
String dataString = "qortalGroupEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1rfla5UBoEcoxbtjgZ9G7FLPb8V/Qfr0bfKWfvMmN06U/pgUdLuv2mGL2V0D3qYd1011MUzGdNG1qERjaCDz8GAi63+KnHHjfMtPgYt6bcqjs4CNV+ZZ4dIt3xxHYyVEBNc=";
|
||||
|
||||
// Write the data a single file in a temp path
|
||||
Path path = Files.createTempDirectory("testValidPrivateData");
|
||||
Path filePath = Paths.get(path.toString(), "test");
|
||||
filePath.toFile().deleteOnExit();
|
||||
|
||||
BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile()));
|
||||
writer.write(dataString);
|
||||
writer.close();
|
||||
|
||||
Service service = Service.FILE_PRIVATE;
|
||||
assertTrue(service.isValidationRequired());
|
||||
|
||||
assertEquals(ValidationResult.OK, service.validate(filePath));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEncryptedData() throws IOException {
|
||||
String dataString = "qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1rfla5UBoEcoxbtjgZ9G7FLPb8V/Qfr0bfKWfvMmN06U/pgUdLuv2mGL2V0D3qYd1011MUzGdNG1qERjaCDz8GAi63+KnHHjfMtPgYt6bcqjs4CNV+ZZ4dIt3xxHYyVEBNc=";
|
||||
|
||||
// Write the data a single file in a temp path
|
||||
Path path = Files.createTempDirectory("testValidPrivateData");
|
||||
Path filePath = Paths.get(path.toString(), "test");
|
||||
filePath.toFile().deleteOnExit();
|
||||
|
||||
BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile()));
|
||||
writer.write(dataString);
|
||||
writer.close();
|
||||
|
||||
// Validate a private service
|
||||
Service service = Service.FILE_PRIVATE;
|
||||
assertTrue(service.isValidationRequired());
|
||||
assertEquals(ValidationResult.OK, service.validate(filePath));
|
||||
|
||||
// Validate a regular service
|
||||
service = Service.FILE;
|
||||
assertTrue(service.isValidationRequired());
|
||||
assertEquals(ValidationResult.DATA_ENCRYPTED, service.validate(filePath));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPlainTextData() throws IOException {
|
||||
String dataString = "plaintext";
|
||||
|
||||
// Write the data a single file in a temp path
|
||||
Path path = Files.createTempDirectory("testInvalidPrivateData");
|
||||
Path filePath = Paths.get(path.toString(), "test");
|
||||
filePath.toFile().deleteOnExit();
|
||||
|
||||
BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toFile()));
|
||||
writer.write(dataString);
|
||||
writer.close();
|
||||
|
||||
// Validate a private service
|
||||
Service service = Service.FILE_PRIVATE;
|
||||
assertTrue(service.isValidationRequired());
|
||||
assertEquals(ValidationResult.DATA_NOT_ENCRYPTED, service.validate(filePath));
|
||||
|
||||
// Validate a regular service
|
||||
service = Service.FILE;
|
||||
assertTrue(service.isValidationRequired());
|
||||
assertEquals(ValidationResult.OK, service.validate(filePath));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetPrivateServices() {
|
||||
List<Service> privateServices = Service.privateServices();
|
||||
for (Service service : privateServices) {
|
||||
assertTrue(service.isPrivate());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetPublicServices() {
|
||||
List<Service> publicServices = Service.publicServices();
|
||||
for (Service service : publicServices) {
|
||||
assertFalse(service.isPrivate());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -248,6 +248,47 @@ public class ArbitraryTransactionMetadataTests extends Common {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUTF8Metadata() 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.ARBITRARY_DATA;
|
||||
int chunkSize = 100;
|
||||
int dataLength = 900; // Actual data length will be longer due to encryption
|
||||
|
||||
// Example (modified) strings from real world content
|
||||
String title = "Доля юаня в трансграничных Доля юаня в трансграничных";
|
||||
String description = "Когда рыночек порешал";
|
||||
List<String> tags = Arrays.asList("Доля", "юаня", "трансграничных");
|
||||
Category category = Category.OTHER;
|
||||
|
||||
// Register the name to Alice
|
||||
RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, "");
|
||||
transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp()));
|
||||
TransactionUtils.signAndMint(repository, transactionData, alice);
|
||||
|
||||
// Create PUT transaction
|
||||
Path path1 = ArbitraryUtils.generateRandomDataPath(dataLength);
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryUtils.createAndMintTxn(repository, publicKey58, path1, name,
|
||||
identifier, ArbitraryTransactionData.Method.PUT, service, alice, chunkSize, 0L, true,
|
||||
title, description, tags, category);
|
||||
|
||||
// Check the chunk count is correct
|
||||
assertEquals(10, arbitraryDataFile.chunkCount());
|
||||
|
||||
// Check the metadata is correct
|
||||
String expectedTrimmedTitle = "Доля юаня в трансграничных Доля юаня в тран";
|
||||
assertEquals(expectedTrimmedTitle, arbitraryDataFile.getMetadata().getTitle());
|
||||
assertEquals(description, arbitraryDataFile.getMetadata().getDescription());
|
||||
assertEquals(tags, arbitraryDataFile.getMetadata().getTags());
|
||||
assertEquals(category, arbitraryDataFile.getMetadata().getCategory());
|
||||
assertEquals("text/plain", arbitraryDataFile.getMetadata().getMimeType());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMetadataLengths() throws DataException, IOException, MissingDataException {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
@@ -33,7 +33,7 @@ public class ArbitraryTestTransaction extends TestTransaction {
|
||||
final byte[] metadataHash = new byte[32];
|
||||
random.nextBytes(metadataHash);
|
||||
|
||||
byte[] data = new byte[1024];
|
||||
byte[] data = new byte[256];
|
||||
random.nextBytes(data);
|
||||
|
||||
DataType dataType = DataType.RAW_DATA;
|
||||
|
@@ -8,6 +8,7 @@ import org.bitcoinj.core.Transaction;
|
||||
import org.bitcoinj.store.BlockStoreException;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Ignore;
|
||||
import org.junit.Test;
|
||||
import org.qortal.crosschain.Bitcoin;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
@@ -32,6 +33,7 @@ public class BitcoinTests extends Common {
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("Often fails due to unreliable BTC testnet ElectrumX servers")
|
||||
public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException {
|
||||
System.out.println(String.format("Starting BTC instance..."));
|
||||
System.out.println(String.format("BTC instance started"));
|
||||
@@ -53,6 +55,7 @@ public class BitcoinTests extends Common {
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("Often fails due to unreliable BTC testnet ElectrumX servers")
|
||||
public void testFindHtlcSecret() throws ForeignBlockchainException {
|
||||
// This actually exists on TEST3 but can take a while to fetch
|
||||
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
||||
@@ -65,6 +68,7 @@ public class BitcoinTests extends Common {
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("Often fails due to unreliable BTC testnet ElectrumX servers")
|
||||
public void testBuildSpend() {
|
||||
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
||||
|
||||
@@ -81,6 +85,7 @@ public class BitcoinTests extends Common {
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("Often fails due to unreliable BTC testnet ElectrumX servers")
|
||||
public void testGetWalletBalance() throws ForeignBlockchainException {
|
||||
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
||||
|
||||
@@ -102,6 +107,7 @@ public class BitcoinTests extends Common {
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("Often fails due to unreliable BTC testnet ElectrumX servers")
|
||||
public void testGetUnusedReceiveAddress() throws ForeignBlockchainException {
|
||||
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
||||
|
||||
|
@@ -8,6 +8,7 @@ import org.junit.Ignore;
|
||||
import org.junit.Test;
|
||||
import org.qortal.crosschain.Bitcoin;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.Litecoin;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.crosschain.BitcoinyHTLC;
|
||||
import org.qortal.repository.DataException;
|
||||
@@ -18,17 +19,19 @@ import com.google.common.primitives.Longs;
|
||||
public class HtlcTests extends Common {
|
||||
|
||||
private Bitcoin bitcoin;
|
||||
private Litecoin litecoin;
|
||||
|
||||
@Before
|
||||
public void beforeTest() throws DataException {
|
||||
Common.useDefaultSettings(); // TestNet3
|
||||
bitcoin = Bitcoin.getInstance();
|
||||
litecoin = Litecoin.getInstance();
|
||||
}
|
||||
|
||||
@After
|
||||
public void afterTest() {
|
||||
Bitcoin.resetForTesting();
|
||||
bitcoin = null;
|
||||
litecoin = null;
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -52,12 +55,12 @@ public class HtlcTests extends Common {
|
||||
do {
|
||||
// We need to perform fresh setup for 1st test
|
||||
Bitcoin.resetForTesting();
|
||||
bitcoin = Bitcoin.getInstance();
|
||||
litecoin = Litecoin.getInstance();
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long timestampBoundary = now / 30_000L;
|
||||
|
||||
byte[] secret1 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress);
|
||||
byte[] secret1 = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress);
|
||||
long executionPeriod1 = System.currentTimeMillis() - now;
|
||||
|
||||
assertNotNull(secret1);
|
||||
@@ -65,7 +68,7 @@ public class HtlcTests extends Common {
|
||||
|
||||
assertTrue("1st execution period should not be instant!", executionPeriod1 > 10);
|
||||
|
||||
byte[] secret2 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress);
|
||||
byte[] secret2 = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress);
|
||||
long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1;
|
||||
|
||||
assertNotNull(secret2);
|
||||
@@ -86,7 +89,7 @@ public class HtlcTests extends Common {
|
||||
// This actually exists on TEST3 but can take a while to fetch
|
||||
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
||||
|
||||
BitcoinyHTLC.Status htlcStatus = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L);
|
||||
BitcoinyHTLC.Status htlcStatus = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddress, 1L);
|
||||
assertNotNull(htlcStatus);
|
||||
|
||||
System.out.println(String.format("HTLC %s status: %s", p2shAddress, htlcStatus.name()));
|
||||
@@ -97,21 +100,21 @@ public class HtlcTests extends Common {
|
||||
do {
|
||||
// We need to perform fresh setup for 1st test
|
||||
Bitcoin.resetForTesting();
|
||||
bitcoin = Bitcoin.getInstance();
|
||||
litecoin = Litecoin.getInstance();
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long timestampBoundary = now / 30_000L;
|
||||
|
||||
// Won't ever exist
|
||||
String p2shAddress = bitcoin.deriveP2shAddress(Crypto.hash160(Longs.toByteArray(now)));
|
||||
String p2shAddress = litecoin.deriveP2shAddress(Crypto.hash160(Longs.toByteArray(now)));
|
||||
|
||||
BitcoinyHTLC.Status htlcStatus1 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L);
|
||||
BitcoinyHTLC.Status htlcStatus1 = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddress, 1L);
|
||||
long executionPeriod1 = System.currentTimeMillis() - now;
|
||||
|
||||
assertNotNull(htlcStatus1);
|
||||
assertTrue("1st execution period should not be instant!", executionPeriod1 > 10);
|
||||
|
||||
BitcoinyHTLC.Status htlcStatus2 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L);
|
||||
BitcoinyHTLC.Status htlcStatus2 = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddress, 1L);
|
||||
long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1;
|
||||
|
||||
assertNotNull(htlcStatus2);
|
||||
|
@@ -5,7 +5,6 @@ import static org.junit.Assert.*;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.bitcoinj.core.Transaction;
|
||||
import org.bitcoinj.store.BlockStoreException;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Ignore;
|
||||
@@ -33,12 +32,12 @@ public class LitecoinTests extends Common {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException {
|
||||
public void testGetMedianBlockTime() throws ForeignBlockchainException {
|
||||
long before = System.currentTimeMillis();
|
||||
System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime()));
|
||||
System.out.println(String.format("Litecoin median blocktime: %d", litecoin.getMedianBlockTime()));
|
||||
long afterFirst = System.currentTimeMillis();
|
||||
|
||||
System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime()));
|
||||
System.out.println(String.format("Litecoin median blocktime: %d", litecoin.getMedianBlockTime()));
|
||||
long afterSecond = System.currentTimeMillis();
|
||||
|
||||
long firstPeriod = afterFirst - before;
|
||||
|
@@ -51,89 +51,6 @@ public class OnlineAccountsTests extends Common {
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testGetOnlineAccountsV2() throws MessageException {
|
||||
List<OnlineAccountData> onlineAccountsOut = generateOnlineAccounts(false);
|
||||
|
||||
Message messageOut = new GetOnlineAccountsV2Message(onlineAccountsOut);
|
||||
|
||||
byte[] messageBytes = messageOut.toBytes();
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap(messageBytes);
|
||||
|
||||
GetOnlineAccountsV2Message messageIn = (GetOnlineAccountsV2Message) Message.fromByteBuffer(byteBuffer);
|
||||
|
||||
List<OnlineAccountData> onlineAccountsIn = messageIn.getOnlineAccounts();
|
||||
|
||||
assertEquals("size mismatch", onlineAccountsOut.size(), onlineAccountsIn.size());
|
||||
assertTrue("accounts mismatch", onlineAccountsIn.containsAll(onlineAccountsOut));
|
||||
|
||||
Message oldMessageOut = new GetOnlineAccountsMessage(onlineAccountsOut);
|
||||
byte[] oldMessageBytes = oldMessageOut.toBytes();
|
||||
|
||||
long numTimestamps = onlineAccountsOut.stream().mapToLong(OnlineAccountData::getTimestamp).sorted().distinct().count();
|
||||
|
||||
System.out.println(String.format("For %d accounts split across %d timestamp%s: old size %d vs new size %d",
|
||||
onlineAccountsOut.size(),
|
||||
numTimestamps,
|
||||
numTimestamps != 1 ? "s" : "",
|
||||
oldMessageBytes.length,
|
||||
messageBytes.length));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOnlineAccountsV2() throws MessageException {
|
||||
List<OnlineAccountData> onlineAccountsOut = generateOnlineAccounts(true);
|
||||
|
||||
Message messageOut = new OnlineAccountsV2Message(onlineAccountsOut);
|
||||
|
||||
byte[] messageBytes = messageOut.toBytes();
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap(messageBytes);
|
||||
|
||||
OnlineAccountsV2Message messageIn = (OnlineAccountsV2Message) Message.fromByteBuffer(byteBuffer);
|
||||
|
||||
List<OnlineAccountData> onlineAccountsIn = messageIn.getOnlineAccounts();
|
||||
|
||||
assertEquals("size mismatch", onlineAccountsOut.size(), onlineAccountsIn.size());
|
||||
assertTrue("accounts mismatch", onlineAccountsIn.containsAll(onlineAccountsOut));
|
||||
|
||||
Message oldMessageOut = new OnlineAccountsMessage(onlineAccountsOut);
|
||||
byte[] oldMessageBytes = oldMessageOut.toBytes();
|
||||
|
||||
long numTimestamps = onlineAccountsOut.stream().mapToLong(OnlineAccountData::getTimestamp).sorted().distinct().count();
|
||||
|
||||
System.out.println(String.format("For %d accounts split across %d timestamp%s: old size %d vs new size %d",
|
||||
onlineAccountsOut.size(),
|
||||
numTimestamps,
|
||||
numTimestamps != 1 ? "s" : "",
|
||||
oldMessageBytes.length,
|
||||
messageBytes.length));
|
||||
}
|
||||
|
||||
private List<OnlineAccountData> generateOnlineAccounts(boolean withSignatures) {
|
||||
List<OnlineAccountData> onlineAccounts = new ArrayList<>();
|
||||
|
||||
int numTimestamps = RANDOM.nextInt(2) + 1; // 1 or 2
|
||||
|
||||
for (int t = 0; t < numTimestamps; ++t) {
|
||||
int numAccounts = RANDOM.nextInt(3000);
|
||||
|
||||
for (int a = 0; a < numAccounts; ++a) {
|
||||
byte[] sig = null;
|
||||
if (withSignatures) {
|
||||
sig = new byte[Transformer.SIGNATURE_LENGTH];
|
||||
RANDOM.nextBytes(sig);
|
||||
}
|
||||
|
||||
byte[] pubkey = new byte[Transformer.PUBLIC_KEY_LENGTH];
|
||||
RANDOM.nextBytes(pubkey);
|
||||
|
||||
onlineAccounts.add(new OnlineAccountData(t << 32, sig, pubkey));
|
||||
}
|
||||
}
|
||||
|
||||
return onlineAccounts;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOnlineAccountsModulusV1() throws IllegalAccessException, DataException {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
@@ -26,41 +26,6 @@ public class OnlineAccountsV3Tests {
|
||||
Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
|
||||
}
|
||||
|
||||
@Ignore("For informational use")
|
||||
@Test
|
||||
public void compareV2ToV3() throws MessageException {
|
||||
List<OnlineAccountData> onlineAccounts = generateOnlineAccounts(false);
|
||||
|
||||
// How many of each timestamp and leading byte (of public key)
|
||||
Map<Long, Map<Byte, byte[]>> hashesByTimestampThenByte = convertToHashMaps(onlineAccounts);
|
||||
|
||||
byte[] v3DataBytes = new GetOnlineAccountsV3Message(hashesByTimestampThenByte).toBytes();
|
||||
int v3ByteSize = v3DataBytes.length;
|
||||
|
||||
byte[] v2DataBytes = new GetOnlineAccountsV2Message(onlineAccounts).toBytes();
|
||||
int v2ByteSize = v2DataBytes.length;
|
||||
|
||||
int numTimestamps = hashesByTimestampThenByte.size();
|
||||
System.out.printf("For %d accounts split across %d timestamp%s: V2 size %d vs V3 size %d%n",
|
||||
onlineAccounts.size(),
|
||||
numTimestamps,
|
||||
numTimestamps != 1 ? "s" : "",
|
||||
v2ByteSize,
|
||||
v3ByteSize
|
||||
);
|
||||
|
||||
for (var outerMapEntry : hashesByTimestampThenByte.entrySet()) {
|
||||
long timestamp = outerMapEntry.getKey();
|
||||
|
||||
var innerMap = outerMapEntry.getValue();
|
||||
|
||||
System.out.printf("For timestamp %d: %d / 256 slots used.%n",
|
||||
timestamp,
|
||||
innerMap.size()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, Map<Byte, byte[]>> convertToHashMaps(List<OnlineAccountData> onlineAccounts) {
|
||||
// How many of each timestamp and leading byte (of public key)
|
||||
Map<Long, Map<Byte, byte[]>> hashesByTimestampThenByte = new HashMap<>();
|
||||
@@ -200,7 +165,9 @@ public class OnlineAccountsV3Tests {
|
||||
byte[] pubkey = new byte[Transformer.PUBLIC_KEY_LENGTH];
|
||||
RANDOM.nextBytes(pubkey);
|
||||
|
||||
onlineAccounts.add(new OnlineAccountData(timestamp, sig, pubkey));
|
||||
Integer nonce = RANDOM.nextInt();
|
||||
|
||||
onlineAccounts.add(new OnlineAccountData(timestamp, sig, pubkey, nonce));
|
||||
}
|
||||
}
|
||||
|
||||
|
3
start.sh
3
start.sh
@@ -33,7 +33,8 @@ fi
|
||||
# Limits Java JVM stack size and maximum heap usage.
|
||||
# Comment out for bigger systems, e.g. non-routers
|
||||
# or when API documentation is enabled
|
||||
# JVM_MEMORY_ARGS="-Xss256k -Xmx128m"
|
||||
# Uncomment (remove '#' sign) line below if your system has less than 12GB of RAM for optimal RAM defaults
|
||||
# JVM_MEMORY_ARGS="-Xss1256k -Xmx3128m"
|
||||
|
||||
# Although java.net.preferIPv4Stack is supposed to be false
|
||||
# by default in Java 11, on some platforms (e.g. FreeBSD 12),
|
||||
|
180
tools/tx.pl
180
tools/tx.pl
@@ -1,16 +1,23 @@
|
||||
#!/usr/bin/env perl
|
||||
|
||||
# v4.0.2
|
||||
|
||||
use JSON;
|
||||
use warnings;
|
||||
use strict;
|
||||
|
||||
use Getopt::Std;
|
||||
use File::Basename;
|
||||
use Digest::SHA qw( sha256 sha256_hex );
|
||||
use Crypt::RIPEMD160;
|
||||
|
||||
our %opt;
|
||||
getopts('dpst', \%opt);
|
||||
|
||||
my $proc = basename($0);
|
||||
my $dirname = dirname($0);
|
||||
my $OPENSSL_SIGN = "${dirname}/openssl-sign.sh";
|
||||
my $OPENSSL_PRIV_TO_PUB = index(`$ENV{SHELL} -i -c 'openssl version;exit' 2>/dev/null`, 'OpenSSL 3.') != -1;
|
||||
|
||||
if (@ARGV < 1) {
|
||||
print STDERR "usage: $proc [-d] [-p] [-s] [-t] <tx-type> <privkey> <values> [<key-value pairs>]\n";
|
||||
@@ -24,7 +31,15 @@ if (@ARGV < 1) {
|
||||
exit 2;
|
||||
}
|
||||
|
||||
our $BASE_URL = $ENV{BASE_URL} || $opt{t} ? 'http://localhost:62391' : 'http://localhost:12391';
|
||||
our @b58 = qw{
|
||||
1 2 3 4 5 6 7 8 9
|
||||
A B C D E F G H J K L M N P Q R S T U V W X Y Z
|
||||
a b c d e f g h i j k m n o p q r s t u v w x y z
|
||||
};
|
||||
our %b58 = map { $b58[$_] => $_ } 0 .. 57;
|
||||
our %reverseb58 = reverse %b58;
|
||||
|
||||
our $BASE_URL = $ENV{BASE_URL} || ($opt{t} ? 'http://localhost:62391' : 'http://localhost:12391');
|
||||
our $DEFAULT_FEE = 0.001;
|
||||
|
||||
our %TRANSACTION_TYPES = (
|
||||
@@ -42,6 +57,7 @@ our %TRANSACTION_TYPES = (
|
||||
create_group => {
|
||||
url => 'groups/create',
|
||||
required => [qw(groupName description isOpen approvalThreshold)],
|
||||
defaults => { minimumBlockDelay => 10, maximumBlockDelay => 30 },
|
||||
key_name => 'creatorPublicKey',
|
||||
},
|
||||
update_group => {
|
||||
@@ -75,10 +91,10 @@ our %TRANSACTION_TYPES = (
|
||||
key_name => 'ownerPublicKey',
|
||||
},
|
||||
remove_group_admin => {
|
||||
url => 'groups/removeadmin',
|
||||
required => [qw(groupId txGroupId admin)],
|
||||
key_name => 'ownerPublicKey',
|
||||
},
|
||||
url => 'groups/removeadmin',
|
||||
required => [qw(groupId txGroupId member)],
|
||||
key_name => 'ownerPublicKey',
|
||||
},
|
||||
group_approval => {
|
||||
url => 'groups/approval',
|
||||
required => [qw(pendingSignature approval)],
|
||||
@@ -113,7 +129,7 @@ our %TRANSACTION_TYPES = (
|
||||
},
|
||||
update_name => {
|
||||
url => 'names/update',
|
||||
required => [qw(newName newData)],
|
||||
required => [qw(name newName newData)],
|
||||
key_name => 'ownerPublicKey',
|
||||
},
|
||||
# reward-shares
|
||||
@@ -144,13 +160,21 @@ our %TRANSACTION_TYPES = (
|
||||
key_name => 'senderPublicKey',
|
||||
pow_url => 'addresses/publicize/compute',
|
||||
},
|
||||
# Cross-chain trading
|
||||
build_trade => {
|
||||
url => 'crosschain/build',
|
||||
required => [qw(initialQortAmount finalQortAmount fundingQortAmount secretHash bitcoinAmount)],
|
||||
optional => [qw(tradeTimeout)],
|
||||
# AT
|
||||
deploy_at => {
|
||||
url => 'at',
|
||||
required => [qw(name description aTType tags creationBytes amount)],
|
||||
optional => [qw(assetId)],
|
||||
key_name => 'creatorPublicKey',
|
||||
defaults => { tradeTimeout => 10800 },
|
||||
defaults => { assetId => 0 },
|
||||
},
|
||||
# Cross-chain trading
|
||||
create_trade => {
|
||||
url => 'crosschain/tradebot/create',
|
||||
required => [qw(qortAmount fundingQortAmount foreignAmount receivingAddress)],
|
||||
optional => [qw(tradeTimeout foreignBlockchain)],
|
||||
key_name => 'creatorPublicKey',
|
||||
defaults => { tradeTimeout => 1440, foreignBlockchain => 'LITECOIN' },
|
||||
},
|
||||
trade_recipient => {
|
||||
url => 'crosschain/tradeoffer/recipient',
|
||||
@@ -196,7 +220,7 @@ if (@ARGV < @required + 1) {
|
||||
|
||||
my $priv_key = shift @ARGV;
|
||||
|
||||
my $account = account($priv_key);
|
||||
my $account;
|
||||
my $raw;
|
||||
|
||||
if ($tx_type ne 'sign') {
|
||||
@@ -215,6 +239,8 @@ if ($tx_type ne 'sign') {
|
||||
|
||||
%extras = (%extras, @ARGV);
|
||||
|
||||
$account = account($priv_key, %extras);
|
||||
|
||||
$raw = build_raw($tx_type, $account, %extras);
|
||||
printf "Raw: %s\n", $raw if $opt{d} || (!$opt{s} && !$opt{p});
|
||||
|
||||
@@ -229,7 +255,7 @@ if ($tx_type ne 'sign') {
|
||||
}
|
||||
|
||||
if ($opt{s}) {
|
||||
my $signed = sign($account->{private}, $raw);
|
||||
my $signed = sign($priv_key, $raw);
|
||||
printf "Signed: %s\n", $signed if $opt{d} || $tx_type eq 'sign';
|
||||
|
||||
if ($opt{p}) {
|
||||
@@ -246,15 +272,25 @@ if ($opt{s}) {
|
||||
}
|
||||
|
||||
sub account {
|
||||
my ($creator) = @_;
|
||||
my ($privkey, %extras) = @_;
|
||||
|
||||
my $account = { private => $creator };
|
||||
$account->{public} = api('utils/publickey', $creator);
|
||||
$account->{address} = api('addresses/convert/{publickey}', '', '{publickey}', $account->{public});
|
||||
my $account = { private => $privkey };
|
||||
$account->{public} = $extras{publickey} || priv_to_pub($privkey);
|
||||
$account->{address} = $extras{address} || pubkey_to_address($account->{public}); # api('addresses/convert/{publickey}', '', '{publickey}', $account->{public});
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
sub priv_to_pub {
|
||||
my ($privkey) = @_;
|
||||
|
||||
if ($OPENSSL_PRIV_TO_PUB) {
|
||||
return openssl_priv_to_pub($privkey);
|
||||
} else {
|
||||
return api('utils/publickey', $privkey);
|
||||
}
|
||||
}
|
||||
|
||||
sub build_raw {
|
||||
my ($type, $account, %extras) = @_;
|
||||
|
||||
@@ -306,6 +342,21 @@ sub build_raw {
|
||||
sub sign {
|
||||
my ($private, $raw) = @_;
|
||||
|
||||
if (-x "$OPENSSL_SIGN") {
|
||||
my $private_hex = decode_base58($private);
|
||||
chomp $private_hex;
|
||||
|
||||
my $raw_hex = decode_base58($raw);
|
||||
chomp $raw_hex;
|
||||
|
||||
my $sig = `${OPENSSL_SIGN} ${private_hex} ${raw_hex}`;
|
||||
chomp $sig;
|
||||
|
||||
my $sig58 = encode_base58(${raw_hex} . ${sig});
|
||||
chomp $sig58;
|
||||
return $sig58;
|
||||
}
|
||||
|
||||
my $json = <<" __JSON__";
|
||||
{
|
||||
"privateKey": "$private",
|
||||
@@ -344,7 +395,14 @@ sub api {
|
||||
my $curl = "curl --silent --output - --url '$BASE_URL/$url'";
|
||||
if (defined $postdata && $postdata ne '') {
|
||||
$postdata =~ tr|\n| |s;
|
||||
$curl .= " --header 'Content-Type: application/json' --data-binary '$postdata'";
|
||||
|
||||
if ($postdata =~ /^\s*\{/so) {
|
||||
$curl .= " --header 'Content-Type: application/json'";
|
||||
} else {
|
||||
$curl .= " --header 'Content-Type: text/plain'";
|
||||
}
|
||||
|
||||
$curl .= " --data-binary '$postdata'";
|
||||
$method = 'POST';
|
||||
}
|
||||
my $response = `$curl 2>/dev/null`;
|
||||
@@ -356,3 +414,87 @@ sub api {
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
sub encode_base58 {
|
||||
use integer;
|
||||
my @in = map { hex($_) } ($_[0] =~ /(..)/g);
|
||||
my $bzeros = length($1) if join('', @in) =~ /^(0*)/;
|
||||
my @out;
|
||||
my $size = 2 * scalar @in;
|
||||
for my $c (@in) {
|
||||
for (my $j = $size; $j--; ) {
|
||||
$c += 256 * ($out[$j] // 0);
|
||||
$out[$j] = $c % 58;
|
||||
$c /= 58;
|
||||
}
|
||||
}
|
||||
my $out = join('', map { $reverseb58{$_} } @out);
|
||||
return $1 if $out =~ /(1{$bzeros}[^1].*)/;
|
||||
return $1 if $out =~ /(1{$bzeros})/;
|
||||
die "Invalid base58!\n";
|
||||
}
|
||||
|
||||
|
||||
sub decode_base58 {
|
||||
use integer;
|
||||
my @out;
|
||||
my $azeros = length($1) if $_[0] =~ /^(1*)/;
|
||||
for my $c ( map { $b58{$_} } $_[0] =~ /./g ) {
|
||||
die("Invalid character!\n") unless defined $c;
|
||||
for (my $j = length($_[0]); $j--; ) {
|
||||
$c += 58 * ($out[$j] // 0);
|
||||
$out[$j] = $c % 256;
|
||||
$c /= 256;
|
||||
}
|
||||
}
|
||||
shift @out while @out && $out[0] == 0;
|
||||
unshift(@out, (0) x $azeros);
|
||||
return sprintf('%02x' x @out, @out);
|
||||
}
|
||||
|
||||
sub openssl_priv_to_pub {
|
||||
my ($privkey) = @_;
|
||||
|
||||
my $privkey_hex = decode_base58($privkey);
|
||||
|
||||
my $key_type = "04"; # hex
|
||||
my $length = "20"; # hex
|
||||
|
||||
my $asn1 = <<"__ASN1__";
|
||||
asn1=SEQUENCE:private_key
|
||||
|
||||
[private_key]
|
||||
version=INTEGER:0
|
||||
included=SEQUENCE:key_info
|
||||
raw=FORMAT:HEX,OCTETSTRING:${key_type}${length}${privkey_hex}
|
||||
|
||||
[key_info]
|
||||
type=OBJECT:ED25519
|
||||
|
||||
__ASN1__
|
||||
|
||||
my $output = `echo "${asn1}" | openssl asn1parse -i -genconf - -out - | openssl pkey -in - -inform der -noout -text_pub`;
|
||||
|
||||
# remove colons
|
||||
my $pubkey = '';
|
||||
$pubkey .= $1 while $output =~ m/([0-9a-f]{2})(?::|$)/g;
|
||||
|
||||
return encode_base58($pubkey);
|
||||
}
|
||||
|
||||
sub pubkey_to_address {
|
||||
my ($pubkey) = @_;
|
||||
|
||||
my $pubkey_hex = decode_base58($pubkey);
|
||||
my $pubkey_raw = pack('H*', $pubkey_hex);
|
||||
|
||||
my $pkh_hex = Crypt::RIPEMD160->hexhash(sha256($pubkey_raw));
|
||||
$pkh_hex =~ tr/ //ds;
|
||||
|
||||
my $version = '3a'; # hex
|
||||
|
||||
my $raw = pack('H*', $version . $pkh_hex);
|
||||
my $chksum = substr(sha256_hex(sha256($raw)), 0, 8);
|
||||
|
||||
return encode_base58($version . $pkh_hex . $chksum);
|
||||
}
|
||||
|
Reference in New Issue
Block a user