forked from Qortal/qortal
Compare commits
382 Commits
trade-bot-
...
bootstrap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb3528aa5 | ||
|
|
4f892835b8 | ||
|
|
ac49221639 | ||
|
|
75ed5db3e4 | ||
|
|
59c8e4e6a2 | ||
|
|
52b322b756 | ||
|
|
dc876d9c96 | ||
|
|
5b028428c4 | ||
|
|
f67a0469fc | ||
|
|
494cd0efff | ||
|
|
fc8e38e862 | ||
|
|
f09fb5a209 | ||
|
|
b00c1c1575 | ||
|
|
7e5dd62a92 | ||
|
|
35718f6215 | ||
|
|
a6d3891a95 | ||
|
|
9591c4eb58 | ||
|
|
8aaf720b0b | ||
|
|
63a35c97bc | ||
|
|
8eddaa3fac | ||
|
|
1b3f37eb78 | ||
|
|
1f8fbfaa24 | ||
|
|
ea92ccb4c1 | ||
|
|
d25a77b633 | ||
|
|
51bb776e56 | ||
|
|
47b1b6daba | ||
|
|
adeb654248 | ||
|
|
c4d7335fdd | ||
|
|
ca7f42c409 | ||
|
|
ca02cd72ae | ||
|
|
1ba542eb50 | ||
|
|
53cd967541 | ||
|
|
49749a0bc7 | ||
|
|
446f924380 | ||
|
|
5b231170cd | ||
|
|
7375357b11 | ||
|
|
347d799d85 | ||
|
|
0d17f02191 | ||
|
|
ce5bc80347 | ||
|
|
0a4479fe9e | ||
|
|
de8e96cd75 | ||
|
|
e2a62f88a6 | ||
|
|
8926d2a73c | ||
|
|
114833cf8e | ||
|
|
32227436e0 | ||
|
|
28ff5636af | ||
|
|
656896d16f | ||
|
|
19bf8afece | ||
|
|
841b6c4ddf | ||
|
|
4c171df848 | ||
|
|
1f79d88840 | ||
|
|
6ee7e9d731 | ||
|
|
4856223838 | ||
|
|
74ea2a847d | ||
|
|
9813dde3d9 | ||
|
|
fea7b62b9c | ||
|
|
37e03bf2bb | ||
|
|
5656de79a2 | ||
|
|
70c6048cc1 | ||
|
|
87595fd704 | ||
|
|
dc030a42bb | ||
|
|
89283ed179 | ||
|
|
64e8a05a9f | ||
|
|
676320586a | ||
|
|
734fa51806 | ||
|
|
f056ecc8d8 | ||
|
|
1a722c1517 | ||
|
|
44607ba6a4 | ||
|
|
01d66212da | ||
|
|
925e10b19b | ||
|
|
1b4c75a76e | ||
|
|
3400e36ac4 | ||
|
|
78e2ae4f36 | ||
|
|
957944f6a5 | ||
|
|
9eab500e2c | ||
|
|
573f4675a1 | ||
|
|
e6bde3e1f4 | ||
|
|
5869174021 | ||
|
|
449761b6ca | ||
|
|
39d5ce19e2 | ||
|
|
3b156bc5c9 | ||
|
|
a4f5124b61 | ||
|
|
47a34c2f54 | ||
|
|
8a7446fb40 | ||
|
|
705e7d1cf1 | ||
|
|
44a90b4e12 | ||
|
|
54e5a65cf0 | ||
|
|
06a2c380bd | ||
|
|
33ac1fed2a | ||
|
|
cc65a7cd11 | ||
|
|
d600a54034 | ||
|
|
ba06225b01 | ||
|
|
ce60ab8e00 | ||
|
|
14f6fd19ef | ||
|
|
1d8351f921 | ||
|
|
6a55b052f5 | ||
|
|
2a36b83dea | ||
|
|
14acc4feb9 | ||
|
|
0657ca2969 | ||
|
|
e90c3a78d1 | ||
|
|
63c9bc5c1c | ||
|
|
a6bbc81962 | ||
|
|
b800fb5846 | ||
|
|
172a629da3 | ||
|
|
6d1f7b36a7 | ||
|
|
673ee4aeed | ||
|
|
25b787f6f2 | ||
|
|
6b74ef77e6 | ||
|
|
278201e87c | ||
|
|
703cdfe174 | ||
|
|
02988989ad | ||
|
|
25c17d3704 | ||
|
|
9973fe4326 | ||
|
|
2479f2d65d | ||
|
|
9056cb7026 | ||
|
|
cd9d9b31ef | ||
|
|
ff841c28e3 | ||
|
|
ca1379d9f8 | ||
|
|
5127f94423 | ||
|
|
f5910ab950 | ||
|
|
22efaccd4a | ||
|
|
c8466a2e7a | ||
|
|
209a9fa8c3 | ||
|
|
bc1af12655 | ||
|
|
e7e4cb7579 | ||
|
|
1b39db664c | ||
|
|
b4f980b349 | ||
|
|
673f23b6a0 | ||
|
|
8c325f3a8a | ||
|
|
f71516f36f | ||
|
|
1752386a6c | ||
|
|
112675c782 | ||
|
|
3b6ba7641d | ||
|
|
477a35a685 | ||
|
|
2a0a39a95a | ||
|
|
dfc77db51d | ||
|
|
c9596fd8c4 | ||
|
|
78373f3746 | ||
|
|
ebc3db8aed | ||
|
|
756601c1ce | ||
|
|
8bb5077e76 | ||
|
|
5b85f01427 | ||
|
|
a7d594e566 | ||
|
|
481e6671c2 | ||
|
|
b890e02a6a | ||
|
|
4772840b4c | ||
|
|
cd7adc997b | ||
|
|
9fdc901b7a | ||
|
|
76ec3473d6 | ||
|
|
b29ae67501 | ||
|
|
24f1fb566d | ||
|
|
a253294890 | ||
|
|
0b53de1bb6 | ||
|
|
746c68c9f6 | ||
|
|
ec008b4a16 | ||
|
|
1d65e34fe5 | ||
|
|
8ae78703ca | ||
|
|
bd4b9a9fd3 | ||
|
|
f09677d376 | ||
|
|
f669e3f6c4 | ||
|
|
961c5ea962 | ||
|
|
a1c61a1146 | ||
|
|
797dff4752 | ||
|
|
711ad638b8 | ||
|
|
4956c3328c | ||
|
|
96a82381d1 | ||
|
|
68190c8c76 | ||
|
|
dde47bc1fc | ||
|
|
744deaed8d | ||
|
|
a62910c8b6 | ||
|
|
3c6d9a4b8e | ||
|
|
3073388403 | ||
|
|
67f856c997 | ||
|
|
742fd0b444 | ||
|
|
e1d69c0eae | ||
|
|
49d4190615 | ||
|
|
64d39765ca | ||
|
|
aca8f64415 | ||
|
|
855b600268 | ||
|
|
476d613e20 | ||
|
|
fb8a4d0a41 | ||
|
|
130f3f6d41 | ||
|
|
ed997af043 | ||
|
|
3c47f6917a | ||
|
|
e32a486493 | ||
|
|
208da935a1 | ||
|
|
1dda9a875e | ||
|
|
b26175b7c6 | ||
|
|
ffc6befb38 | ||
|
|
9df7c96d08 | ||
|
|
32fa66f0a2 | ||
|
|
7153ed022c | ||
|
|
50e4e71abb | ||
|
|
d6e65a3d63 | ||
|
|
79691541ae | ||
|
|
05d0542875 | ||
|
|
1d22b39a1d | ||
|
|
549b68cf71 | ||
|
|
55f87de2e0 | ||
|
|
b8424e20aa | ||
|
|
bbe3a30e77 | ||
|
|
39d8750ef9 | ||
|
|
52b0c244a8 | ||
|
|
ee95a00ce2 | ||
|
|
11566ec923 | ||
|
|
a78ff08202 | ||
|
|
ceb3969c8b | ||
|
|
6f048ef40e | ||
|
|
aff4f6c859 | ||
|
|
1f8f73fa30 | ||
|
|
620d6624a9 | ||
|
|
287f42ae64 | ||
|
|
d976c97d13 | ||
|
|
6d549b0754 | ||
|
|
02dd64558f | ||
|
|
d25e98d9c4 | ||
|
|
227cdc1ec8 | ||
|
|
2c585a9328 | ||
|
|
45b0d9e19b | ||
|
|
026a4b896c | ||
|
|
78237fcd11 | ||
|
|
73cc3dcb92 | ||
|
|
4cff03e7fe | ||
|
|
280f7814aa | ||
|
|
3174681bd8 | ||
|
|
853f80b928 | ||
|
|
8bdad377d7 | ||
|
|
9e1c2a5bd1 | ||
|
|
b1777b6011 | ||
|
|
e3923b7b22 | ||
|
|
a43993e3ec | ||
|
|
319e64bacc | ||
|
|
ecf044bed1 | ||
|
|
76e1de38e8 | ||
|
|
1648a74ed7 | ||
|
|
c443187d0b | ||
|
|
8c305d8390 | ||
|
|
0345c5c03b | ||
|
|
cc6ac4c9d9 | ||
|
|
815934ff5c | ||
|
|
5a84016a91 | ||
|
|
bb0269f484 | ||
|
|
1adc9349fc | ||
|
|
06215c83f2 | ||
|
|
8a828137ee | ||
|
|
de4b1c8f09 | ||
|
|
265d40f04a | ||
|
|
b64e52c0c0 | ||
|
|
ac02e5c0a6 | ||
|
|
427a415fbf | ||
|
|
9a3414aaa7 | ||
|
|
c8897ecf9b | ||
|
|
2c8b94d469 | ||
|
|
36c1cfae51 | ||
|
|
41ad78750e | ||
|
|
3eaa4d5b38 | ||
|
|
35176f9550 | ||
|
|
eb2c7268ea | ||
|
|
80311355ae | ||
|
|
39d1590ace | ||
|
|
0b36b650a4 | ||
|
|
39575e8542 | ||
|
|
326ef498b0 | ||
|
|
5148bad82e | ||
|
|
518f02472f | ||
|
|
ee5a132eb2 | ||
|
|
654dc5bff3 | ||
|
|
13dcf7f72a | ||
|
|
65c26f17df | ||
|
|
3bedba71d5 | ||
|
|
1ba64d9745 | ||
|
|
84bf570243 | ||
|
|
28d50bccf9 | ||
|
|
66711c2e9d | ||
|
|
92d8c37d7d | ||
|
|
5824f75669 | ||
|
|
deb8adafc9 | ||
|
|
d2649b237c | ||
|
|
6532c258f6 | ||
|
|
83e2b10904 | ||
|
|
26c1793d85 | ||
|
|
23a9eea26b | ||
|
|
af9b536dd9 | ||
|
|
e4874f86f9 | ||
|
|
e300a957e4 | ||
|
|
1c38afcd25 | ||
|
|
a06faa7685 | ||
|
|
019ab2b21d | ||
|
|
f6ba5f5d51 | ||
|
|
c4cbb64643 | ||
|
|
8260cec713 | ||
|
|
f4520e2752 | ||
|
|
475802afbc | ||
|
|
a170668d9d | ||
|
|
f8dac39076 | ||
|
|
fe4ae61552 | ||
|
|
0c3597f757 | ||
|
|
6109bdeafe | ||
|
|
6e9a61c4e5 | ||
|
|
8e244fd956 | ||
|
|
2eb6771963 | ||
|
|
db77108054 | ||
|
|
241e2bef85 | ||
|
|
fac02dbc7d | ||
|
|
9ebcd55ff5 | ||
|
|
50244c1c40 | ||
|
|
b4395fdad1 | ||
|
|
1da8994be7 | ||
|
|
55ff1e2bb1 | ||
|
|
5fd8528c49 | ||
|
|
26d8ed783a | ||
|
|
c0c5bf1591 | ||
|
|
c17a481b74 | ||
|
|
a9a0e69ec0 | ||
|
|
ea1fed2fd3 | ||
|
|
b37f2c7d7f | ||
|
|
0c0c5ff077 | ||
|
|
e12b99d17e | ||
|
|
d599146c3a | ||
|
|
476731a2c3 | ||
|
|
1e491dd8fb | ||
|
|
ba6397b963 | ||
|
|
3146da6aec | ||
|
|
5643e57ede | ||
|
|
f532dbe7b4 | ||
|
|
ec2af62b4d | ||
|
|
423142d730 | ||
|
|
bdddb526da | ||
|
|
dbf1ed40b3 | ||
|
|
02ace06526 | ||
|
|
2d2bfc0a4c | ||
|
|
3c22a12cbb | ||
|
|
3071ef2f36 | ||
|
|
3022cb22d6 | ||
|
|
e9b4a3f6b3 | ||
|
|
4312ebfcc3 | ||
|
|
2c0e099d1c | ||
|
|
b1eb02eb1d | ||
|
|
c919797553 | ||
|
|
08dacab05c | ||
|
|
2efc9218df | ||
|
|
41505dae11 | ||
|
|
45efe7cd56 | ||
|
|
78cac7f0e6 | ||
|
|
a1a1b8e94a | ||
|
|
641a658059 | ||
|
|
44ec447014 | ||
|
|
98308ecf98 | ||
|
|
8d613a6472 | ||
|
|
c3e5298ecd | ||
|
|
e89d31eb5a | ||
|
|
30160e2843 | ||
|
|
503d22e4d0 | ||
|
|
b9a0d489d7 | ||
|
|
d9d4c4c302 | ||
|
|
81c6d75d62 | ||
|
|
d1419bdfbd | ||
|
|
8566d9b7e5 | ||
|
|
b319d6db6b | ||
|
|
35fd1d8455 | ||
|
|
be21771e49 | ||
|
|
745528a9b1 | ||
|
|
f1422af95b | ||
|
|
f92f4dc1e2 | ||
|
|
019cfdc1db | ||
|
|
e694a51cdd | ||
|
|
16453ed602 | ||
|
|
fde68dc598 | ||
|
|
22e3140ff0 | ||
|
|
4824c4198b | ||
|
|
ec7d4f4498 | ||
|
|
d635de44a8 | ||
|
|
bce66bf57f | ||
|
|
0fc5153f9b | ||
|
|
0398c2fae1 | ||
|
|
847e81e95c | ||
|
|
1e6e5e66da | ||
|
|
9b0e88ca87 | ||
|
|
3acc0babb7 | ||
|
|
a9c7142d7b | ||
|
|
7a40c3526f | ||
|
|
3253d9d3fb |
33
.github/workflows/pr-testing.yml
vendored
Normal file
33
.github/workflows/pr-testing.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: PR testing
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
mavenTesting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Cache local Maven repository
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
- name: Set up the Java JDK
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'adopt'
|
||||
|
||||
- name: Run all tests
|
||||
run: |
|
||||
mvn -B clean test -DskipTests=false --file pom.xml
|
||||
if [ -f "target/site/jacoco/index.html" ]; then echo "Total coverage: $(cat target/site/jacoco/index.html | grep -o 'Total[^%]*%' | grep -o '[0-9]*%')"; fi
|
||||
|
||||
- name: Log coverage percentage
|
||||
run: |
|
||||
if [ ! -f "target/site/jacoco/index.html" ]; then echo "No coverage information available"; fi
|
||||
if [ -f "target/site/jacoco/index.html" ]; then echo "Total coverage: $(cat target/site/jacoco/index.html | grep -o 'Total[^%]*%' | grep -o '[0-9]*%')"; fi
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
/db*
|
||||
/lists/
|
||||
/bin/
|
||||
/target/
|
||||
/qortal-backup/
|
||||
/log.txt.*
|
||||
/arbitrary*
|
||||
/Qortal-BTC*
|
||||
@@ -14,8 +16,14 @@
|
||||
/settings.json
|
||||
/testnet*
|
||||
/settings*.json
|
||||
/testchain.json
|
||||
/run-testnet.sh
|
||||
/testchain*.json
|
||||
/run-testnet*.sh
|
||||
/.idea
|
||||
/qortal.iml
|
||||
*.DS_Store
|
||||
.DS_Store
|
||||
/src/main/resources/resources
|
||||
/*.jar
|
||||
/run.pid
|
||||
/run.log
|
||||
/WindowsInstaller/Install Files/qortal.jar
|
||||
/*.7z
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ configured paths, or create a dummy `D:` drive with the expected layout.
|
||||
|
||||
Typical build procedure:
|
||||
|
||||
* Overwrite the `qortal.jar` file in `Install-Files\`
|
||||
* Place the `qortal.jar` file in `Install-Files\`
|
||||
* Open AdvancedInstaller with qortal.aip file
|
||||
* If releasing a new version, change version number in:
|
||||
+ "Product Information" side menu
|
||||
|
||||
BIN
WindowsInstaller/qortal.ico
Executable file → Normal file
BIN
WindowsInstaller/qortal.ico
Executable file → Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 42 KiB |
27
pom.xml
27
pom.xml
@@ -3,17 +3,20 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.qortal</groupId>
|
||||
<artifactId>qortal</artifactId>
|
||||
<version>1.4.3</version>
|
||||
<version>1.7.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<properties>
|
||||
<skipTests>true</skipTests>
|
||||
<altcoinj.version>bf9fb80</altcoinj.version>
|
||||
<bitcoinj.version>0.15.6</bitcoinj.version>
|
||||
<bitcoinj.version>0.15.10</bitcoinj.version>
|
||||
<bouncycastle.version>1.64</bouncycastle.version>
|
||||
<build.timestamp>${maven.build.timestamp}</build.timestamp>
|
||||
<ciyam-at.version>1.3.8</ciyam-at.version>
|
||||
<commons-net.version>3.6</commons-net.version>
|
||||
<commons-text.version>1.8</commons-text.version>
|
||||
<commons-io.version>2.6</commons-io.version>
|
||||
<commons-compress.version>1.21</commons-compress.version>
|
||||
<xz.version>1.9</xz.version>
|
||||
<dagger.version>1.2.2</dagger.version>
|
||||
<guava.version>28.1-jre</guava.version>
|
||||
<hsqldb.version>2.5.1</hsqldb.version>
|
||||
@@ -439,11 +442,31 @@
|
||||
<artifactId>json-simple</artifactId>
|
||||
<version>1.1.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>20210307</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-text</artifactId>
|
||||
<version>${commons-text.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>${commons-io.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
<version>${commons-compress.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.tukaani</groupId>
|
||||
<artifactId>xz</artifactId>
|
||||
<version>${xz.version}</version>
|
||||
</dependency>
|
||||
<!-- For bitset/bitmap compression -->
|
||||
<dependency>
|
||||
<groupId>io.druid</groupId>
|
||||
|
||||
BIN
src/.DS_Store
vendored
BIN
src/.DS_Store
vendored
Binary file not shown.
BIN
src/main/.DS_Store
vendored
BIN
src/main/.DS_Store
vendored
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
package org.qortal;
|
||||
|
||||
import java.security.Security;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -57,10 +58,10 @@ public class RepositoryMaintenance {
|
||||
|
||||
LOGGER.info("Starting repository periodic maintenance. This can take a while...");
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
repository.performPeriodicMaintenance();
|
||||
repository.performPeriodicMaintenance(null);
|
||||
|
||||
LOGGER.info("Repository periodic maintenance completed");
|
||||
} catch (DataException e) {
|
||||
} catch (DataException | TimeoutException e) {
|
||||
LOGGER.error("Repository periodic maintenance failed", e);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,10 @@ public enum ApiError {
|
||||
// Foreign blockchain
|
||||
FOREIGN_BLOCKCHAIN_NETWORK_ISSUE(1201, 500),
|
||||
FOREIGN_BLOCKCHAIN_BALANCE_ISSUE(1202, 402),
|
||||
FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408);
|
||||
FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408),
|
||||
|
||||
// Trade portal
|
||||
ORDER_SIZE_TOO_SMALL(1300, 402);
|
||||
|
||||
private static final Map<Integer, ApiError> map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError));
|
||||
|
||||
@@ -157,4 +160,4 @@ public enum ApiError {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import java.security.SecureRandom;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.eclipse.jetty.http.HttpVersion;
|
||||
import org.eclipse.jetty.rewrite.handler.RedirectPatternRule;
|
||||
import org.eclipse.jetty.rewrite.handler.RewriteHandler;
|
||||
@@ -50,6 +52,8 @@ import org.qortal.settings.Settings;
|
||||
|
||||
public class ApiService {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(ApiService.class);
|
||||
|
||||
private static ApiService instance;
|
||||
|
||||
private final ResourceConfig config;
|
||||
@@ -203,6 +207,9 @@ public class ApiService {
|
||||
context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot");
|
||||
context.addServlet(PresenceWebSocket.class, "/websockets/presence");
|
||||
|
||||
// Warn about API security if needed
|
||||
this.checkApiSecurity();
|
||||
|
||||
// Start server
|
||||
this.server.start();
|
||||
} catch (Exception e) {
|
||||
@@ -222,4 +229,23 @@ public class ApiService {
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
private void checkApiSecurity() {
|
||||
// Warn about API security if needed
|
||||
boolean allConnectionsAllowed = false;
|
||||
if (Settings.getInstance().isApiKeyDisabled()) {
|
||||
for (String pattern : Settings.getInstance().getApiWhitelist()) {
|
||||
if (pattern.startsWith("0.0.0.0/") || pattern.startsWith("::/") || pattern.endsWith("/0")) {
|
||||
allConnectionsAllowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (allConnectionsAllowed) {
|
||||
LOGGER.warn("Warning: API key validation is currently disabled, and the API whitelist " +
|
||||
"is allowing all connections. This can be a security risk.");
|
||||
LOGGER.warn("To fix, set the apiKeyDisabled setting to false, or allow only specific local " +
|
||||
"IP addresses using the apiWhitelist setting.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.qortal.api;
|
||||
|
||||
import javax.xml.bind.annotation.adapters.XmlAdapter;
|
||||
|
||||
import org.bitcoinj.core.Base58;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
public class Base58TypeAdapter extends XmlAdapter<String, byte[]> {
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ public abstract class Security {
|
||||
public static final String API_KEY_HEADER = "X-API-KEY";
|
||||
|
||||
public static void checkApiCallAllowed(HttpServletRequest request) {
|
||||
// If API key checking has been disabled, we will allow the request in all cases
|
||||
boolean isApiKeyDisabled = Settings.getInstance().isApiKeyDisabled();
|
||||
if (isApiKeyDisabled)
|
||||
return;
|
||||
|
||||
String expectedApiKey = Settings.getInstance().getApiKey();
|
||||
String passedApiKey = request.getHeader(API_KEY_HEADER);
|
||||
|
||||
|
||||
18
src/main/java/org/qortal/api/model/AddressListRequest.java
Normal file
18
src/main/java/org/qortal/api/model/AddressListRequest.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import java.util.List;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class AddressListRequest {
|
||||
|
||||
@Schema(description = "A list of addresses")
|
||||
public List<String> addresses;
|
||||
|
||||
public AddressListRequest() {
|
||||
}
|
||||
|
||||
}
|
||||
23
src/main/java/org/qortal/api/model/BlockMintingInfo.java
Normal file
23
src/main/java/org/qortal/api/model/BlockMintingInfo.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class BlockMintingInfo {
|
||||
|
||||
public byte[] minterPublicKey;
|
||||
public int minterLevel;
|
||||
public int onlineAccountsCount;
|
||||
public BigDecimal maxDistance;
|
||||
public BigInteger keyDistance;
|
||||
public double keyDistanceRatio;
|
||||
public long timestamp;
|
||||
public long timeDelta;
|
||||
|
||||
public BlockMintingInfo() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,61 +1,74 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.qortal.data.network.PeerChainTipData;
|
||||
import org.qortal.data.network.PeerData;
|
||||
import org.qortal.network.Handshake;
|
||||
import org.qortal.network.Peer;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class ConnectedPeer {
|
||||
|
||||
public enum Direction {
|
||||
INBOUND,
|
||||
OUTBOUND;
|
||||
}
|
||||
public Direction direction;
|
||||
public Handshake handshakeStatus;
|
||||
public Long lastPing;
|
||||
public Long connectedWhen;
|
||||
public Long peersConnectedWhen;
|
||||
public enum Direction {
|
||||
INBOUND,
|
||||
OUTBOUND;
|
||||
}
|
||||
|
||||
public String address;
|
||||
public String version;
|
||||
public Direction direction;
|
||||
public Handshake handshakeStatus;
|
||||
public Long lastPing;
|
||||
public Long connectedWhen;
|
||||
public Long peersConnectedWhen;
|
||||
|
||||
public String nodeId;
|
||||
public String address;
|
||||
public String version;
|
||||
|
||||
public Integer lastHeight;
|
||||
@Schema(example = "base58")
|
||||
public byte[] lastBlockSignature;
|
||||
public Long lastBlockTimestamp;
|
||||
public String nodeId;
|
||||
|
||||
protected ConnectedPeer() {
|
||||
}
|
||||
public Integer lastHeight;
|
||||
@Schema(example = "base58")
|
||||
public byte[] lastBlockSignature;
|
||||
public Long lastBlockTimestamp;
|
||||
public UUID connectionId;
|
||||
public String age;
|
||||
|
||||
public ConnectedPeer(Peer peer) {
|
||||
this.direction = peer.isOutbound() ? Direction.OUTBOUND : Direction.INBOUND;
|
||||
this.handshakeStatus = peer.getHandshakeStatus();
|
||||
this.lastPing = peer.getLastPing();
|
||||
protected ConnectedPeer() {
|
||||
}
|
||||
|
||||
PeerData peerData = peer.getPeerData();
|
||||
this.connectedWhen = peer.getConnectionTimestamp();
|
||||
this.peersConnectedWhen = peer.getPeersConnectionTimestamp();
|
||||
public ConnectedPeer(Peer peer) {
|
||||
this.direction = peer.isOutbound() ? Direction.OUTBOUND : Direction.INBOUND;
|
||||
this.handshakeStatus = peer.getHandshakeStatus();
|
||||
this.lastPing = peer.getLastPing();
|
||||
|
||||
this.address = peerData.getAddress().toString();
|
||||
PeerData peerData = peer.getPeerData();
|
||||
this.connectedWhen = peer.getConnectionTimestamp();
|
||||
this.peersConnectedWhen = peer.getPeersConnectionTimestamp();
|
||||
|
||||
this.version = peer.getPeersVersionString();
|
||||
this.nodeId = peer.getPeersNodeId();
|
||||
this.address = peerData.getAddress().toString();
|
||||
|
||||
PeerChainTipData peerChainTipData = peer.getChainTipData();
|
||||
if (peerChainTipData != null) {
|
||||
this.lastHeight = peerChainTipData.getLastHeight();
|
||||
this.lastBlockSignature = peerChainTipData.getLastBlockSignature();
|
||||
this.lastBlockTimestamp = peerChainTipData.getLastBlockTimestamp();
|
||||
}
|
||||
}
|
||||
this.version = peer.getPeersVersionString();
|
||||
this.nodeId = peer.getPeersNodeId();
|
||||
this.connectionId = peer.getPeerConnectionId();
|
||||
if (peer.getConnectionEstablishedTime() > 0) {
|
||||
long age = (System.currentTimeMillis() - peer.getConnectionEstablishedTime());
|
||||
long minutes = TimeUnit.MILLISECONDS.toMinutes(age);
|
||||
long seconds = TimeUnit.MILLISECONDS.toSeconds(age) - TimeUnit.MINUTES.toSeconds(minutes);
|
||||
this.age = String.format("%dm %ds", minutes, seconds);
|
||||
} else {
|
||||
this.age = "connecting...";
|
||||
}
|
||||
|
||||
PeerChainTipData peerChainTipData = peer.getChainTipData();
|
||||
if (peerChainTipData != null) {
|
||||
this.lastHeight = peerChainTipData.getLastHeight();
|
||||
this.lastBlockSignature = peerChainTipData.getLastBlockSignature();
|
||||
this.lastBlockTimestamp = peerChainTipData.getLastBlockTimestamp();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainDualSecretRequest {
|
||||
|
||||
@Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] partnerPublicKey;
|
||||
|
||||
@Schema(description = "Qortal AT address")
|
||||
public String atAddress;
|
||||
|
||||
@Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1")
|
||||
public byte[] secretA;
|
||||
|
||||
@Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx")
|
||||
public byte[] secretB;
|
||||
|
||||
@Schema(description = "Qortal address for receiving QORT from AT")
|
||||
public String receivingAddress;
|
||||
|
||||
public CrossChainDualSecretRequest() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,17 +8,14 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainSecretRequest {
|
||||
|
||||
@Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] partnerPublicKey;
|
||||
@Schema(description = "Private key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] partnerPrivateKey;
|
||||
|
||||
@Schema(description = "Qortal AT address")
|
||||
public String atAddress;
|
||||
|
||||
@Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1")
|
||||
public byte[] secretA;
|
||||
|
||||
@Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx")
|
||||
public byte[] secretB;
|
||||
@Schema(description = "Secret (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1")
|
||||
public byte[] secret;
|
||||
|
||||
@Schema(description = "Qortal address for receiving QORT from AT")
|
||||
public String receivingAddress;
|
||||
|
||||
@@ -25,6 +25,12 @@ public class CrossChainTradeSummary {
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
private long foreignAmount;
|
||||
|
||||
private String atAddress;
|
||||
|
||||
private String sellerAddress;
|
||||
|
||||
private String buyerReceivingAddress;
|
||||
|
||||
protected CrossChainTradeSummary() {
|
||||
/* For JAXB */
|
||||
}
|
||||
@@ -34,6 +40,9 @@ public class CrossChainTradeSummary {
|
||||
this.qortAmount = crossChainTradeData.qortAmount;
|
||||
this.foreignAmount = crossChainTradeData.expectedForeignAmount;
|
||||
this.btcAmount = this.foreignAmount;
|
||||
this.sellerAddress = crossChainTradeData.qortalCreator;
|
||||
this.buyerReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress;
|
||||
this.atAddress = crossChainTradeData.qortalAtAddress;
|
||||
}
|
||||
|
||||
public long getTradeTimestamp() {
|
||||
@@ -48,7 +57,11 @@ public class CrossChainTradeSummary {
|
||||
return this.btcAmount;
|
||||
}
|
||||
|
||||
public long getForeignAmount() {
|
||||
return this.foreignAmount;
|
||||
}
|
||||
public long getForeignAmount() { return this.foreignAmount; }
|
||||
|
||||
public String getAtAddress() { return this.atAddress; }
|
||||
|
||||
public String getSellerAddress() { return this.sellerAddress; }
|
||||
|
||||
public String getBuyerReceivingAddressAddress() { return this.buyerReceivingAddress; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.qortal.api.model.crosschain;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class DogecoinSendRequest {
|
||||
|
||||
@Schema(description = "Dogecoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________")
|
||||
public String xprv58;
|
||||
|
||||
@Schema(description = "Recipient's Dogecoin address ('legacy' P2PKH only)", example = "DoGecoinEaterAddressDontSendhLfzKD")
|
||||
public String receivingAddress;
|
||||
|
||||
@Schema(description = "Amount of DOGE to send", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long dogecoinAmount;
|
||||
|
||||
@Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 DOGE (100 sats) per byte", example = "0.00000100", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public Long feePerByte;
|
||||
|
||||
public DogecoinSendRequest() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -35,6 +36,7 @@ import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.apache.logging.log4j.core.LoggerContext;
|
||||
import org.apache.logging.log4j.core.appender.RollingFileAppender;
|
||||
import org.qortal.account.Account;
|
||||
@@ -67,6 +69,8 @@ import com.google.common.collect.Lists;
|
||||
@Tag(name = "Admin")
|
||||
public class AdminResource {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(AdminResource.class);
|
||||
|
||||
private static final int MAX_LOG_LINES = 500;
|
||||
|
||||
@Context
|
||||
@@ -459,6 +463,23 @@ public class AdminResource {
|
||||
if (targetHeight <= 0 || targetHeight > Controller.getInstance().getChainHeight())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT);
|
||||
|
||||
// Make sure we're not orphaning as far back as the archived blocks
|
||||
// FUTURE: we could support this by first importing earlier blocks from the archive
|
||||
if (Settings.getInstance().isTopOnly() ||
|
||||
Settings.getInstance().isArchiveEnabled()) {
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Find the first unarchived block
|
||||
int oldestBlock = repository.getBlockArchiveRepository().getBlockArchiveHeight();
|
||||
// Add some extra blocks just in case we're currently archiving/pruning
|
||||
oldestBlock += 100;
|
||||
if (targetHeight <= oldestBlock) {
|
||||
LOGGER.info("Unable to orphan beyond block {} because it is archived", oldestBlock);
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (BlockChain.orphan(targetHeight))
|
||||
return "true";
|
||||
else
|
||||
@@ -542,19 +563,8 @@ public class AdminResource {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
repository.exportNodeLocalData();
|
||||
return "true";
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// We couldn't lock blockchain to perform export
|
||||
return "false";
|
||||
repository.exportNodeLocalData();
|
||||
return "true";
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -564,13 +574,13 @@ public class AdminResource {
|
||||
@Path("/repository/data")
|
||||
@Operation(
|
||||
summary = "Import data into repository.",
|
||||
description = "Imports data from file on local machine. Filename is forced to 'import.script' if apiKey is not set.",
|
||||
description = "Imports data from file on local machine. Filename is forced to 'qortal-backup/TradeBotStates.json' if apiKey is not set.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string", example = "MintingAccounts.script"
|
||||
type = "string", example = "qortal-backup/TradeBotStates.json"
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -588,7 +598,7 @@ public class AdminResource {
|
||||
|
||||
// Hard-coded because it's too dangerous to allow user-supplied filenames in weaker security contexts
|
||||
if (Settings.getInstance().getApiKey() == null)
|
||||
filename = "import.script";
|
||||
filename = "qortal-backup/TradeBotStates.json";
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
@@ -600,6 +610,10 @@ public class AdminResource {
|
||||
repository.saveChanges();
|
||||
|
||||
return "true";
|
||||
|
||||
} catch (IOException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e);
|
||||
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
@@ -655,14 +669,16 @@ public class AdminResource {
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
repository.backup(true);
|
||||
// Timeout if the database isn't ready for backing up after 60 seconds
|
||||
long timeout = 60 * 1000L;
|
||||
repository.backup(true, "backup", timeout);
|
||||
repository.saveChanges();
|
||||
|
||||
return "true";
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
} catch (InterruptedException | TimeoutException e) {
|
||||
// We couldn't lock blockchain to perform backup
|
||||
return "false";
|
||||
} catch (DataException e) {
|
||||
@@ -687,13 +703,15 @@ public class AdminResource {
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
repository.performPeriodicMaintenance();
|
||||
// Timeout if the database isn't ready to start after 60 seconds
|
||||
long timeout = 60 * 1000L;
|
||||
repository.performPeriodicMaintenance(timeout);
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// No big deal
|
||||
} catch (DataException e) {
|
||||
} catch (DataException | TimeoutException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import com.google.common.primitives.Ints;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
@@ -8,7 +9,14 @@ 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 java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -20,18 +28,25 @@ import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.model.BlockMintingInfo;
|
||||
import org.qortal.api.model.BlockSignerSummary;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.account.AccountData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.block.BlockSummaryData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.repository.BlockArchiveReader;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.block.BlockTransformer;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
@Path("/blocks")
|
||||
@@ -70,16 +85,74 @@ public class BlocksResource {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Check the database first
|
||||
BlockData blockData = repository.getBlockRepository().fromSignature(signature);
|
||||
if (blockData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
if (blockData != null) {
|
||||
return blockData;
|
||||
}
|
||||
|
||||
return blockData;
|
||||
// Not found, so try the block archive
|
||||
blockData = repository.getBlockArchiveRepository().fromSignature(signature);
|
||||
if (blockData != null) {
|
||||
return blockData;
|
||||
}
|
||||
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/signature/{signature}/data")
|
||||
@Operation(
|
||||
summary = "Fetch serialized, base58 encoded block data using base58 signature",
|
||||
description = "Returns serialized data for the block that matches the given signature",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "the block data",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({
|
||||
ApiError.INVALID_SIGNATURE, ApiError.BLOCK_UNKNOWN, ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public String getSerializedBlockData(@PathParam("signature") String signature58) {
|
||||
// Decode signature
|
||||
byte[] signature;
|
||||
try {
|
||||
signature = Base58.decode(signature58);
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_SIGNATURE, e);
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
// Check the database first
|
||||
BlockData blockData = repository.getBlockRepository().fromSignature(signature);
|
||||
if (blockData != null) {
|
||||
Block block = new Block(repository, blockData);
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
bytes.write(Ints.toByteArray(block.getBlockData().getHeight()));
|
||||
bytes.write(BlockTransformer.toBytes(block));
|
||||
return Base58.encode(bytes.toByteArray());
|
||||
}
|
||||
|
||||
// Not found, so try the block archive
|
||||
byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, repository);
|
||||
if (bytes != null) {
|
||||
return Base58.encode(bytes);
|
||||
}
|
||||
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
} catch (TransformationException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e);
|
||||
} catch (DataException | IOException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/signature/{signature}/transactions")
|
||||
@Operation(
|
||||
@@ -117,8 +190,12 @@ public class BlocksResource {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
if (repository.getBlockRepository().getHeightFromSignature(signature) == 0)
|
||||
// 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);
|
||||
}
|
||||
|
||||
return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse);
|
||||
} catch (DataException e) {
|
||||
@@ -147,7 +224,19 @@ public class BlocksResource {
|
||||
})
|
||||
public BlockData getFirstBlock() {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getBlockRepository().fromHeight(1);
|
||||
// Check the database first
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(1);
|
||||
if (blockData != null) {
|
||||
return blockData;
|
||||
}
|
||||
|
||||
// Try the archive
|
||||
blockData = repository.getBlockArchiveRepository().fromHeight(1);
|
||||
if (blockData != null) {
|
||||
return blockData;
|
||||
}
|
||||
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -209,17 +298,28 @@ public class BlocksResource {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
BlockData childBlockData = null;
|
||||
|
||||
// Check if block exists in database
|
||||
BlockData blockData = repository.getBlockRepository().fromSignature(signature);
|
||||
if (blockData != null) {
|
||||
return repository.getBlockRepository().fromReference(signature);
|
||||
}
|
||||
|
||||
// Check block exists
|
||||
if (blockData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
BlockData childBlockData = repository.getBlockRepository().fromReference(signature);
|
||||
// Not found, so try the archive
|
||||
// This also checks that the parent block exists
|
||||
// It will return null if either the parent or child don't exit
|
||||
childBlockData = repository.getBlockArchiveRepository().fromReference(signature);
|
||||
|
||||
// Check child block exists
|
||||
if (childBlockData == null)
|
||||
if (childBlockData == null) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
}
|
||||
|
||||
// Check child block's reference matches the supplied signature
|
||||
if (!Arrays.equals(childBlockData.getReference(), signature)) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
}
|
||||
|
||||
return childBlockData;
|
||||
} catch (DataException e) {
|
||||
@@ -285,13 +385,20 @@ public class BlocksResource {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Firstly check the database
|
||||
BlockData blockData = repository.getBlockRepository().fromSignature(signature);
|
||||
if (blockData != null) {
|
||||
return blockData.getHeight();
|
||||
}
|
||||
|
||||
// Check block exists
|
||||
if (blockData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
// Not found, so try the archive
|
||||
blockData = repository.getBlockArchiveRepository().fromSignature(signature);
|
||||
if (blockData != null) {
|
||||
return blockData.getHeight();
|
||||
}
|
||||
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
return blockData.getHeight();
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -318,11 +425,92 @@ public class BlocksResource {
|
||||
})
|
||||
public BlockData getByHeight(@PathParam("height") int height) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Firstly check the database
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
if (blockData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
if (blockData != null) {
|
||||
return blockData;
|
||||
}
|
||||
|
||||
return blockData;
|
||||
// Not found, so try the archive
|
||||
blockData = repository.getBlockArchiveRepository().fromHeight(height);
|
||||
if (blockData != null) {
|
||||
return blockData;
|
||||
}
|
||||
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/byheight/{height}/mintinginfo")
|
||||
@Operation(
|
||||
summary = "Fetch block minter info using block height",
|
||||
description = "Returns the minter info for the block with given height",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "the block",
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
implementation = BlockData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({
|
||||
ApiError.BLOCK_UNKNOWN, ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public BlockMintingInfo getBlockMintingInfoByHeight(@PathParam("height") int height) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Try the database
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
if (blockData == null) {
|
||||
|
||||
// Not found, so try the archive
|
||||
blockData = repository.getBlockArchiveRepository().fromHeight(height);
|
||||
if (blockData == null) {
|
||||
|
||||
// Still not found
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
}
|
||||
}
|
||||
|
||||
Block block = new Block(repository, blockData);
|
||||
BlockData parentBlockData = repository.getBlockRepository().fromSignature(blockData.getReference());
|
||||
if (parentBlockData == null) {
|
||||
// Parent block not found - try the archive
|
||||
parentBlockData = repository.getBlockArchiveRepository().fromSignature(blockData.getReference());
|
||||
if (parentBlockData == null) {
|
||||
|
||||
// Still not found
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
}
|
||||
}
|
||||
|
||||
int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, blockData.getMinterPublicKey());
|
||||
if (minterLevel == 0)
|
||||
// This may be unavailable when requesting a trimmed block
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
BigInteger distance = block.calcKeyDistance(parentBlockData.getHeight(), parentBlockData.getSignature(), blockData.getMinterPublicKey(), minterLevel);
|
||||
double ratio = new BigDecimal(distance).divide(new BigDecimal(block.MAX_DISTANCE), 40, RoundingMode.DOWN).doubleValue();
|
||||
long timestamp = block.calcTimestamp(parentBlockData, blockData.getMinterPublicKey(), minterLevel);
|
||||
long timeDelta = timestamp - parentBlockData.getTimestamp();
|
||||
|
||||
BlockMintingInfo blockMintingInfo = new BlockMintingInfo();
|
||||
blockMintingInfo.minterPublicKey = blockData.getMinterPublicKey();
|
||||
blockMintingInfo.minterLevel = minterLevel;
|
||||
blockMintingInfo.onlineAccountsCount = blockData.getOnlineAccountsCount();
|
||||
blockMintingInfo.maxDistance = new BigDecimal(block.MAX_DISTANCE);
|
||||
blockMintingInfo.keyDistance = distance;
|
||||
blockMintingInfo.keyDistanceRatio = ratio;
|
||||
blockMintingInfo.timestamp = timestamp;
|
||||
blockMintingInfo.timeDelta = timeDelta;
|
||||
|
||||
return blockMintingInfo;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -348,13 +536,26 @@ public class BlocksResource {
|
||||
})
|
||||
public BlockData getByTimestamp(@PathParam("timestamp") long timestamp) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int height = repository.getBlockRepository().getHeightFromTimestamp(timestamp);
|
||||
if (height == 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
BlockData blockData = null;
|
||||
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
if (blockData == null)
|
||||
// Try the Blocks table
|
||||
int height = repository.getBlockRepository().getHeightFromTimestamp(timestamp);
|
||||
if (height > 0) {
|
||||
// Found match in Blocks table
|
||||
return repository.getBlockRepository().fromHeight(height);
|
||||
}
|
||||
|
||||
// Not found in Blocks table, so try the archive
|
||||
height = repository.getBlockArchiveRepository().getHeightFromTimestamp(timestamp);
|
||||
if (height > 0) {
|
||||
// Found match in archive
|
||||
blockData = repository.getBlockArchiveRepository().fromHeight(height);
|
||||
}
|
||||
|
||||
// Ensure block exists
|
||||
if (blockData == null) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
}
|
||||
|
||||
return blockData;
|
||||
} catch (DataException e) {
|
||||
@@ -391,9 +592,14 @@ public class BlocksResource {
|
||||
|
||||
for (/* count already set */; count > 0; --count, ++height) {
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
if (blockData == null)
|
||||
// Run out of blocks!
|
||||
break;
|
||||
if (blockData == null) {
|
||||
// Not found - try the archive
|
||||
blockData = repository.getBlockArchiveRepository().fromHeight(height);
|
||||
if (blockData == null) {
|
||||
// Run out of blocks!
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
blocks.add(blockData);
|
||||
}
|
||||
@@ -438,7 +644,29 @@ public class BlocksResource {
|
||||
if (accountData == null || accountData.getPublicKey() == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.PUBLIC_KEY_NOT_FOUND);
|
||||
|
||||
return repository.getBlockRepository().getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse);
|
||||
|
||||
List<BlockSummaryData> summaries = repository.getBlockRepository()
|
||||
.getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse);
|
||||
|
||||
// Add any from the archive
|
||||
List<BlockSummaryData> archivedSummaries = repository.getBlockArchiveRepository()
|
||||
.getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse);
|
||||
if (archivedSummaries != null && !archivedSummaries.isEmpty()) {
|
||||
summaries.addAll(archivedSummaries);
|
||||
}
|
||||
else {
|
||||
summaries = archivedSummaries;
|
||||
}
|
||||
|
||||
// Sort the results (because they may have been obtained from two places)
|
||||
if (reverse != null && reverse) {
|
||||
summaries.sort((s1, s2) -> Integer.valueOf(s2.getHeight()).compareTo(Integer.valueOf(s1.getHeight())));
|
||||
}
|
||||
else {
|
||||
summaries.sort(Comparator.comparing(s -> Integer.valueOf(s.getHeight())));
|
||||
}
|
||||
|
||||
return summaries;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -474,7 +702,8 @@ public class BlocksResource {
|
||||
if (!Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
return repository.getBlockRepository().getBlockSigners(addresses, limit, offset, reverse);
|
||||
// This method pulls data from both Blocks and BlockArchive, so no need to query serparately
|
||||
return repository.getBlockArchiveRepository().getBlockSigners(addresses, limit, offset, reverse);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -514,7 +743,76 @@ public class BlocksResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getBlockRepository().getBlockSummaries(startHeight, endHeight, count);
|
||||
|
||||
/*
|
||||
* start end count result
|
||||
* 10 40 null blocks 10 to 39 (excludes end block, ignore count)
|
||||
*
|
||||
* null null null blocks 1 to 50 (assume count=50, maybe start=1)
|
||||
* 30 null null blocks 30 to 79 (assume count=50)
|
||||
* 30 null 10 blocks 30 to 39
|
||||
*
|
||||
* null null 50 last 50 blocks? so if max(blocks.height) is 200, then blocks 151 to 200
|
||||
* null 200 null blocks 150 to 199 (excludes end block, assume count=50)
|
||||
* null 200 10 blocks 190 to 199 (excludes end block)
|
||||
*/
|
||||
|
||||
List<BlockSummaryData> blockSummaries = new ArrayList<>();
|
||||
|
||||
// Use the latest X blocks if only a count is specified
|
||||
if (startHeight == null && endHeight == null && count != null) {
|
||||
BlockData chainTip = repository.getBlockRepository().getLastBlock();
|
||||
startHeight = chainTip.getHeight() - count;
|
||||
endHeight = chainTip.getHeight();
|
||||
}
|
||||
|
||||
// ... otherwise default the start height to 1
|
||||
if (startHeight == null && endHeight == null) {
|
||||
startHeight = 1;
|
||||
}
|
||||
|
||||
// Default the count to 50
|
||||
if (count == null) {
|
||||
count = 50;
|
||||
}
|
||||
|
||||
// If both a start and end height exist, ignore the count
|
||||
if (startHeight != null && endHeight != null) {
|
||||
if (startHeight > 0 && endHeight > 0) {
|
||||
count = Integer.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive start height from end height if missing
|
||||
if (startHeight == null || startHeight == 0) {
|
||||
if (endHeight != null && endHeight > 0) {
|
||||
if (count != null) {
|
||||
startHeight = endHeight - count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (/* count already set */; count > 0; --count, ++startHeight) {
|
||||
if (endHeight != null && startHeight >= endHeight) {
|
||||
break;
|
||||
}
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(startHeight);
|
||||
if (blockData == null) {
|
||||
// Not found - try the archive
|
||||
blockData = repository.getBlockArchiveRepository().fromHeight(startHeight);
|
||||
if (blockData == null) {
|
||||
// Run out of blocks!
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (blockData != null) {
|
||||
BlockSummaryData blockSummaryData = new BlockSummaryData(blockData);
|
||||
blockSummaries.add(blockSummaryData);
|
||||
}
|
||||
}
|
||||
|
||||
return blockSummaries;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
|
||||
93
src/main/java/org/qortal/api/resource/BootstrapResource.java
Normal file
93
src/main/java/org/qortal/api/resource/BootstrapResource.java
Normal file
@@ -0,0 +1,93 @@
|
||||
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.ApiError;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.repository.Bootstrap;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.*;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import java.io.IOException;
|
||||
|
||||
|
||||
@Path("/bootstrap")
|
||||
@Tag(name = "Bootstrap")
|
||||
public class BootstrapResource {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(BootstrapResource.class);
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@POST
|
||||
@Path("/create")
|
||||
@Operation(
|
||||
summary = "Create bootstrap",
|
||||
description = "Builds a bootstrap file for distribution",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "path to file on success, an exception on failure",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
public String createBootstrap() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
Bootstrap bootstrap = new Bootstrap(repository);
|
||||
if (!bootstrap.canCreateBootstrap()) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
}
|
||||
|
||||
boolean isBlockchainValid = bootstrap.validateBlockchain();
|
||||
if (!isBlockchainValid) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE);
|
||||
}
|
||||
|
||||
return bootstrap.create();
|
||||
|
||||
} catch (DataException | InterruptedException | IOException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/validate")
|
||||
@Operation(
|
||||
summary = "Validate blockchain",
|
||||
description = "Useful to check database integrity prior to creating or after installing a bootstrap. " +
|
||||
"This process is intensive and can take over an hour to run.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "true if valid, false if invalid",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
public boolean validateBootstrap() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
Bootstrap bootstrap = new Bootstrap(repository);
|
||||
return bootstrap.validateCompleteBlockchain();
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.CrossChainBuildRequest;
|
||||
import org.qortal.api.model.CrossChainSecretRequest;
|
||||
import org.qortal.api.model.CrossChainDualSecretRequest;
|
||||
import org.qortal.api.model.CrossChainTradeRequest;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.crosschain.BitcoinACCTv1;
|
||||
@@ -242,7 +242,7 @@ public class CrossChainBitcoinACCTv1Resource {
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainSecretRequest.class
|
||||
implementation = CrossChainDualSecretRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -257,7 +257,7 @@ public class CrossChainBitcoinACCTv1Resource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public String buildRedeemMessage(CrossChainSecretRequest secretRequest) {
|
||||
public String buildRedeemMessage(CrossChainDualSecretRequest secretRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
byte[] partnerPublicKey = secretRequest.partnerPublicKey;
|
||||
|
||||
@@ -23,8 +23,8 @@ import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.crosschain.BitcoinSendRequest;
|
||||
import org.qortal.crosschain.Bitcoin;
|
||||
import org.qortal.crosschain.BitcoinyTransaction;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.SimpleTransaction;
|
||||
|
||||
@Path("/crosschain/btc")
|
||||
@Tag(name = "Cross-Chain (Bitcoin)")
|
||||
@@ -89,12 +89,12 @@ public class CrossChainBitcoinResource {
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = BitcoinyTransaction.class ) ) )
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public List<BitcoinyTransaction> getBitcoinWalletTransactions(String key58) {
|
||||
public List<SimpleTransaction> getBitcoinWalletTransactions(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Bitcoin bitcoin = Bitcoin.getInstance();
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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.account.PrivateKeyAccount;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.CrossChainSecretRequest;
|
||||
import org.qortal.crosschain.AcctMode;
|
||||
import org.qortal.crosschain.DogecoinACCTv1;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.group.Group;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transaction.MessageTransaction;
|
||||
import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.transform.Transformer;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import java.util.Arrays;
|
||||
|
||||
@Path("/crosschain/DogecoinACCTv1")
|
||||
@Tag(name = "Cross-Chain (DogecoinACCTv1)")
|
||||
public class CrossChainDogecoinACCTv1Resource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@POST
|
||||
@Path("/redeemmessage")
|
||||
@Operation(
|
||||
summary = "Signs and broadcasts a 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner",
|
||||
description = "Specify address of cross-chain AT that needs to be messaged, Alice's trade private key, the 32-byte secret,<br>"
|
||||
+ "and an address for receiving QORT from AT. All of these can be found in Alice's trade bot data.<br>"
|
||||
+ "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!<br>"
|
||||
+ "You need to use the private key that the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainSecretRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public boolean buildRedeemMessage(CrossChainSecretRequest secretRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
byte[] partnerPrivateKey = secretRequest.partnerPrivateKey;
|
||||
|
||||
if (partnerPrivateKey == null || partnerPrivateKey.length != Transformer.PRIVATE_KEY_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (secretRequest.secret == null || secretRequest.secret.length != DogecoinACCTv1.SECRET_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress);
|
||||
CrossChainTradeData crossChainTradeData = DogecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||
|
||||
if (crossChainTradeData.mode != AcctMode.TRADING)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] partnerPublicKey = new PrivateKeyAccount(null, partnerPrivateKey).getPublicKey();
|
||||
String partnerAddress = Crypto.toAddress(partnerPublicKey);
|
||||
|
||||
// MESSAGE must come from address that AT considers trade partner
|
||||
if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
// Good to make MESSAGE
|
||||
|
||||
byte[] messageData = DogecoinACCTv1.buildRedeemMessage(secretRequest.secret, secretRequest.receivingAddress);
|
||||
|
||||
PrivateKeyAccount sender = new PrivateKeyAccount(repository, partnerPrivateKey);
|
||||
MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, secretRequest.atAddress, messageData, false, false);
|
||||
|
||||
messageTransaction.computeNonce();
|
||||
messageTransaction.sign(sender);
|
||||
|
||||
// reset repository state to prevent deadlock
|
||||
repository.discardChanges();
|
||||
ValidationResult result = messageTransaction.importAsUnconfirmed();
|
||||
|
||||
if (result != ValidationResult.OK)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID);
|
||||
|
||||
return true;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException {
|
||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
// Must be correct AT - check functionality using code hash
|
||||
if (!Arrays.equals(atData.getCodeHash(), DogecoinACCTv1.CODE_BYTES_HASH))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// No point sending message to AT that's finished
|
||||
if (atData.getIsFinished())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
return atData;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
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.bitcoinj.core.Transaction;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.crosschain.DogecoinSendRequest;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.Dogecoin;
|
||||
import org.qortal.crosschain.SimpleTransaction;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
|
||||
@Path("/crosschain/doge")
|
||||
@Tag(name = "Cross-Chain (Dogecoin)")
|
||||
public class CrossChainDogecoinResource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@POST
|
||||
@Path("/walletbalance")
|
||||
@Operation(
|
||||
summary = "Returns DOGE balance for hierarchical, deterministic BIP32 wallet",
|
||||
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
description = "BIP32 'm' private/public key in base58",
|
||||
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public String getDogecoinWalletBalance(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Dogecoin dogecoin = Dogecoin.getInstance();
|
||||
|
||||
if (!dogecoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
Long balance = dogecoin.getWalletBalance(key58);
|
||||
if (balance == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
return balance.toString();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/wallettransactions")
|
||||
@Operation(
|
||||
summary = "Returns transactions for hierarchical, deterministic BIP32 wallet",
|
||||
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
description = "BIP32 'm' private/public key in base58",
|
||||
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public List<SimpleTransaction> getDogecoinWalletTransactions(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Dogecoin dogecoin = Dogecoin.getInstance();
|
||||
|
||||
if (!dogecoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
return dogecoin.getWalletTransactions(key58);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/send")
|
||||
@Operation(
|
||||
summary = "Sends DOGE from hierarchical, deterministic BIP32 wallet to specific address",
|
||||
description = "Currently only supports 'legacy' P2PKH Dogecoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = DogecoinSendRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public String sendBitcoin(DogecoinSendRequest dogecoinSendRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
if (dogecoinSendRequest.dogecoinAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (dogecoinSendRequest.feePerByte != null && dogecoinSendRequest.feePerByte <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
Dogecoin dogecoin = Dogecoin.getInstance();
|
||||
|
||||
if (!dogecoin.isValidAddress(dogecoinSendRequest.receivingAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (!dogecoin.isValidDeterministicKey(dogecoinSendRequest.xprv58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
Transaction spendTransaction = dogecoin.buildSpend(dogecoinSendRequest.xprv58,
|
||||
dogecoinSendRequest.receivingAddress,
|
||||
dogecoinSendRequest.dogecoinAmount,
|
||||
dogecoinSendRequest.feePerByte);
|
||||
|
||||
if (spendTransaction == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE);
|
||||
|
||||
try {
|
||||
dogecoin.broadcastTransaction(spendTransaction);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
|
||||
return spendTransaction.getTxId().toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,29 +11,35 @@ import java.util.List;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
import org.bitcoinj.core.TransactionOutput;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bitcoinj.core.*;
|
||||
import org.bitcoinj.script.Script;
|
||||
import org.qortal.api.*;
|
||||
import org.qortal.api.model.CrossChainBitcoinyHTLCStatus;
|
||||
import org.qortal.crosschain.Bitcoiny;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
import org.qortal.crosschain.BitcoinyHTLC;
|
||||
import org.qortal.crosschain.*;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.data.crosschain.TradeBotData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
|
||||
@Path("/crosschain/htlc")
|
||||
@Tag(name = "Cross-Chain (Hash time-locked contracts)")
|
||||
public class CrossChainHtlcResource {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(CrossChainHtlcResource.class);
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@@ -41,7 +47,7 @@ public class CrossChainHtlcResource {
|
||||
@Path("/address/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}")
|
||||
@Operation(
|
||||
summary = "Returns HTLC address based on trade info",
|
||||
description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.",
|
||||
description = "Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
@@ -50,21 +56,21 @@ public class CrossChainHtlcResource {
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_CRITERIA})
|
||||
public String deriveHtlcAddress(@PathParam("blockchain") String blockchainName,
|
||||
@PathParam("refundPKH") String refundHex,
|
||||
@PathParam("refundPKH") String refundPKH,
|
||||
@PathParam("locktime") int lockTime,
|
||||
@PathParam("redeemPKH") String redeemHex,
|
||||
@PathParam("hashOfSecret") String hashOfSecretHex) {
|
||||
@PathParam("redeemPKH") String redeemPKH,
|
||||
@PathParam("hashOfSecret") String hashOfSecret) {
|
||||
SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName);
|
||||
if (blockchain == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] refunderPubKeyHash;
|
||||
byte[] redeemerPubKeyHash;
|
||||
byte[] hashOfSecret;
|
||||
byte[] decodedHashOfSecret;
|
||||
|
||||
try {
|
||||
refunderPubKeyHash = HashCode.fromString(refundHex).asBytes();
|
||||
redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes();
|
||||
refunderPubKeyHash = Base58.decode(refundPKH);
|
||||
redeemerPubKeyHash = Base58.decode(redeemPKH);
|
||||
|
||||
if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
@@ -73,14 +79,14 @@ public class CrossChainHtlcResource {
|
||||
}
|
||||
|
||||
try {
|
||||
hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes();
|
||||
if (hashOfSecret.length != 20)
|
||||
decodedHashOfSecret = Base58.decode(hashOfSecret);
|
||||
if (decodedHashOfSecret.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret);
|
||||
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, decodedHashOfSecret);
|
||||
|
||||
Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance();
|
||||
|
||||
@@ -91,7 +97,7 @@ public class CrossChainHtlcResource {
|
||||
@Path("/status/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}")
|
||||
@Operation(
|
||||
summary = "Checks HTLC status",
|
||||
description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.",
|
||||
description = "Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class))
|
||||
@@ -100,10 +106,10 @@ public class CrossChainHtlcResource {
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN})
|
||||
public CrossChainBitcoinyHTLCStatus checkHtlcStatus(@PathParam("blockchain") String blockchainName,
|
||||
@PathParam("refundPKH") String refundHex,
|
||||
@PathParam("refundPKH") String refundPKH,
|
||||
@PathParam("locktime") int lockTime,
|
||||
@PathParam("redeemPKH") String redeemHex,
|
||||
@PathParam("hashOfSecret") String hashOfSecretHex) {
|
||||
@PathParam("redeemPKH") String redeemPKH,
|
||||
@PathParam("hashOfSecret") String hashOfSecret) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName);
|
||||
@@ -112,11 +118,11 @@ public class CrossChainHtlcResource {
|
||||
|
||||
byte[] refunderPubKeyHash;
|
||||
byte[] redeemerPubKeyHash;
|
||||
byte[] hashOfSecret;
|
||||
byte[] decodedHashOfSecret;
|
||||
|
||||
try {
|
||||
refunderPubKeyHash = HashCode.fromString(refundHex).asBytes();
|
||||
redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes();
|
||||
refunderPubKeyHash = Base58.decode(refundPKH);
|
||||
redeemerPubKeyHash = Base58.decode(redeemPKH);
|
||||
|
||||
if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
@@ -125,14 +131,14 @@ public class CrossChainHtlcResource {
|
||||
}
|
||||
|
||||
try {
|
||||
hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes();
|
||||
if (hashOfSecret.length != 20)
|
||||
decodedHashOfSecret = Base58.decode(hashOfSecret);
|
||||
if (decodedHashOfSecret.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret);
|
||||
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, decodedHashOfSecret);
|
||||
|
||||
Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance();
|
||||
|
||||
@@ -168,8 +174,480 @@ public class CrossChainHtlcResource {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: refund
|
||||
@POST
|
||||
@Path("/redeem/{ataddress}")
|
||||
@Operation(
|
||||
summary = "Redeems HTLC associated with supplied AT",
|
||||
description = "To be used by a QORT seller (Bob) who needs to redeem LTC/DOGE/etc proceeds that are stuck in a P2SH.<br>" +
|
||||
"This requires Bob's trade bot data to be present in the database for this AT.<br>" +
|
||||
"It will fail if the buyer has yet to redeem the QORT held in the AT.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN})
|
||||
public boolean redeemHtlc(@PathParam("ataddress") String atAddress) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
// TODO: redeem
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
}
|
||||
ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
|
||||
if (acct == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
if (crossChainTradeData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Attempt to find secret from the buyer's message to AT
|
||||
byte[] decodedSecret = acct.findSecretA(repository, crossChainTradeData);
|
||||
if (decodedSecret == null) {
|
||||
LOGGER.info(() -> String.format("Unable to find secret-A from redeem message to AT %s", atAddress));
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null);
|
||||
|
||||
// Search for the tradePrivateKey in the tradebot data
|
||||
byte[] decodedPrivateKey = null;
|
||||
if (tradeBotData != null)
|
||||
decodedPrivateKey = tradeBotData.getTradePrivateKey();
|
||||
|
||||
// Search for the foreign blockchain receiving address in the tradebot data
|
||||
byte[] foreignBlockchainReceivingAccountInfo = null;
|
||||
if (tradeBotData != null)
|
||||
// Use receiving address PKH from tradebot data
|
||||
foreignBlockchainReceivingAccountInfo = tradeBotData.getReceivingAccountInfo();
|
||||
|
||||
return this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, foreignBlockchainReceivingAccountInfo);
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/redeemAll")
|
||||
@Operation(
|
||||
summary = "Redeems HTLC for all applicable ATs in tradebot data",
|
||||
description = "To be used by a QORT seller (Bob) who needs to redeem LTC/DOGE/etc proceeds that are stuck in P2SH transactions.<br>" +
|
||||
"This requires Bob's trade bot data to be present in the database for any ATs that need redeeming.<br>" +
|
||||
"Returns true if at least one trade is redeemed. More detail is available in the log.txt.* file.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN})
|
||||
public boolean redeemAllHtlc() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
boolean success = false;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
|
||||
for (TradeBotData tradeBotData : allTradeBotData) {
|
||||
String atAddress = tradeBotData.getAtAddress();
|
||||
if (atAddress == null) {
|
||||
LOGGER.info("Missing AT address in tradebot data", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
String tradeState = tradeBotData.getState();
|
||||
if (tradeState == null) {
|
||||
LOGGER.info("Missing trade state for AT {}", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tradeState.startsWith("ALICE")) {
|
||||
LOGGER.info("AT {} isn't redeemable because it is a buy order", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null) {
|
||||
LOGGER.info("Couldn't find AT with address {}", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
|
||||
if (acct == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
if (crossChainTradeData == null) {
|
||||
LOGGER.info("Couldn't find crosschain trade data for AT {}", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attempt to find secret from the buyer's message to AT
|
||||
byte[] decodedSecret = acct.findSecretA(repository, crossChainTradeData);
|
||||
if (decodedSecret == null) {
|
||||
LOGGER.info("Unable to find secret-A from redeem message to AT {}", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Search for the tradePrivateKey in the tradebot data
|
||||
byte[] decodedPrivateKey = tradeBotData.getTradePrivateKey();
|
||||
|
||||
// Search for the foreign blockchain receiving address PKH in the tradebot data
|
||||
byte[] foreignBlockchainReceivingAccountInfo = tradeBotData.getReceivingAccountInfo();
|
||||
|
||||
try {
|
||||
LOGGER.info("Attempting to redeem P2SH balance associated with AT {}...", atAddress);
|
||||
boolean redeemed = this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, foreignBlockchainReceivingAccountInfo);
|
||||
if (redeemed) {
|
||||
LOGGER.info("Redeemed P2SH balance associated with AT {}", atAddress);
|
||||
success = true;
|
||||
}
|
||||
else {
|
||||
LOGGER.info("Couldn't redeem P2SH balance associated with AT {}. Already redeemed?", atAddress);
|
||||
}
|
||||
} catch (ApiException e) {
|
||||
LOGGER.info("Couldn't redeem P2SH balance associated with AT {}. Missing data?", atAddress);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private boolean doRedeemHtlc(String atAddress, byte[] decodedTradePrivateKey, byte[] decodedSecret,
|
||||
byte[] foreignBlockchainReceivingAccountInfo) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
|
||||
if (acct == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
if (crossChainTradeData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Validate trade private key
|
||||
if (decodedTradePrivateKey == null || decodedTradePrivateKey.length != 32)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Validate secret
|
||||
if (decodedSecret == null || decodedSecret.length != 32)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Validate receiving address
|
||||
if (foreignBlockchainReceivingAccountInfo == null || foreignBlockchainReceivingAccountInfo.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Make sure the receiving address isn't a QORT address, given that we can share the same field for both QORT and foreign blockchains
|
||||
if (Crypto.isValidAddress(foreignBlockchainReceivingAccountInfo))
|
||||
if (Base58.encode(foreignBlockchainReceivingAccountInfo).startsWith("Q"))
|
||||
// This is likely a QORT address, not a foreign blockchain
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
|
||||
// Use secret-A to redeem P2SH-A
|
||||
|
||||
Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain();
|
||||
if (bitcoiny.getClass() == Bitcoin.class) {
|
||||
LOGGER.info("Redeeming a Bitcoin HTLC is not yet supported");
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
int lockTime = crossChainTradeData.lockTimeA;
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTime, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA);
|
||||
String p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA);
|
||||
LOGGER.info(String.format("Redeeming P2SH address: %s", p2shAddressA));
|
||||
|
||||
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||
long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = bitcoiny.getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
|
||||
switch (htlcStatusA) {
|
||||
case UNFUNDED:
|
||||
case FUNDING_IN_PROGRESS:
|
||||
// P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund
|
||||
return false;
|
||||
|
||||
case REDEEM_IN_PROGRESS:
|
||||
case REDEEMED:
|
||||
// Double-check that we have redeemed P2SH-A...
|
||||
return false;
|
||||
|
||||
case REFUND_IN_PROGRESS:
|
||||
case REFUNDED:
|
||||
// Wait for AT to auto-refund
|
||||
return false;
|
||||
|
||||
case FUNDED: {
|
||||
Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
|
||||
ECKey redeemKey = ECKey.fromPrivate(decodedTradePrivateKey);
|
||||
List<TransactionOutput> fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoiny.getNetworkParameters(), redeemAmount, redeemKey,
|
||||
fundingOutputs, redeemScriptA, decodedSecret, foreignBlockchainReceivingAccountInfo);
|
||||
|
||||
bitcoiny.broadcastTransaction(p2shRedeemTransaction);
|
||||
LOGGER.info(String.format("P2SH address %s redeemed!", p2shAddressA));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/refund/{ataddress}")
|
||||
@Operation(
|
||||
summary = "Refunds HTLC associated with supplied AT",
|
||||
description = "To be used by a QORT buyer (Alice) who needs to refund their LTC/DOGE/etc that is stuck in a P2SH.<br>" +
|
||||
"This requires Alice's trade bot data to be present in the database for this AT.<br>" +
|
||||
"It will fail if it's already redeemed by the seller, or if the lockTime (60 minutes) hasn't passed yet.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN})
|
||||
public boolean refundHtlc(@PathParam("ataddress") String atAddress) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null);
|
||||
if (tradeBotData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (tradeBotData.getForeignKey() == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
|
||||
if (acct == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Determine foreign blockchain receive address for refund
|
||||
Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain();
|
||||
String receiveAddress = bitcoiny.getUnusedReceiveAddress(tradeBotData.getForeignKey());
|
||||
|
||||
return this.doRefundHtlc(atAddress, receiveAddress);
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@POST
|
||||
@Path("/refundAll")
|
||||
@Operation(
|
||||
summary = "Refunds HTLC for all applicable ATs in tradebot data",
|
||||
description = "To be used by a QORT buyer (Alice) who needs to refund their LTC/DOGE/etc proceeds that are stuck in P2SH transactions.<br>" +
|
||||
"This requires Alice's trade bot data to be present in the database for this AT.<br>" +
|
||||
"It will fail if it's already redeemed by the seller, or if the lockTime (60 minutes) hasn't passed yet.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN})
|
||||
public boolean refundAllHtlc() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
boolean success = false;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
|
||||
for (TradeBotData tradeBotData : allTradeBotData) {
|
||||
String atAddress = tradeBotData.getAtAddress();
|
||||
if (atAddress == null) {
|
||||
LOGGER.info("Missing AT address in tradebot data", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
String tradeState = tradeBotData.getState();
|
||||
if (tradeState == null) {
|
||||
LOGGER.info("Missing trade state for AT {}", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tradeState.startsWith("BOB")) {
|
||||
LOGGER.info("AT {} isn't refundable because it is a sell order", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null) {
|
||||
LOGGER.info("Couldn't find AT with address {}", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
|
||||
if (acct == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
if (crossChainTradeData == null) {
|
||||
LOGGER.info("Couldn't find crosschain trade data for AT {}", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tradeBotData.getForeignKey() == null) {
|
||||
LOGGER.info("Couldn't find foreign key for AT {}", atAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine foreign blockchain receive address for refund
|
||||
Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain();
|
||||
String receivingAddress = bitcoiny.getUnusedReceiveAddress(tradeBotData.getForeignKey());
|
||||
|
||||
LOGGER.info("Attempting to refund P2SH balance associated with AT {}...", atAddress);
|
||||
boolean refunded = this.doRefundHtlc(atAddress, receivingAddress);
|
||||
if (refunded) {
|
||||
LOGGER.info("Refunded P2SH balance associated with AT {}", atAddress);
|
||||
success = true;
|
||||
}
|
||||
else {
|
||||
LOGGER.info("Couldn't refund P2SH balance associated with AT {}. Already redeemed?", atAddress);
|
||||
}
|
||||
} catch (ApiException | ForeignBlockchainException e) {
|
||||
LOGGER.info("Couldn't refund P2SH balance associated with AT {}. Missing data?", atAddress);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
|
||||
private boolean doRefundHtlc(String atAddress, String receiveAddress) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
|
||||
if (acct == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
if (crossChainTradeData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// If the AT is "finished" then it will have a zero balance
|
||||
// In these cases we should avoid HTLC refunds if tbe QORT haven't been returned to the seller
|
||||
if (atData.getIsFinished() && crossChainTradeData.mode != AcctMode.REFUNDED && crossChainTradeData.mode != AcctMode.CANCELLED) {
|
||||
LOGGER.info(String.format("Skipping AT %s because the QORT has already been redemed", atAddress));
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null);
|
||||
if (tradeBotData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain();
|
||||
if (bitcoiny.getClass() == Bitcoin.class) {
|
||||
LOGGER.info("Refunding a Bitcoin HTLC is not yet supported");
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
int lockTime = tradeBotData.getLockTimeA();
|
||||
|
||||
// We can't refund P2SH-A until lockTime-A has passed
|
||||
if (NTP.getTime() <= lockTime * 1000L)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON);
|
||||
|
||||
// We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
|
||||
int medianBlockTime = bitcoiny.getMedianBlockTime();
|
||||
if (medianBlockTime <= lockTime)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON);
|
||||
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||
String p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA);
|
||||
LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA));
|
||||
|
||||
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||
long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = bitcoiny.getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
|
||||
switch (htlcStatusA) {
|
||||
case UNFUNDED:
|
||||
case FUNDING_IN_PROGRESS:
|
||||
// Still waiting for P2SH-A to be funded...
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON);
|
||||
|
||||
case REDEEM_IN_PROGRESS:
|
||||
case REDEEMED:
|
||||
case REFUND_IN_PROGRESS:
|
||||
case REFUNDED:
|
||||
// Too late!
|
||||
return false;
|
||||
|
||||
case FUNDED:{
|
||||
Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
|
||||
ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
|
||||
List<TransactionOutput> fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
// Validate the destination foreign blockchain address
|
||||
Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress);
|
||||
if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey,
|
||||
fundingOutputs, redeemScriptA, lockTime, receiving.getHash());
|
||||
|
||||
bitcoiny.broadcastTransaction(p2shRefundTransaction);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) {
|
||||
return (lockTimeA - tradeTimeout * 60) * 1000L;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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.account.PrivateKeyAccount;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.CrossChainSecretRequest;
|
||||
import org.qortal.crosschain.AcctMode;
|
||||
import org.qortal.crosschain.LitecoinACCTv1;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.group.Group;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transaction.MessageTransaction;
|
||||
import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.Transformer;
|
||||
import org.qortal.transform.transaction.MessageTransactionTransformer;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import java.util.Arrays;
|
||||
import java.util.Random;
|
||||
|
||||
@Path("/crosschain/LitecoinACCTv1")
|
||||
@Tag(name = "Cross-Chain (LitecoinACCTv1)")
|
||||
public class CrossChainLitecoinACCTv1Resource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@POST
|
||||
@Path("/redeemmessage")
|
||||
@Operation(
|
||||
summary = "Signs and broadcasts a 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner",
|
||||
description = "Specify address of cross-chain AT that needs to be messaged, Alice's trade private key, the 32-byte secret,<br>"
|
||||
+ "and an address for receiving QORT from AT. All of these can be found in Alice's trade bot data.<br>"
|
||||
+ "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!<br>"
|
||||
+ "You need to use the private key that the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainSecretRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public boolean buildRedeemMessage(CrossChainSecretRequest secretRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
byte[] partnerPrivateKey = secretRequest.partnerPrivateKey;
|
||||
|
||||
if (partnerPrivateKey == null || partnerPrivateKey.length != Transformer.PRIVATE_KEY_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (secretRequest.secret == null || secretRequest.secret.length != LitecoinACCTv1.SECRET_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress);
|
||||
CrossChainTradeData crossChainTradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||
|
||||
if (crossChainTradeData.mode != AcctMode.TRADING)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] partnerPublicKey = new PrivateKeyAccount(null, partnerPrivateKey).getPublicKey();
|
||||
String partnerAddress = Crypto.toAddress(partnerPublicKey);
|
||||
|
||||
// MESSAGE must come from address that AT considers trade partner
|
||||
if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
// Good to make MESSAGE
|
||||
|
||||
byte[] messageData = LitecoinACCTv1.buildRedeemMessage(secretRequest.secret, secretRequest.receivingAddress);
|
||||
|
||||
PrivateKeyAccount sender = new PrivateKeyAccount(repository, partnerPrivateKey);
|
||||
MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, secretRequest.atAddress, messageData, false, false);
|
||||
|
||||
messageTransaction.computeNonce();
|
||||
messageTransaction.sign(sender);
|
||||
|
||||
// reset repository state to prevent deadlock
|
||||
repository.discardChanges();
|
||||
ValidationResult result = messageTransaction.importAsUnconfirmed();
|
||||
|
||||
if (result != ValidationResult.OK)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID);
|
||||
|
||||
return true;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException {
|
||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
// Must be correct AT - check functionality using code hash
|
||||
if (!Arrays.equals(atData.getCodeHash(), LitecoinACCTv1.CODE_BYTES_HASH))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// No point sending message to AT that's finished
|
||||
if (atData.getIsFinished())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
return atData;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,9 +22,9 @@ import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.crosschain.LitecoinSendRequest;
|
||||
import org.qortal.crosschain.BitcoinyTransaction;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.Litecoin;
|
||||
import org.qortal.crosschain.SimpleTransaction;
|
||||
|
||||
@Path("/crosschain/ltc")
|
||||
@Tag(name = "Cross-Chain (Litecoin)")
|
||||
@@ -89,12 +89,12 @@ public class CrossChainLitecoinResource {
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = BitcoinyTransaction.class ) ) )
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public List<BitcoinyTransaction> getLitecoinWalletTransactions(String key58) {
|
||||
public List<SimpleTransaction> getLitecoinWalletTransactions(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
@@ -255,13 +255,19 @@ public class CrossChainResource {
|
||||
description = "foreign blockchain",
|
||||
example = "LITECOIN",
|
||||
schema = @Schema(implementation = SupportedBlockchain.class)
|
||||
) @PathParam("blockchain") SupportedBlockchain foreignBlockchain) {
|
||||
) @PathParam("blockchain") SupportedBlockchain foreignBlockchain,
|
||||
@Parameter(
|
||||
description = "Maximum number of trades to include in price calculation",
|
||||
example = "10",
|
||||
schema = @Schema(type = "integer", defaultValue = "10")
|
||||
) @QueryParam("maxtrades") Integer maxtrades) {
|
||||
// foreignBlockchain is required
|
||||
if (foreignBlockchain == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// We want both a minimum of 5 trades and enough trades to span at least 4 hours
|
||||
int minimumCount = 5;
|
||||
int maximumCount = maxtrades != null ? maxtrades : 10;
|
||||
long minimumPeriod = 4 * 60 * 60 * 1000L; // ms
|
||||
Boolean isFinished = Boolean.TRUE;
|
||||
|
||||
@@ -276,7 +282,7 @@ public class CrossChainResource {
|
||||
ACCT acct = acctInfo.getValue().get();
|
||||
|
||||
List<ATStateData> atStates = repository.getATRepository().getMatchingFinalATStatesQuorum(codeHash,
|
||||
isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumCount, minimumPeriod);
|
||||
isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumCount, maximumCount, minimumPeriod);
|
||||
|
||||
for (ATStateData atState : atStates) {
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState);
|
||||
|
||||
@@ -107,7 +107,7 @@ public class CrossChainTradeBotResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE})
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE, ApiError.ORDER_SIZE_TOO_SMALL})
|
||||
@SuppressWarnings("deprecation")
|
||||
public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
@@ -128,10 +128,13 @@ public class CrossChainTradeBotResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (tradeBotCreateRequest.foreignAmount == null || tradeBotCreateRequest.foreignAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ORDER_SIZE_TOO_SMALL);
|
||||
|
||||
if (tradeBotCreateRequest.foreignAmount < foreignBlockchain.getMinimumOrderAmount())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ORDER_SIZE_TOO_SMALL);
|
||||
|
||||
if (tradeBotCreateRequest.qortAmount <= 0 || tradeBotCreateRequest.fundingQortAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ORDER_SIZE_TOO_SMALL);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Do some simple checking first
|
||||
@@ -283,4 +286,4 @@ public class CrossChainTradeBotResource {
|
||||
return atData;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
298
src/main/java/org/qortal/api/resource/ListsResource.java
Normal file
298
src/main/java/org/qortal/api/resource/ListsResource.java
Normal file
@@ -0,0 +1,298 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
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.*;
|
||||
import org.qortal.api.model.AddressListRequest;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.account.AccountData;
|
||||
import org.qortal.list.ResourceListManager;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.*;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
|
||||
@Path("/lists")
|
||||
@Tag(name = "Lists")
|
||||
public class ListsResource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@POST
|
||||
@Path("/blacklist/address/{address}")
|
||||
@Operation(
|
||||
summary = "Add a QORT address to the local blacklist",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "Returns true on success, or an exception on failure",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public String addAddressToBlacklist(@PathParam("address") String address) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
if (!Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
AccountData accountData = repository.getAccountRepository().getAccount(address);
|
||||
// Not found?
|
||||
if (accountData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
// Valid address, so go ahead and blacklist it
|
||||
boolean success = ResourceListManager.getInstance().addAddressToBlacklist(address, true);
|
||||
|
||||
return success ? "true" : "false";
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/blacklist/addresses")
|
||||
@Operation(
|
||||
summary = "Add one or more QORT addresses to the local blacklist",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = AddressListRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "Returns true if all addresses were processed, false if any couldn't be " +
|
||||
"processed, or an exception on failure. If false or an exception is returned, " +
|
||||
"the list will not be updated, and the request will need to be re-issued.",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public String addAddressesToBlacklist(AddressListRequest addressListRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
if (addressListRequest == null || addressListRequest.addresses == null) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
int successCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
for (String address : addressListRequest.addresses) {
|
||||
|
||||
if (!Crypto.isValidAddress(address)) {
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
AccountData accountData = repository.getAccountRepository().getAccount(address);
|
||||
// Not found?
|
||||
if (accountData == null) {
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Valid address, so go ahead and blacklist it
|
||||
boolean success = ResourceListManager.getInstance().addAddressToBlacklist(address, false);
|
||||
if (success) {
|
||||
successCount++;
|
||||
}
|
||||
else {
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
|
||||
if (successCount > 0 && errorCount == 0) {
|
||||
// All were successful, so save the blacklist
|
||||
ResourceListManager.getInstance().saveBlacklist();
|
||||
return "true";
|
||||
}
|
||||
else {
|
||||
// Something went wrong, so revert
|
||||
ResourceListManager.getInstance().revertBlacklist();
|
||||
return "false";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@DELETE
|
||||
@Path("/blacklist/address/{address}")
|
||||
@Operation(
|
||||
summary = "Remove a QORT address from the local blacklist",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "Returns true on success, or an exception on failure",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public String removeAddressFromBlacklist(@PathParam("address") String address) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
if (!Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
AccountData accountData = repository.getAccountRepository().getAccount(address);
|
||||
// Not found?
|
||||
if (accountData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
// Valid address, so go ahead and blacklist it
|
||||
boolean success = ResourceListManager.getInstance().removeAddressFromBlacklist(address, true);
|
||||
|
||||
return success ? "true" : "false";
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/blacklist/addresses")
|
||||
@Operation(
|
||||
summary = "Remove one or more QORT addresses from the local blacklist",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = AddressListRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "Returns true if all addresses were processed, false if any couldn't be " +
|
||||
"processed, or an exception on failure. If false or an exception is returned, " +
|
||||
"the list will not be updated, and the request will need to be re-issued.",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public String removeAddressesFromBlacklist(AddressListRequest addressListRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
if (addressListRequest == null || addressListRequest.addresses == null) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
int successCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
for (String address : addressListRequest.addresses) {
|
||||
|
||||
if (!Crypto.isValidAddress(address)) {
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
AccountData accountData = repository.getAccountRepository().getAccount(address);
|
||||
// Not found?
|
||||
if (accountData == null) {
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Valid address, so go ahead and blacklist it
|
||||
// Don't save as we will do this at the end of the process
|
||||
boolean success = ResourceListManager.getInstance().removeAddressFromBlacklist(address, false);
|
||||
if (success) {
|
||||
successCount++;
|
||||
}
|
||||
else {
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
|
||||
if (successCount > 0 && errorCount == 0) {
|
||||
// All were successful, so save the blacklist
|
||||
ResourceListManager.getInstance().saveBlacklist();
|
||||
return "true";
|
||||
}
|
||||
else {
|
||||
// Something went wrong, so revert
|
||||
ResourceListManager.getInstance().revertBlacklist();
|
||||
return "false";
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/blacklist/addresses")
|
||||
@Operation(
|
||||
summary = "Fetch the list of blacklisted addresses",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "A JSON array of addresses",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = String.class)))
|
||||
)
|
||||
}
|
||||
)
|
||||
public String getAddressBlacklist() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
return ResourceListManager.getInstance().getBlacklistJSONString();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/blacklist/address/{address}")
|
||||
@Operation(
|
||||
summary = "Check if an address is present in the local blacklist",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "Returns true or false if the list was queried, or an exception on failure",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public String checkAddressInBlacklist(@PathParam("address") String address) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
if (!Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
AccountData accountData = repository.getAccountRepository().getAccount(address);
|
||||
// Not found?
|
||||
if (accountData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
// Valid address, so go ahead and blacklist it
|
||||
boolean blacklisted = ResourceListManager.getInstance().isAddressInBlacklist(address);
|
||||
|
||||
return blacklisted ? "true" : "false";
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -321,7 +321,7 @@ public class PeersResource {
|
||||
boolean force = true;
|
||||
List<BlockSummaryData> peerBlockSummaries = new ArrayList<>();
|
||||
|
||||
SynchronizationResult findCommonBlockResult = Synchronizer.getInstance().fetchSummariesFromCommonBlock(repository, targetPeer, ourInitialHeight, force, peerBlockSummaries);
|
||||
SynchronizationResult findCommonBlockResult = Synchronizer.getInstance().fetchSummariesFromCommonBlock(repository, targetPeer, ourInitialHeight, force, peerBlockSummaries, true);
|
||||
if (findCommonBlockResult != SynchronizationResult.OK)
|
||||
return null;
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.transform.Transformer;
|
||||
import org.qortal.transform.transaction.TransactionTransformer;
|
||||
import org.qortal.transform.transaction.TransactionTransformer.Transformation;
|
||||
import org.qortal.utils.BIP39;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
@@ -195,123 +194,6 @@ public class UtilsResource {
|
||||
return Base58.encode(random);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/mnemonic")
|
||||
@Operation(
|
||||
summary = "Generate 12-word BIP39 mnemonic",
|
||||
description = "Optionally pass 16-byte, base58-encoded entropy or entropy will be internally generated.<br>"
|
||||
+ "Example entropy input: YcVfxkQb6JRzqk5kF2tNLv",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "mnemonic",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.NON_PRODUCTION, ApiError.INVALID_DATA})
|
||||
public String getMnemonic(@QueryParam("entropy") String suppliedEntropy) {
|
||||
if (Settings.getInstance().isApiRestricted())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION);
|
||||
|
||||
/*
|
||||
* BIP39 word lists have 2048 entries so can be represented by 11 bits.
|
||||
* UUID (128bits) and another 4 bits gives 132 bits.
|
||||
* 132 bits, divided by 11, gives 12 words.
|
||||
*/
|
||||
byte[] entropy;
|
||||
if (suppliedEntropy != null) {
|
||||
// Use caller-supplied entropy input
|
||||
try {
|
||||
entropy = Base58.decode(suppliedEntropy);
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
}
|
||||
|
||||
// Must be 16-bytes
|
||||
if (entropy.length != 16)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
} else {
|
||||
// Generate entropy internally
|
||||
UUID uuid = UUID.randomUUID();
|
||||
|
||||
byte[] uuidMSB = Longs.toByteArray(uuid.getMostSignificantBits());
|
||||
byte[] uuidLSB = Longs.toByteArray(uuid.getLeastSignificantBits());
|
||||
entropy = Bytes.concat(uuidMSB, uuidLSB);
|
||||
}
|
||||
|
||||
// Use SHA256 to generate more bits
|
||||
byte[] hash = Crypto.digest(entropy);
|
||||
|
||||
// Append first 4 bits from hash to end. (Actually 8 bits but we only use 4).
|
||||
byte checksum = (byte) (hash[0] & 0xf0);
|
||||
entropy = Bytes.concat(entropy, new byte[] {
|
||||
checksum
|
||||
});
|
||||
|
||||
return BIP39.encode(entropy, "en");
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/mnemonic")
|
||||
@Operation(
|
||||
summary = "Calculate binary entropy from 12-word BIP39 mnemonic",
|
||||
description = "Returns the base58-encoded binary form, or \"false\" if mnemonic is invalid.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "entropy in base58",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.NON_PRODUCTION})
|
||||
public String fromMnemonic(String mnemonic) {
|
||||
if (Settings.getInstance().isApiRestricted())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION);
|
||||
|
||||
if (mnemonic.isEmpty())
|
||||
return "false";
|
||||
|
||||
// Strip leading/trailing whitespace if any
|
||||
mnemonic = mnemonic.trim();
|
||||
|
||||
String[] phraseWords = mnemonic.split(" ");
|
||||
if (phraseWords.length != 12)
|
||||
return "false";
|
||||
|
||||
// Convert BIP39 mnemonic to binary
|
||||
byte[] binary = BIP39.decode(phraseWords, "en");
|
||||
if (binary == null)
|
||||
return "false";
|
||||
|
||||
byte[] entropy = Arrays.copyOf(binary, 16); // 132 bits is 16.5 bytes, but we're discarding checksum nybble
|
||||
|
||||
byte checksumNybble = (byte) (binary[16] & 0xf0);
|
||||
byte[] checksum = Crypto.digest(entropy);
|
||||
if (checksumNybble != (byte) (checksum[0] & 0xf0))
|
||||
return "false";
|
||||
|
||||
return Base58.encode(entropy);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/privatekey")
|
||||
@Operation(
|
||||
|
||||
@@ -115,6 +115,9 @@ public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
}
|
||||
|
||||
private void onNotify(Session session, ChatTransactionData chatTransactionData, List<String> involvingAddresses) {
|
||||
if (chatTransactionData == null)
|
||||
return;
|
||||
|
||||
// We only want direct/non-group messages where sender/recipient match our addresses
|
||||
String recipient = chatTransactionData.getRecipient();
|
||||
if (recipient == null)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.qortal.at;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.ciyam.at.MachineState;
|
||||
@@ -56,12 +58,12 @@ public class AT {
|
||||
|
||||
this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, codeBytes, codeHash,
|
||||
machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(),
|
||||
machineState.isFrozen(), machineState.getFrozenBalance());
|
||||
machineState.isFrozen(), machineState.getFrozenBalance(), null);
|
||||
|
||||
byte[] stateData = machineState.toBytes();
|
||||
byte[] stateHash = Crypto.digest(stateData);
|
||||
|
||||
this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true);
|
||||
this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true, null);
|
||||
}
|
||||
|
||||
// Getters / setters
|
||||
@@ -84,13 +86,28 @@ public class AT {
|
||||
this.repository.getATRepository().delete(this.atData.getATAddress());
|
||||
}
|
||||
|
||||
/**
|
||||
* Potentially execute AT.
|
||||
* <p>
|
||||
* Note that sleep-until-message support might set/reset
|
||||
* sleep-related flags/values.
|
||||
* <p>
|
||||
* {@link #getATStateData()} will return null if nothing happened.
|
||||
* <p>
|
||||
* @param blockHeight
|
||||
* @param blockTimestamp
|
||||
* @return AT-generated transactions, possibly empty
|
||||
* @throws DataException
|
||||
*/
|
||||
public List<AtTransaction> run(int blockHeight, long blockTimestamp) throws DataException {
|
||||
String atAddress = this.atData.getATAddress();
|
||||
|
||||
QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp);
|
||||
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
|
||||
|
||||
byte[] codeBytes = this.atData.getCodeBytes();
|
||||
if (!api.willExecute(blockHeight))
|
||||
// this.atStateData will be null
|
||||
return Collections.emptyList();
|
||||
|
||||
// Fetch latest ATStateData for this AT
|
||||
ATStateData latestAtStateData = this.repository.getATRepository().getLatestATState(atAddress);
|
||||
@@ -100,8 +117,10 @@ public class AT {
|
||||
throw new IllegalStateException("No previous AT state data found");
|
||||
|
||||
// [Re]create AT machine state using AT state data or from scratch as applicable
|
||||
byte[] codeBytes = this.atData.getCodeBytes();
|
||||
MachineState state = MachineState.fromBytes(api, loggerFactory, latestAtStateData.getStateData(), codeBytes);
|
||||
try {
|
||||
api.preExecute(state);
|
||||
state.execute();
|
||||
} catch (Exception e) {
|
||||
throw new DataException(String.format("Uncaught exception while running AT '%s'", atAddress), e);
|
||||
@@ -109,9 +128,18 @@ public class AT {
|
||||
|
||||
byte[] stateData = state.toBytes();
|
||||
byte[] stateHash = Crypto.digest(stateData);
|
||||
long atFees = api.calcFinalFees(state);
|
||||
|
||||
this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false);
|
||||
// Nothing happened?
|
||||
if (state.getSteps() == 0 && Arrays.equals(stateHash, latestAtStateData.getStateHash()))
|
||||
// We currently want to execute frozen ATs, to maintain backwards support.
|
||||
if (state.isFrozen() == false)
|
||||
// this.atStateData will be null
|
||||
return Collections.emptyList();
|
||||
|
||||
long atFees = api.calcFinalFees(state);
|
||||
Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp();
|
||||
|
||||
this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false, sleepUntilMessageTimestamp);
|
||||
|
||||
return api.getTransactions();
|
||||
}
|
||||
@@ -130,6 +158,10 @@ public class AT {
|
||||
this.atData.setHadFatalError(state.hadFatalError());
|
||||
this.atData.setIsFrozen(state.isFrozen());
|
||||
this.atData.setFrozenBalance(state.getFrozenBalance());
|
||||
|
||||
// Special sleep-until-message support
|
||||
this.atData.setSleepUntilMessageTimestamp(this.atStateData.getSleepUntilMessageTimestamp());
|
||||
|
||||
this.repository.getATRepository().save(this.atData);
|
||||
}
|
||||
|
||||
@@ -157,6 +189,10 @@ public class AT {
|
||||
this.atData.setHadFatalError(state.hadFatalError());
|
||||
this.atData.setIsFrozen(state.isFrozen());
|
||||
this.atData.setFrozenBalance(state.getFrozenBalance());
|
||||
|
||||
// Special sleep-until-message support
|
||||
this.atData.setSleepUntilMessageTimestamp(previousStateData.getSleepUntilMessageTimestamp());
|
||||
|
||||
this.repository.getATRepository().save(this.atData);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.qortal.group.Group;
|
||||
import org.qortal.repository.ATRepository;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.ATRepository.NextTransactionInfo;
|
||||
import org.qortal.transaction.AtTransaction;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.utils.Base58;
|
||||
@@ -74,8 +75,45 @@ public class QortalATAPI extends API {
|
||||
return this.transactions;
|
||||
}
|
||||
|
||||
public long calcFinalFees(MachineState state) {
|
||||
return state.getSteps() * this.ciyamAtSettings.feePerStep;
|
||||
public boolean willExecute(int blockHeight) throws DataException {
|
||||
// Sleep-until-message/height checking
|
||||
Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp();
|
||||
|
||||
if (sleepUntilMessageTimestamp != null) {
|
||||
// Quicker to check height, if sleep-until-height also active
|
||||
Integer sleepUntilHeight = this.atData.getSleepUntilHeight();
|
||||
|
||||
boolean wakeDueToHeight = sleepUntilHeight != null && sleepUntilHeight != 0 && blockHeight >= sleepUntilHeight;
|
||||
|
||||
boolean wakeDueToMessage = false;
|
||||
if (!wakeDueToHeight) {
|
||||
// No avoiding asking repository
|
||||
Timestamp previousTxTimestamp = new Timestamp(sleepUntilMessageTimestamp);
|
||||
NextTransactionInfo nextTransactionInfo = this.repository.getATRepository().findNextTransaction(this.atData.getATAddress(),
|
||||
previousTxTimestamp.blockHeight,
|
||||
previousTxTimestamp.transactionSequence);
|
||||
|
||||
wakeDueToMessage = nextTransactionInfo != null;
|
||||
}
|
||||
|
||||
// Can we skip?
|
||||
if (!wakeDueToHeight && !wakeDueToMessage)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void preExecute(MachineState state) {
|
||||
// Sleep-until-message/height checking
|
||||
Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp();
|
||||
|
||||
if (sleepUntilMessageTimestamp != null) {
|
||||
// We've passed checks, so clear sleep-related flags/values
|
||||
this.setIsSleeping(state, false);
|
||||
this.setSleepUntilHeight(state, 0);
|
||||
this.atData.setSleepUntilMessageTimestamp(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Inherited methods from CIYAM AT API
|
||||
@@ -412,6 +450,10 @@ public class QortalATAPI extends API {
|
||||
|
||||
// Utility methods
|
||||
|
||||
public long calcFinalFees(MachineState state) {
|
||||
return state.getSteps() * this.ciyamAtSettings.feePerStep;
|
||||
}
|
||||
|
||||
/** Returns partial transaction signature, used to verify we're operating on the same transaction and not naively using block height & sequence. */
|
||||
public static byte[] partialSignature(byte[] fullSignature) {
|
||||
return Arrays.copyOfRange(fullSignature, 8, 32);
|
||||
@@ -460,6 +502,15 @@ public class QortalATAPI extends API {
|
||||
}
|
||||
}
|
||||
|
||||
/*package*/ void sleepUntilMessageOrHeight(MachineState state, long txTimestamp, Long sleepUntilHeight) {
|
||||
this.setIsSleeping(state, true);
|
||||
|
||||
this.atData.setSleepUntilMessageTimestamp(txTimestamp);
|
||||
|
||||
if (sleepUntilHeight != null)
|
||||
this.setSleepUntilHeight(state, sleepUntilHeight.intValue());
|
||||
}
|
||||
|
||||
/** Returns AT's account */
|
||||
/* package */ Account getATAccount() {
|
||||
return new Account(this.repository, this.atData.getATAddress());
|
||||
|
||||
@@ -84,6 +84,43 @@ public enum QortalFunctionCode {
|
||||
api.setB(state, bBytes);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Sleep AT until a new message arrives after 'tx-timestamp'.<br>
|
||||
* <tt>0x0503 tx-timestamp</tt>
|
||||
*/
|
||||
SLEEP_UNTIL_MESSAGE(0x0503, 1, false) {
|
||||
@Override
|
||||
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
|
||||
if (functionData.value1 <= 0)
|
||||
return;
|
||||
|
||||
long txTimestamp = functionData.value1;
|
||||
|
||||
QortalATAPI api = (QortalATAPI) state.getAPI();
|
||||
api.sleepUntilMessageOrHeight(state, txTimestamp, null);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Sleep AT until a new message arrives, after 'tx-timestamp', or height reached.<br>
|
||||
* <tt>0x0504 tx-timestamp height</tt>
|
||||
*/
|
||||
SLEEP_UNTIL_MESSAGE_OR_HEIGHT(0x0504, 2, false) {
|
||||
@Override
|
||||
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
|
||||
if (functionData.value1 <= 0)
|
||||
return;
|
||||
|
||||
long txTimestamp = functionData.value1;
|
||||
|
||||
if (functionData.value2 <= 0)
|
||||
return;
|
||||
|
||||
long sleepUntilHeight = functionData.value2;
|
||||
|
||||
QortalATAPI api = (QortalATAPI) state.getAPI();
|
||||
api.sleepUntilMessageOrHeight(state, txTimestamp, sleepUntilHeight);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.<br>
|
||||
* <tt>0x0510</tt>
|
||||
|
||||
@@ -176,19 +176,26 @@ public class Block {
|
||||
*
|
||||
* @return account-level share "bin" from blockchain config, or null if founder / none found
|
||||
*/
|
||||
public AccountLevelShareBin getShareBin() {
|
||||
public AccountLevelShareBin getShareBin(int blockHeight) {
|
||||
if (this.isMinterFounder)
|
||||
return null;
|
||||
|
||||
final int accountLevel = this.mintingAccountData.getLevel();
|
||||
if (accountLevel <= 0)
|
||||
return null;
|
||||
return null; // level 0 isn't included in any share bins
|
||||
|
||||
final AccountLevelShareBin[] shareBinsByLevel = BlockChain.getInstance().getShareBinsByAccountLevel();
|
||||
final BlockChain blockChain = BlockChain.getInstance();
|
||||
final AccountLevelShareBin[] shareBinsByLevel = blockChain.getShareBinsByAccountLevel();
|
||||
if (accountLevel > shareBinsByLevel.length)
|
||||
return null;
|
||||
|
||||
return shareBinsByLevel[accountLevel];
|
||||
if (blockHeight < blockChain.getShareBinFixHeight())
|
||||
// Off-by-one bug still in effect
|
||||
return shareBinsByLevel[accountLevel];
|
||||
|
||||
// level 1 stored at index 0, level 2 stored at index 1, etc.
|
||||
return shareBinsByLevel[accountLevel-1];
|
||||
|
||||
}
|
||||
|
||||
public long distribute(long accountAmount, Map<String, Long> balanceChanges) {
|
||||
@@ -225,7 +232,7 @@ public class Block {
|
||||
|
||||
// Other useful constants
|
||||
|
||||
private static final BigInteger MAX_DISTANCE;
|
||||
public static final BigInteger MAX_DISTANCE;
|
||||
static {
|
||||
byte[] maxValue = new byte[Transformer.PUBLIC_KEY_LENGTH];
|
||||
Arrays.fill(maxValue, (byte) 0xFF);
|
||||
@@ -789,7 +796,9 @@ public class Block {
|
||||
NumberFormat formatter = new DecimalFormat("0.###E0");
|
||||
boolean isLogging = LOGGER.getLevel().isLessSpecificThan(Level.TRACE);
|
||||
|
||||
int blockCount = 0;
|
||||
for (BlockSummaryData blockSummaryData : blockSummaries) {
|
||||
blockCount++;
|
||||
StringBuilder stringBuilder = isLogging ? new StringBuilder(512) : null;
|
||||
|
||||
if (isLogging)
|
||||
@@ -818,11 +827,11 @@ public class Block {
|
||||
parentHeight = blockSummaryData.getHeight();
|
||||
parentBlockSignature = blockSummaryData.getSignature();
|
||||
|
||||
/* Potential future consensus change: only comparing the same number of blocks.
|
||||
if (parentHeight >= maxHeight)
|
||||
// After this timestamp, we only compare the same number of blocks
|
||||
if (NTP.getTime() >= BlockChain.getInstance().getCalcChainWeightTimestamp() && parentHeight >= maxHeight)
|
||||
break;
|
||||
*/
|
||||
}
|
||||
LOGGER.trace(String.format("Chain weight calculation was based on %d blocks", blockCount));
|
||||
|
||||
return cumulativeWeight;
|
||||
}
|
||||
@@ -1083,9 +1092,14 @@ public class Block {
|
||||
// Create repository savepoint here so we can rollback to it after testing transactions
|
||||
repository.setSavepoint();
|
||||
|
||||
if (this.blockData.getHeight() == 212937)
|
||||
if (this.blockData.getHeight() == 212937) {
|
||||
// Apply fix for block 212937 but fix will be rolled back before we exit method
|
||||
Block212937.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);
|
||||
}
|
||||
|
||||
for (Transaction transaction : this.getTransactions()) {
|
||||
TransactionData transactionData = transaction.getTransactionData();
|
||||
@@ -1124,7 +1138,7 @@ public class Block {
|
||||
// Check transaction can even be processed
|
||||
validationResult = transaction.isProcessable();
|
||||
if (validationResult != Transaction.ValidationResult.OK) {
|
||||
LOGGER.debug(String.format("Error during transaction validation, tx %s: %s", Base58.encode(transactionData.getSignature()), validationResult.name()));
|
||||
LOGGER.info(String.format("Error during transaction validation, tx %s: %s", Base58.encode(transactionData.getSignature()), validationResult.name()));
|
||||
return ValidationResult.TRANSACTION_INVALID;
|
||||
}
|
||||
|
||||
@@ -1238,12 +1252,13 @@ public class Block {
|
||||
for (ATData atData : executableATs) {
|
||||
AT at = new AT(this.repository, atData);
|
||||
List<AtTransaction> atTransactions = at.run(this.blockData.getHeight(), this.blockData.getTimestamp());
|
||||
ATStateData atStateData = at.getATStateData();
|
||||
// Didn't execute? (e.g. sleeping)
|
||||
if (atStateData == null)
|
||||
continue;
|
||||
|
||||
allAtTransactions.addAll(atTransactions);
|
||||
|
||||
ATStateData atStateData = at.getATStateData();
|
||||
this.ourAtStates.add(atStateData);
|
||||
|
||||
this.ourAtFees += atStateData.getFees();
|
||||
}
|
||||
|
||||
@@ -1272,6 +1287,21 @@ public class Block {
|
||||
return mintingAccount.canMint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-process block, and its transactions.
|
||||
* This allows for any database integrity checks prior to validation.
|
||||
* This is called before isValid() and process()
|
||||
*
|
||||
* @throws DataException
|
||||
*/
|
||||
public void preProcess() throws DataException {
|
||||
List<Transaction> blocksTransactions = this.getTransactions();
|
||||
|
||||
for (Transaction transaction : blocksTransactions) {
|
||||
transaction.preProcess();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process block, and its transactions, adding them to the blockchain.
|
||||
*
|
||||
@@ -1328,6 +1358,9 @@ public class Block {
|
||||
|
||||
// Give Controller our cached, valid online accounts data (if any) to help reduce CPU load for next block
|
||||
Controller.getInstance().pushLatestBlocksOnlineAccounts(this.cachedValidOnlineAccounts);
|
||||
|
||||
// Log some debugging info relating to the block weight calculation
|
||||
this.logDebugInfo();
|
||||
}
|
||||
|
||||
protected void increaseAccountLevels() throws DataException {
|
||||
@@ -1509,6 +1542,9 @@ public class Block {
|
||||
public void orphan() throws DataException {
|
||||
LOGGER.trace(() -> String.format("Orphaning block %d", this.blockData.getHeight()));
|
||||
|
||||
// Log some debugging info relating to the block weight calculation
|
||||
this.logDebugInfo();
|
||||
|
||||
// Return AT fees and delete AT states from repository
|
||||
orphanAtFeesAndStates();
|
||||
|
||||
@@ -1783,7 +1819,7 @@ public class Block {
|
||||
// Find all accounts in share bin. getShareBin() returns null for minter accounts that are also founders, so they are effectively filtered out.
|
||||
AccountLevelShareBin accountLevelShareBin = accountLevelShareBins.get(binIndex);
|
||||
// Object reference compare is OK as all references are read-only from blockchain config.
|
||||
List<ExpandedAccount> binnedAccounts = expandedAccounts.stream().filter(accountInfo -> accountInfo.getShareBin() == accountLevelShareBin).collect(Collectors.toList());
|
||||
List<ExpandedAccount> binnedAccounts = expandedAccounts.stream().filter(accountInfo -> accountInfo.getShareBin(this.blockData.getHeight()) == accountLevelShareBin).collect(Collectors.toList());
|
||||
|
||||
// No online accounts in this bin? Skip to next one
|
||||
if (binnedAccounts.isEmpty())
|
||||
@@ -1981,4 +2017,38 @@ public class Block {
|
||||
this.repository.getAccountRepository().tidy();
|
||||
}
|
||||
|
||||
private void logDebugInfo() {
|
||||
try {
|
||||
// Avoid calculations if possible. We have to check against INFO here, since Level.isMoreSpecificThan() confusingly uses <= rather than just <
|
||||
if (LOGGER.getLevel().isMoreSpecificThan(Level.INFO))
|
||||
return;
|
||||
|
||||
if (this.repository == null || this.getMinter() == null || this.getBlockData() == null)
|
||||
return;
|
||||
|
||||
int minterLevel = Account.getRewardShareEffectiveMintingLevel(this.repository, this.getMinter().getPublicKey());
|
||||
|
||||
LOGGER.debug(String.format("======= BLOCK %d (%.8s) =======", this.getBlockData().getHeight(), Base58.encode(this.getSignature())));
|
||||
LOGGER.debug(String.format("Timestamp: %d", this.getBlockData().getTimestamp()));
|
||||
LOGGER.debug(String.format("Minter level: %d", minterLevel));
|
||||
LOGGER.debug(String.format("Online accounts: %d", this.getBlockData().getOnlineAccountsCount()));
|
||||
LOGGER.debug(String.format("AT count: %d", this.getBlockData().getATCount()));
|
||||
|
||||
BlockSummaryData blockSummaryData = new BlockSummaryData(this.getBlockData());
|
||||
if (this.getParent() == null || this.getParent().getSignature() == null || blockSummaryData == null || minterLevel == 0)
|
||||
return;
|
||||
|
||||
blockSummaryData.setMinterLevel(minterLevel);
|
||||
BigInteger blockWeight = calcBlockWeight(this.getParent().getHeight(), this.getParent().getSignature(), blockSummaryData);
|
||||
BigInteger keyDistance = calcKeyDistance(this.getParent().getHeight(), this.getParent().getSignature(), blockSummaryData.getMinterPublicKey(), blockSummaryData.getMinterLevel());
|
||||
NumberFormat formatter = new DecimalFormat("0.###E0");
|
||||
|
||||
LOGGER.debug(String.format("Key distance: %s", formatter.format(keyDistance)));
|
||||
LOGGER.debug(String.format("Weight: %s", formatter.format(blockWeight)));
|
||||
|
||||
} catch (DataException e) {
|
||||
LOGGER.info(() -> String.format("Unable to log block debugging info: %s", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.InputStream;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import javax.xml.bind.JAXBContext;
|
||||
@@ -27,11 +24,9 @@ import org.eclipse.persistence.jaxb.UnmarshallerProperties;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.network.Network;
|
||||
import org.qortal.repository.BlockRepository;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.repository.*;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.StringLongMapXmlAdapter;
|
||||
|
||||
/**
|
||||
@@ -71,7 +66,9 @@ public class BlockChain {
|
||||
|
||||
public enum FeatureTrigger {
|
||||
atFindNextTransactionFix,
|
||||
newBlockSigHeight;
|
||||
newBlockSigHeight,
|
||||
shareBinFix,
|
||||
calcChainWeightTimestamp;
|
||||
}
|
||||
|
||||
/** Map of which blockchain features are enabled when (height/timestamp) */
|
||||
@@ -381,6 +378,14 @@ public class BlockChain {
|
||||
return this.featureTriggers.get(FeatureTrigger.newBlockSigHeight.name()).intValue();
|
||||
}
|
||||
|
||||
public int getShareBinFixHeight() {
|
||||
return this.featureTriggers.get(FeatureTrigger.shareBinFix.name()).intValue();
|
||||
}
|
||||
|
||||
public long getCalcChainWeightTimestamp() {
|
||||
return this.featureTriggers.get(FeatureTrigger.calcChainWeightTimestamp.name()).longValue();
|
||||
}
|
||||
|
||||
// More complex getters for aspects that change by height or timestamp
|
||||
|
||||
public long getRewardAtHeight(int ourHeight) {
|
||||
@@ -496,29 +501,100 @@ public class BlockChain {
|
||||
* @throws SQLException
|
||||
*/
|
||||
public static void validate() throws DataException {
|
||||
// Check first block is Genesis Block
|
||||
if (!isGenesisBlockValid())
|
||||
rebuildBlockchain();
|
||||
|
||||
boolean isTopOnly = Settings.getInstance().isTopOnly();
|
||||
boolean archiveEnabled = Settings.getInstance().isArchiveEnabled();
|
||||
boolean canBootstrap = Settings.getInstance().getBootstrap();
|
||||
boolean needsArchiveRebuild = false;
|
||||
BlockData chainTip;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
chainTip = repository.getBlockRepository().getLastBlock();
|
||||
|
||||
// Ensure archive is (at least partially) intact, and force a bootstrap if it isn't
|
||||
if (!isTopOnly && archiveEnabled && canBootstrap) {
|
||||
needsArchiveRebuild = (repository.getBlockArchiveRepository().fromHeight(2) == null);
|
||||
if (needsArchiveRebuild) {
|
||||
LOGGER.info("Couldn't retrieve block 2 from archive. Bootstrapping...");
|
||||
Controller.getInstance().exportRepositoryData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1);
|
||||
|
||||
if (isTopOnly && hasBlocks) {
|
||||
// Top-only mode is enabled and we have blocks, so it's possible that the genesis block has been pruned
|
||||
// It's best not to validate it, and there's no real need to
|
||||
} else {
|
||||
// Check first block is Genesis Block
|
||||
if (!isGenesisBlockValid() || needsArchiveRebuild) {
|
||||
try {
|
||||
rebuildBlockchain();
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We need to create a new connection, as the previous repository and its connections may be been
|
||||
// closed by rebuildBlockchain() if a bootstrap was applied
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
repository.checkConsistency();
|
||||
|
||||
int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - 1440, 1);
|
||||
// Set the number of blocks to validate based on the pruned state of the chain
|
||||
// If pruned, subtract an extra 10 to allow room for error
|
||||
int blocksToValidate = (isTopOnly || archiveEnabled) ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440;
|
||||
|
||||
int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1);
|
||||
BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight);
|
||||
|
||||
if (detachedBlockData != null) {
|
||||
LOGGER.error(String.format("Block %d's reference does not match any block's signature", detachedBlockData.getHeight()));
|
||||
LOGGER.error(String.format("Block %d's reference does not match any block's signature",
|
||||
detachedBlockData.getHeight()));
|
||||
LOGGER.error(String.format("Your chain may be invalid and you should consider bootstrapping" +
|
||||
" or re-syncing from genesis."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for blockchain lock (whereas orphan() only tries to get lock)
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
blockchainLock.lock();
|
||||
try {
|
||||
LOGGER.info(String.format("Orphaning back to block %d", detachedBlockData.getHeight() - 1));
|
||||
orphan(detachedBlockData.getHeight() - 1);
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
/**
|
||||
* More thorough blockchain validation method. Useful for validating bootstraps.
|
||||
* A DataException is thrown if anything is invalid.
|
||||
*
|
||||
* @throws DataException
|
||||
*/
|
||||
public static void validateAllBlocks() throws DataException {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
BlockData chainTip = repository.getBlockRepository().getLastBlock();
|
||||
final int chainTipHeight = chainTip.getHeight();
|
||||
final int oldestBlock = 1; // TODO: increase if in pruning mode
|
||||
byte[] lastReference = null;
|
||||
|
||||
for (int height = chainTipHeight; height > oldestBlock; height--) {
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
if (blockData == null) {
|
||||
blockData = repository.getBlockArchiveRepository().fromHeight(height);
|
||||
}
|
||||
|
||||
if (blockData == null) {
|
||||
String error = String.format("Missing block at height %d", height);
|
||||
LOGGER.error(error);
|
||||
throw new DataException(error);
|
||||
}
|
||||
|
||||
if (height != chainTipHeight) {
|
||||
// Check reference
|
||||
if (!Arrays.equals(blockData.getSignature(), lastReference)) {
|
||||
String error = String.format("Invalid reference for block at height %d: %s (should be %s)",
|
||||
height, Base58.encode(blockData.getReference()), Base58.encode(lastReference));
|
||||
LOGGER.error(error);
|
||||
throw new DataException(error);
|
||||
}
|
||||
}
|
||||
|
||||
lastReference = blockData.getReference();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,7 +617,15 @@ public class BlockChain {
|
||||
}
|
||||
}
|
||||
|
||||
private static void rebuildBlockchain() throws DataException {
|
||||
private static void rebuildBlockchain() throws DataException, InterruptedException {
|
||||
boolean shouldBootstrap = Settings.getInstance().getBootstrap();
|
||||
if (shouldBootstrap) {
|
||||
// Settings indicate that we should apply a bootstrap rather than rebuilding and syncing from genesis
|
||||
Bootstrap bootstrap = new Bootstrap();
|
||||
bootstrap.startImport();
|
||||
return;
|
||||
}
|
||||
|
||||
// (Re)build repository
|
||||
if (!RepositoryManager.wasPristineAtOpen())
|
||||
RepositoryManager.rebuild();
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.qortal.block;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.naming.Name;
|
||||
import org.qortal.repository.DataException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Invalid Name Registration Blocks
|
||||
* <p>
|
||||
* A node minted a version of block 535658 that contained one transaction:
|
||||
* a REGISTER_NAME transaction that attempted to register a name that was already registered.
|
||||
* <p>
|
||||
* This invalid transaction made block 535658 (rightly) invalid to several nodes,
|
||||
* which refused to use that block.
|
||||
* However, it seems there were no other nodes minting an alternative, valid block at that time
|
||||
* and so the chain stalled for several nodes in the network.
|
||||
* <p>
|
||||
* Additionally, the invalid block 535658 affected all new installations, regardless of whether
|
||||
* they synchronized from scratch (block 1) or used an 'official release' bootstrap.
|
||||
* <p>
|
||||
* The diagnosis found the following:
|
||||
* - The original problem occurred in block 535205 where for some unknown reason many nodes didn't
|
||||
* add the name from a REGISTER_NAME transaction to their Names table.
|
||||
* - As a result, those nodes had a corrupt db, because they weren't holding a record of the name.
|
||||
* - This invalid db then caused them to treat a candidate for block 535658 as valid when it
|
||||
* should have been invalid.
|
||||
* - As such, the chain continued on with a technically invalid block in it, for a subset of the network
|
||||
* <p>
|
||||
* As with block 212937, there were three options, but the only feasible one was to apply edits to block
|
||||
* 535658 to make it valid. There were several cross-chain trades completed after this block, so doing
|
||||
* any kind of rollback was out of the question.
|
||||
* <p>
|
||||
* To complicate things further, a custom data field was used for the first REGISTER_NAME transaction,
|
||||
* and the default data field was used for the second. So it was important that all nodes ended up with
|
||||
* the exact same data regardless of how they arrived there.
|
||||
* <p>
|
||||
* The invalid block 535658 signature is: <tt>3oiuDhok...NdXvCLEV</tt>.
|
||||
* <p>
|
||||
* The invalid transaction in block 212937 is:
|
||||
* <p>
|
||||
* <code><pre>
|
||||
{
|
||||
"type": "REGISTER_NAME",
|
||||
"timestamp": 1630739437517,
|
||||
"reference": "4peRechwSPxP6UkRj9Y8ox9YxkWb34sWk5zyMc1WyMxEsACxD4Gmm7LZVsQ6Skpze8QCSBMZasvEZg6RgdqkyADW",
|
||||
"fee": "0.00100000",
|
||||
"signature": "2t1CryCog8KPDBarzY5fDCKu499nfnUcGrz4Lz4w5wNb5nWqm7y126P48dChYY7huhufcBV3RJPkgKP4Ywxc1gXx",
|
||||
"txGroupId": 0,
|
||||
"blockHeight": 535658,
|
||||
"approvalStatus": "NOT_REQUIRED",
|
||||
"creatorAddress": "Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB",
|
||||
"registrantPublicKey": "HJqGEf6cW695Xun4ydhkB2excGFwsDxznhNCRHZStyyx",
|
||||
"name": "Qplay",
|
||||
"data": "Registered Name on the Qortal Chain"
|
||||
}
|
||||
</pre></code>
|
||||
* <p>
|
||||
* Account <tt>Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB</tt> attempted to register the name <tt>Qplay</tt>
|
||||
* when they had already registered it 12 hours before in block <tt>535205</tt>.
|
||||
* <p>
|
||||
* However, on the broken DB nodes, their Names table was missing a record for the `Qplay` name
|
||||
* which was sufficient to make the transaction valid.
|
||||
*
|
||||
* This problem then occurred two more times, in blocks 536140 and 541334
|
||||
* To reduce duplication, I have combined all three block fixes into a single class
|
||||
*
|
||||
*/
|
||||
public final class InvalidNameRegistrationBlocks {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(InvalidNameRegistrationBlocks.class);
|
||||
|
||||
public static Map<Integer, String> invalidBlocksNamesMap = new HashMap<Integer, String>()
|
||||
{
|
||||
{
|
||||
put(535658, "Qplay");
|
||||
put(536140, "Qweb");
|
||||
put(541334, "Qithub");
|
||||
}
|
||||
};
|
||||
|
||||
private InvalidNameRegistrationBlocks() {
|
||||
/* Do not instantiate */
|
||||
}
|
||||
|
||||
public static boolean isAffectedBlock(int height) {
|
||||
return (invalidBlocksNamesMap.containsKey(height));
|
||||
}
|
||||
|
||||
public static void processFix(Block block) throws DataException {
|
||||
Integer blockHeight = block.getBlockData().getHeight();
|
||||
String invalidName = invalidBlocksNamesMap.get(blockHeight);
|
||||
if (invalidName == null) {
|
||||
throw new DataException(String.format("Unable to lookup invalid name for block height %d", blockHeight));
|
||||
}
|
||||
|
||||
// Unregister the existing name record if it exists
|
||||
// This ensures that the duplicate name is considered valid, and therefore
|
||||
// the second (i.e. duplicate) REGISTER_NAME transaction data is applied.
|
||||
// Both were issued by the same user account, so there is no conflict.
|
||||
Name name = new Name(block.repository, invalidName);
|
||||
name.unregister();
|
||||
|
||||
LOGGER.debug("Applied name registration patch for block {}", blockHeight);
|
||||
}
|
||||
|
||||
// Note:
|
||||
// There is no need to write an orphanFix() method, as we do not have
|
||||
// the necessary ATStatesData to orphan back this far anyway
|
||||
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -215,8 +216,17 @@ public class AutoUpdate extends Thread {
|
||||
}
|
||||
|
||||
// Give repository a chance to backup in case things go badly wrong (if enabled)
|
||||
if (Settings.getInstance().getRepositoryBackupInterval() > 0)
|
||||
RepositoryManager.backup(true);
|
||||
if (Settings.getInstance().getRepositoryBackupInterval() > 0) {
|
||||
try {
|
||||
// Timeout if the database isn't ready for backing up after 60 seconds
|
||||
long timeout = 60 * 1000L;
|
||||
RepositoryManager.backup(true, "backup", timeout);
|
||||
|
||||
} catch (TimeoutException e) {
|
||||
LOGGER.info("Attempt to backup repository failed due to timeout: {}", e.getMessage());
|
||||
// Continue with the auto update anyway...
|
||||
}
|
||||
}
|
||||
|
||||
// Call ApplyUpdate to end this process (unlocking current JAR so it can be replaced)
|
||||
String javaHome = System.getProperty("java.home");
|
||||
|
||||
@@ -44,6 +44,9 @@ public class BlockMinter extends Thread {
|
||||
private static Long lastLogTimestamp;
|
||||
private static Long logTimeout;
|
||||
|
||||
// Recovery
|
||||
public static final long INVALID_BLOCK_RECOVERY_TIMEOUT = 10 * 60 * 1000L; // ms
|
||||
|
||||
// Constructors
|
||||
|
||||
public BlockMinter() {
|
||||
@@ -135,16 +138,35 @@ public class BlockMinter extends Thread {
|
||||
// Disregard peers that have "misbehaved" recently
|
||||
peers.removeIf(Controller.hasMisbehaved);
|
||||
|
||||
// Disregard peers that don't have a recent block
|
||||
peers.removeIf(Controller.hasNoRecentBlock);
|
||||
// Disregard peers that don't have a recent block, but only if we're not in recovery mode.
|
||||
// In that mode, we want to allow minting on top of older blocks, to recover stalled networks.
|
||||
if (Controller.getInstance().getRecoveryMode() == false)
|
||||
peers.removeIf(Controller.hasNoRecentBlock);
|
||||
|
||||
// Don't mint if we don't have enough up-to-date peers as where would the transactions/consensus come from?
|
||||
if (peers.size() < Settings.getInstance().getMinBlockchainPeers())
|
||||
continue;
|
||||
|
||||
// If our latest block isn't recent then we need to synchronize instead of minting.
|
||||
// If we are stuck on an invalid block, we should allow an alternative to be minted
|
||||
boolean recoverInvalidBlock = false;
|
||||
if (Synchronizer.getInstance().timeInvalidBlockLastReceived != null) {
|
||||
// We've had at least one invalid block
|
||||
long timeSinceLastValidBlock = NTP.getTime() - Synchronizer.getInstance().timeValidBlockLastReceived;
|
||||
long timeSinceLastInvalidBlock = NTP.getTime() - Synchronizer.getInstance().timeInvalidBlockLastReceived;
|
||||
if (timeSinceLastValidBlock > INVALID_BLOCK_RECOVERY_TIMEOUT) {
|
||||
if (timeSinceLastInvalidBlock < INVALID_BLOCK_RECOVERY_TIMEOUT) {
|
||||
// Last valid block was more than 10 mins ago, but we've had an invalid block since then
|
||||
// Assume that the chain has stalled because there is no alternative valid candidate
|
||||
// Enter recovery mode to allow alternative, valid candidates to be minted
|
||||
recoverInvalidBlock = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If our latest block isn't recent then we need to synchronize instead of minting, unless we're in recovery mode.
|
||||
if (!peers.isEmpty() && lastBlockData.getTimestamp() < minLatestBlockTimestamp)
|
||||
continue;
|
||||
if (Controller.getInstance().getRecoveryMode() == false && recoverInvalidBlock == false)
|
||||
continue;
|
||||
|
||||
// There are enough peers with a recent block and our latest block is recent
|
||||
// so go ahead and mint a block if possible.
|
||||
@@ -165,6 +187,14 @@ public class BlockMinter extends Thread {
|
||||
// Do we need to build any potential new blocks?
|
||||
List<PrivateKeyAccount> newBlocksMintingAccounts = mintingAccountsData.stream().map(accountData -> new PrivateKeyAccount(repository, accountData.getPrivateKey())).collect(Collectors.toList());
|
||||
|
||||
// We might need to sit the next block out, if one of our minting accounts signed the previous one
|
||||
final byte[] previousBlockMinter = previousBlockData.getMinterPublicKey();
|
||||
final boolean mintedLastBlock = mintingAccountsData.stream().anyMatch(mintingAccount -> Arrays.equals(mintingAccount.getPublicKey(), previousBlockMinter));
|
||||
if (mintedLastBlock) {
|
||||
LOGGER.trace(String.format("One of our keys signed the last block, so we won't sign the next one"));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (PrivateKeyAccount mintingAccount : newBlocksMintingAccounts) {
|
||||
// First block does the AT heavy-lifting
|
||||
if (newBlocks.isEmpty()) {
|
||||
@@ -219,6 +249,8 @@ public class BlockMinter extends Thread {
|
||||
if (testBlock.isTimestampValid() != ValidationResult.OK)
|
||||
continue;
|
||||
|
||||
testBlock.preProcess();
|
||||
|
||||
// Is new block valid yet? (Before adding unconfirmed transactions)
|
||||
ValidationResult result = testBlock.isValid();
|
||||
if (result != ValidationResult.OK) {
|
||||
@@ -282,15 +314,17 @@ public class BlockMinter extends Thread {
|
||||
RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(newBlock.getBlockData().getMinterPublicKey());
|
||||
|
||||
if (rewardShareData != null) {
|
||||
LOGGER.info(String.format("Minted block %d, sig %.8s by %s on behalf of %s",
|
||||
LOGGER.info(String.format("Minted block %d, sig %.8s, parent sig: %.8s by %s on behalf of %s",
|
||||
newBlock.getBlockData().getHeight(),
|
||||
Base58.encode(newBlock.getBlockData().getSignature()),
|
||||
Base58.encode(newBlock.getParent().getSignature()),
|
||||
rewardShareData.getMinter(),
|
||||
rewardShareData.getRecipient()));
|
||||
} else {
|
||||
LOGGER.info(String.format("Minted block %d, sig %.8s by %s",
|
||||
LOGGER.info(String.format("Minted block %d, sig %.8s, parent sig: %.8s by %s",
|
||||
newBlock.getBlockData().getHeight(),
|
||||
Base58.encode(newBlock.getBlockData().getSignature()),
|
||||
Base58.encode(newBlock.getParent().getSignature()),
|
||||
newBlock.getMinter().getAddress()));
|
||||
}
|
||||
|
||||
@@ -408,7 +442,8 @@ public class BlockMinter extends Thread {
|
||||
|
||||
// Add to blockchain
|
||||
newBlock.process();
|
||||
LOGGER.info(String.format("Minted new test block: %d", newBlock.getBlockData().getHeight()));
|
||||
LOGGER.info(String.format("Minted new test block: %d sig: %.8s",
|
||||
newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getBlockData().getSignature())));
|
||||
|
||||
repository.saveChanges();
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ package org.qortal.controller;
|
||||
|
||||
import java.awt.TrayIcon.MessageType;
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Security;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -24,7 +27,7 @@ import java.util.Properties;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Function;
|
||||
@@ -46,6 +49,8 @@ import org.qortal.block.Block;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.block.BlockChain.BlockTimingByHeight;
|
||||
import org.qortal.controller.Synchronizer.SynchronizationResult;
|
||||
import org.qortal.controller.repository.PruneManager;
|
||||
import org.qortal.controller.repository.NamesDatabaseIntegrityCheck;
|
||||
import org.qortal.controller.tradebot.TradeBot;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.account.MintingAccountData;
|
||||
@@ -67,8 +72,8 @@ import org.qortal.gui.SysTray;
|
||||
import org.qortal.network.Network;
|
||||
import org.qortal.network.Peer;
|
||||
import org.qortal.network.message.ArbitraryDataMessage;
|
||||
import org.qortal.network.message.BlockMessage;
|
||||
import org.qortal.network.message.BlockSummariesMessage;
|
||||
import org.qortal.network.message.CachedBlockMessage;
|
||||
import org.qortal.network.message.GetArbitraryDataMessage;
|
||||
import org.qortal.network.message.GetBlockMessage;
|
||||
import org.qortal.network.message.GetBlockSummariesMessage;
|
||||
@@ -83,21 +88,14 @@ import org.qortal.network.message.OnlineAccountsMessage;
|
||||
import org.qortal.network.message.SignaturesMessage;
|
||||
import org.qortal.network.message.TransactionMessage;
|
||||
import org.qortal.network.message.TransactionSignaturesMessage;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryFactory;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.repository.*;
|
||||
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.ArbitraryTransaction;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.ByteArray;
|
||||
import org.qortal.utils.DaemonThreadFactory;
|
||||
import org.qortal.utils.NTP;
|
||||
import org.qortal.utils.Triple;
|
||||
import org.qortal.utils.*;
|
||||
|
||||
import com.google.common.primitives.Longs;
|
||||
|
||||
@@ -121,6 +119,7 @@ public class Controller extends Thread {
|
||||
private static final long NTP_PRE_SYNC_CHECK_PERIOD = 5 * 1000L; // ms
|
||||
private static final long NTP_POST_SYNC_CHECK_PERIOD = 5 * 60 * 1000L; // ms
|
||||
private static final long DELETE_EXPIRED_INTERVAL = 5 * 60 * 1000L; // ms
|
||||
private static final long RECOVERY_MODE_TIMEOUT = 10 * 60 * 1000L; // ms
|
||||
|
||||
// To do with online accounts list
|
||||
private static final long ONLINE_ACCOUNTS_TASKS_INTERVAL = 10 * 1000L; // ms
|
||||
@@ -148,9 +147,9 @@ public class Controller extends Thread {
|
||||
|
||||
/** Cache of BlockMessages, indexed by block signature */
|
||||
@SuppressWarnings("serial")
|
||||
private final LinkedHashMap<ByteArray, BlockMessage> blockMessageCache = new LinkedHashMap<>() {
|
||||
private final LinkedHashMap<ByteArray, CachedBlockMessage> blockMessageCache = new LinkedHashMap<>() {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<ByteArray, BlockMessage> eldest) {
|
||||
protected boolean removeEldestEntry(Map.Entry<ByteArray, CachedBlockMessage> eldest) {
|
||||
return this.size() > Settings.getInstance().getBlockCacheSize();
|
||||
}
|
||||
};
|
||||
@@ -175,6 +174,11 @@ public class Controller extends Thread {
|
||||
/** Latest block signatures from other peers that we know are on inferior chains. */
|
||||
List<ByteArray> inferiorChainSignatures = new ArrayList<>();
|
||||
|
||||
/** Recovery mode, which is used to bring back a stalled network */
|
||||
private boolean recoveryMode = false;
|
||||
private boolean peersAvailable = true; // peersAvailable must default to true
|
||||
private long timePeersLastAvailable = 0;
|
||||
|
||||
/**
|
||||
* Map of recent requests for ARBITRARY transaction data payloads.
|
||||
* <p>
|
||||
@@ -253,17 +257,29 @@ public class Controller extends Thread {
|
||||
throw new RuntimeException("Can't read build.properties resource", e);
|
||||
}
|
||||
|
||||
// Determine build timestamp
|
||||
String buildTimestampProperty = properties.getProperty("build.timestamp");
|
||||
if (buildTimestampProperty == null)
|
||||
if (buildTimestampProperty == null) {
|
||||
throw new RuntimeException("Can't read build.timestamp from build.properties resource");
|
||||
|
||||
this.buildTimestamp = LocalDateTime.parse(buildTimestampProperty, DateTimeFormatter.ofPattern("yyyyMMddHHmmss")).toEpochSecond(ZoneOffset.UTC);
|
||||
}
|
||||
if (buildTimestampProperty.startsWith("$")) {
|
||||
// Maven vars haven't been replaced - this was most likely built using an IDE, not via mvn package
|
||||
this.buildTimestamp = System.currentTimeMillis();
|
||||
buildTimestampProperty = "unknown";
|
||||
} else {
|
||||
this.buildTimestamp = LocalDateTime.parse(buildTimestampProperty, DateTimeFormatter.ofPattern("yyyyMMddHHmmss")).toEpochSecond(ZoneOffset.UTC);
|
||||
}
|
||||
LOGGER.info(String.format("Build timestamp: %s", buildTimestampProperty));
|
||||
|
||||
// Determine build version
|
||||
String buildVersionProperty = properties.getProperty("build.version");
|
||||
if (buildVersionProperty == null)
|
||||
if (buildVersionProperty == null) {
|
||||
throw new RuntimeException("Can't read build.version from build.properties resource");
|
||||
|
||||
}
|
||||
if (buildVersionProperty.contains("${git.commit.id.abbrev}")) {
|
||||
// Maven vars haven't been replaced - this was most likely built using an IDE, not via mvn package
|
||||
buildVersionProperty = buildVersionProperty.replace("${git.commit.id.abbrev}", "debug");
|
||||
}
|
||||
this.buildVersion = VERSION_PREFIX + buildVersionProperty;
|
||||
LOGGER.info(String.format("Build version: %s", this.buildVersion));
|
||||
|
||||
@@ -296,6 +312,10 @@ public class Controller extends Thread {
|
||||
return this.buildVersion;
|
||||
}
|
||||
|
||||
public String getVersionStringWithoutPrefix() {
|
||||
return this.buildVersion.replaceFirst(VERSION_PREFIX, "");
|
||||
}
|
||||
|
||||
/** Returns current blockchain height, or 0 if it's not available. */
|
||||
public int getChainHeight() {
|
||||
synchronized (this.latestBlocks) {
|
||||
@@ -339,7 +359,7 @@ public class Controller extends Thread {
|
||||
return this.savedArgs;
|
||||
}
|
||||
|
||||
/* package */ static boolean isStopping() {
|
||||
public static boolean isStopping() {
|
||||
return isStopping;
|
||||
}
|
||||
|
||||
@@ -358,6 +378,10 @@ public class Controller extends Thread {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean getRecoveryMode() {
|
||||
return this.recoveryMode;
|
||||
}
|
||||
|
||||
// Entry point
|
||||
|
||||
public static void main(String[] args) {
|
||||
@@ -393,6 +417,12 @@ public class Controller extends Thread {
|
||||
try {
|
||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl());
|
||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||
RepositoryManager.setRequestedCheckpoint(Boolean.TRUE);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
RepositoryManager.archive(repository);
|
||||
RepositoryManager.prune(repository);
|
||||
}
|
||||
} catch (DataException e) {
|
||||
// If exception has no cause then repository is in use by some other process.
|
||||
if (e.getCause() == null) {
|
||||
@@ -406,6 +436,11 @@ public class Controller extends Thread {
|
||||
return; // Not System.exit() so that GUI can display error
|
||||
}
|
||||
|
||||
// Rebuild Names table and check database integrity
|
||||
NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck();
|
||||
namesDatabaseIntegrityCheck.rebuildAllNames();
|
||||
namesDatabaseIntegrityCheck.runIntegrityCheck();
|
||||
|
||||
LOGGER.info("Validating blockchain");
|
||||
try {
|
||||
BlockChain.validate();
|
||||
@@ -418,6 +453,9 @@ public class Controller extends Thread {
|
||||
return; // Not System.exit() so that GUI can display error
|
||||
}
|
||||
|
||||
// Import current trade bot states and minting accounts if they exist
|
||||
Controller.importRepositoryData();
|
||||
|
||||
LOGGER.info("Starting controller");
|
||||
Controller.getInstance().start();
|
||||
|
||||
@@ -488,9 +526,8 @@ public class Controller extends Thread {
|
||||
final long repositoryBackupInterval = Settings.getInstance().getRepositoryBackupInterval();
|
||||
final long repositoryCheckpointInterval = Settings.getInstance().getRepositoryCheckpointInterval();
|
||||
|
||||
ExecutorService trimExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory());
|
||||
trimExecutor.execute(new AtStatesTrimmer());
|
||||
trimExecutor.execute(new OnlineAccountsSignaturesTrimmer());
|
||||
// Start executor service for trimming or pruning
|
||||
PruneManager.getInstance().start();
|
||||
|
||||
try {
|
||||
while (!isStopping) {
|
||||
@@ -548,7 +585,14 @@ public class Controller extends Thread {
|
||||
Translator.INSTANCE.translate("SysTray", "CREATING_BACKUP_OF_DB_FILES"),
|
||||
MessageType.INFO);
|
||||
|
||||
RepositoryManager.backup(true);
|
||||
try {
|
||||
// Timeout if the database isn't ready for backing up after 60 seconds
|
||||
long timeout = 60 * 1000L;
|
||||
RepositoryManager.backup(true, "backup", timeout);
|
||||
|
||||
} catch (TimeoutException e) {
|
||||
LOGGER.info("Attempt to backup repository failed due to timeout: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Prune stuck/slow/old peers
|
||||
@@ -575,13 +619,48 @@ public class Controller extends Thread {
|
||||
Thread.interrupted();
|
||||
// Fall-through to exit
|
||||
} finally {
|
||||
trimExecutor.shutdownNow();
|
||||
PruneManager.getInstance().stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import current trade bot states and minting accounts.
|
||||
* This is needed because the user may have bootstrapped, or there could be a database inconsistency
|
||||
* if the core crashed when computing the nonce during the start of the trade process.
|
||||
*/
|
||||
private static void importRepositoryData() {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
String exportPath = Settings.getInstance().getExportPath();
|
||||
try {
|
||||
Path importPath = Paths.get(exportPath, "TradeBotStates.json");
|
||||
repository.importDataFromFile(importPath.toString());
|
||||
} catch (FileNotFoundException e) {
|
||||
// Do nothing, as the files will only exist in certain cases
|
||||
}
|
||||
|
||||
try {
|
||||
trimExecutor.awaitTermination(2L, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
// We tried...
|
||||
Path importPath = Paths.get(exportPath, "MintingAccounts.json");
|
||||
repository.importDataFromFile(importPath.toString());
|
||||
} catch (FileNotFoundException e) {
|
||||
// Do nothing, as the files will only exist in certain cases
|
||||
}
|
||||
repository.saveChanges();
|
||||
}
|
||||
catch (DataException | IOException e) {
|
||||
LOGGER.info("Unable to import data into repository: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current trade bot states and minting accounts.
|
||||
*/
|
||||
public void exportRepositoryData() {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
repository.exportNodeLocalData();
|
||||
|
||||
} catch (DataException e) {
|
||||
// Fail silently as this is an optional step
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,6 +692,11 @@ public class Controller extends Thread {
|
||||
return peerChainTipData == null || peerChainTipData.getLastBlockSignature() == null || inferiorChainTips.contains(new ByteArray(peerChainTipData.getLastBlockSignature()));
|
||||
};
|
||||
|
||||
public static final Predicate<Peer> hasOldVersion = peer -> {
|
||||
final String minPeerVersion = Settings.getInstance().getMinPeerVersion();
|
||||
return peer.isAtLeastVersion(minPeerVersion) == false;
|
||||
};
|
||||
|
||||
private void potentiallySynchronize() throws InterruptedException {
|
||||
// Already synchronizing via another thread?
|
||||
if (this.isSynchronizing)
|
||||
@@ -629,6 +713,17 @@ public class Controller extends Thread {
|
||||
// Disregard peers that don't have a recent block
|
||||
peers.removeIf(hasNoRecentBlock);
|
||||
|
||||
// Disregard peers that are on an old version
|
||||
peers.removeIf(hasOldVersion);
|
||||
|
||||
checkRecoveryModeForPeers(peers);
|
||||
if (recoveryMode) {
|
||||
peers = Network.getInstance().getHandshakedPeers();
|
||||
peers.removeIf(hasOnlyGenesisBlock);
|
||||
peers.removeIf(hasMisbehaved);
|
||||
peers.removeIf(hasOldVersion);
|
||||
}
|
||||
|
||||
// Check we have enough peers to potentially synchronize
|
||||
if (peers.size() < Settings.getInstance().getMinBlockchainPeers())
|
||||
return;
|
||||
@@ -639,9 +734,31 @@ public class Controller extends Thread {
|
||||
// Disregard peers that are on the same block as last sync attempt and we didn't like their chain
|
||||
peers.removeIf(hasInferiorChainTip);
|
||||
|
||||
final int peersBeforeComparison = peers.size();
|
||||
|
||||
// Request recent block summaries from the remaining peers, and locate our common block with each
|
||||
Synchronizer.getInstance().findCommonBlocksWithPeers(peers);
|
||||
|
||||
// Compare the peers against each other, and against our chain, which will return an updated list excluding those without common blocks
|
||||
peers = Synchronizer.getInstance().comparePeers(peers);
|
||||
|
||||
// We may have added more inferior chain tips when comparing peers, so remove any peers that are currently on those chains
|
||||
peers.removeIf(hasInferiorChainTip);
|
||||
|
||||
final int peersRemoved = peersBeforeComparison - peers.size();
|
||||
if (peersRemoved > 0 && peers.size() > 0)
|
||||
LOGGER.debug(String.format("Ignoring %d peers on inferior chains. Peers remaining: %d", peersRemoved, peers.size()));
|
||||
|
||||
if (peers.isEmpty())
|
||||
return;
|
||||
|
||||
if (peers.size() > 1) {
|
||||
StringBuilder finalPeersString = new StringBuilder();
|
||||
for (Peer peer : peers)
|
||||
finalPeersString = finalPeersString.length() > 0 ? finalPeersString.append(", ").append(peer) : finalPeersString.append(peer);
|
||||
LOGGER.debug(String.format("Choosing random peer from: [%s]", finalPeersString.toString()));
|
||||
}
|
||||
|
||||
// Pick random peer to sync with
|
||||
int index = new SecureRandom().nextInt(peers.size());
|
||||
Peer peer = peers.get(index);
|
||||
@@ -662,6 +779,7 @@ public class Controller extends Thread {
|
||||
hasStatusChanged = true;
|
||||
}
|
||||
}
|
||||
peer.setSyncInProgress(true);
|
||||
|
||||
if (hasStatusChanged)
|
||||
updateSysTray();
|
||||
@@ -741,9 +859,50 @@ public class Controller extends Thread {
|
||||
return syncResult;
|
||||
} finally {
|
||||
isSynchronizing = false;
|
||||
peer.setSyncInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkRecoveryModeForPeers(List<Peer> qualifiedPeers) {
|
||||
List<Peer> handshakedPeers = Network.getInstance().getHandshakedPeers();
|
||||
|
||||
if (handshakedPeers.size() > 0) {
|
||||
// There is at least one handshaked peer
|
||||
if (qualifiedPeers.isEmpty()) {
|
||||
// There are no 'qualified' peers - i.e. peers that have a recent block we can sync to
|
||||
boolean werePeersAvailable = peersAvailable;
|
||||
peersAvailable = false;
|
||||
|
||||
// If peers only just became unavailable, update our record of the time they were last available
|
||||
if (werePeersAvailable)
|
||||
timePeersLastAvailable = NTP.getTime();
|
||||
|
||||
// If enough time has passed, enter recovery mode, which lifts some restrictions on who we can sync with and when we can mint
|
||||
if (NTP.getTime() - timePeersLastAvailable > RECOVERY_MODE_TIMEOUT) {
|
||||
if (recoveryMode == false) {
|
||||
LOGGER.info(String.format("Peers have been unavailable for %d minutes. Entering recovery mode...", RECOVERY_MODE_TIMEOUT/60/1000));
|
||||
recoveryMode = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We now have at least one peer with a recent block, so we can exit recovery mode and sync normally
|
||||
peersAvailable = true;
|
||||
if (recoveryMode) {
|
||||
LOGGER.info("Peers have become available again. Exiting recovery mode...");
|
||||
recoveryMode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return recoveryMode;
|
||||
}
|
||||
|
||||
public void addInferiorChainSignature(byte[] inferiorSignature) {
|
||||
// Update our list of inferior chain tips
|
||||
ByteArray inferiorChainSignature = new ByteArray(inferiorSignature);
|
||||
if (!inferiorChainSignatures.contains(inferiorChainSignature))
|
||||
inferiorChainSignatures.add(inferiorChainSignature);
|
||||
}
|
||||
|
||||
public static class StatusChangeEvent implements Event {
|
||||
public StatusChangeEvent() {
|
||||
}
|
||||
@@ -752,6 +911,7 @@ public class Controller extends Thread {
|
||||
private void updateSysTray() {
|
||||
if (NTP.getTime() == null) {
|
||||
SysTray.getInstance().setToolTipText(Translator.INSTANCE.translate("SysTray", "SYNCHRONIZING_CLOCK"));
|
||||
SysTray.getInstance().setTrayIcon(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -765,17 +925,25 @@ public class Controller extends Thread {
|
||||
String actionText;
|
||||
|
||||
synchronized (this.syncLock) {
|
||||
if (this.isMintingPossible)
|
||||
if (this.isMintingPossible) {
|
||||
actionText = Translator.INSTANCE.translate("SysTray", "MINTING_ENABLED");
|
||||
else if (this.isSynchronizing)
|
||||
SysTray.getInstance().setTrayIcon(2);
|
||||
}
|
||||
else if (this.isSynchronizing) {
|
||||
actionText = String.format("%s - %d%%", Translator.INSTANCE.translate("SysTray", "SYNCHRONIZING_BLOCKCHAIN"), this.syncPercent);
|
||||
else if (numberOfPeers < Settings.getInstance().getMinBlockchainPeers())
|
||||
SysTray.getInstance().setTrayIcon(3);
|
||||
}
|
||||
else if (numberOfPeers < Settings.getInstance().getMinBlockchainPeers()) {
|
||||
actionText = Translator.INSTANCE.translate("SysTray", "CONNECTING");
|
||||
else
|
||||
SysTray.getInstance().setTrayIcon(3);
|
||||
}
|
||||
else {
|
||||
actionText = Translator.INSTANCE.translate("SysTray", "MINTING_DISABLED");
|
||||
SysTray.getInstance().setTrayIcon(4);
|
||||
}
|
||||
}
|
||||
|
||||
String tooltip = String.format("%s - %d %s - %s %d", actionText, numberOfPeers, connectionsText, heightText, height);
|
||||
String tooltip = String.format("%s - %d %s - %s %d", actionText, numberOfPeers, connectionsText, heightText, height) + "\n" + String.format("%s: %s", Translator.INSTANCE.translate("SysTray", "BUILD_VERSION"), this.buildVersion);
|
||||
SysTray.getInstance().setToolTipText(tooltip);
|
||||
|
||||
this.callbackExecutor.execute(() -> {
|
||||
@@ -795,14 +963,19 @@ public class Controller extends Thread {
|
||||
|
||||
List<TransactionData> transactions = repository.getTransactionRepository().getUnconfirmedTransactions();
|
||||
|
||||
int deletedCount = 0;
|
||||
for (TransactionData transactionData : transactions) {
|
||||
Transaction transaction = Transaction.fromData(repository, transactionData);
|
||||
|
||||
if (now >= transaction.getDeadline()) {
|
||||
LOGGER.info(() -> String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
|
||||
LOGGER.debug(() -> String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
|
||||
repository.getTransactionRepository().delete(transactionData);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
if (deletedCount > 0) {
|
||||
LOGGER.info(String.format("Deleted %d expired, unconfirmed transaction%s", deletedCount, (deletedCount == 1 ? "" : "s")));
|
||||
}
|
||||
|
||||
repository.saveChanges();
|
||||
} catch (DataException e) {
|
||||
@@ -842,6 +1015,10 @@ public class Controller extends Thread {
|
||||
}
|
||||
}
|
||||
|
||||
// Export local data
|
||||
LOGGER.info("Backing up local data");
|
||||
this.exportRepositoryData();
|
||||
|
||||
LOGGER.info("Shutting down networking");
|
||||
Network.getInstance().shutdown();
|
||||
|
||||
@@ -1151,7 +1328,7 @@ public class Controller extends Thread {
|
||||
|
||||
ByteArray signatureAsByteArray = new ByteArray(signature);
|
||||
|
||||
BlockMessage cachedBlockMessage = this.blockMessageCache.get(signatureAsByteArray);
|
||||
CachedBlockMessage cachedBlockMessage = this.blockMessageCache.get(signatureAsByteArray);
|
||||
int blockCacheSize = Settings.getInstance().getBlockCacheSize();
|
||||
|
||||
// Check cached latest block message
|
||||
@@ -1159,7 +1336,7 @@ public class Controller extends Thread {
|
||||
this.stats.getBlockMessageStats.cacheHits.incrementAndGet();
|
||||
|
||||
// We need to duplicate it to prevent multiple threads setting ID on the same message
|
||||
BlockMessage clonedBlockMessage = cachedBlockMessage.cloneWithNewId(message.getId());
|
||||
CachedBlockMessage clonedBlockMessage = cachedBlockMessage.cloneWithNewId(message.getId());
|
||||
|
||||
if (!peer.sendMessage(clonedBlockMessage))
|
||||
peer.disconnect("failed to send block");
|
||||
@@ -1170,6 +1347,41 @@ public class Controller extends Thread {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
BlockData blockData = repository.getBlockRepository().fromSignature(signature);
|
||||
|
||||
if (blockData != null) {
|
||||
if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) {
|
||||
// If this is a pruned block, we likely only have partial data, so best not to sent it
|
||||
blockData = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have no block data, we should check the archive in case it's there
|
||||
if (blockData == null) {
|
||||
if (Settings.getInstance().isArchiveEnabled()) {
|
||||
byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, repository);
|
||||
if (bytes != null) {
|
||||
CachedBlockMessage blockMessage = new CachedBlockMessage(bytes);
|
||||
blockMessage.setId(message.getId());
|
||||
|
||||
// This call also causes the other needed data to be pulled in from repository
|
||||
if (!peer.sendMessage(blockMessage)) {
|
||||
peer.disconnect("failed to send block");
|
||||
// Don't fall-through to caching because failure to send might be from failure to build message
|
||||
return;
|
||||
}
|
||||
|
||||
// If request is for a recent block, cache it
|
||||
if (getChainHeight() - blockData.getHeight() <= blockCacheSize) {
|
||||
this.stats.getBlockMessageStats.cacheFills.incrementAndGet();
|
||||
|
||||
this.blockMessageCache.put(new ByteArray(blockData.getSignature()), blockMessage);
|
||||
}
|
||||
|
||||
// Sent successfully from archive, so nothing more to do
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blockData == null) {
|
||||
// We don't have this block
|
||||
this.stats.getBlockMessageStats.unknownBlocks.getAndIncrement();
|
||||
@@ -1187,12 +1399,15 @@ public class Controller extends Thread {
|
||||
|
||||
Block block = new Block(repository, blockData);
|
||||
|
||||
BlockMessage blockMessage = new BlockMessage(block);
|
||||
CachedBlockMessage blockMessage = new CachedBlockMessage(block);
|
||||
blockMessage.setId(message.getId());
|
||||
|
||||
// This call also causes the other needed data to be pulled in from repository
|
||||
if (!peer.sendMessage(blockMessage))
|
||||
if (!peer.sendMessage(blockMessage)) {
|
||||
peer.disconnect("failed to send block");
|
||||
// Don't fall-through to caching because failure to send might be from failure to build message
|
||||
return;
|
||||
}
|
||||
|
||||
// If request is for a recent block, cache it
|
||||
if (getChainHeight() - blockData.getHeight() <= blockCacheSize) {
|
||||
@@ -1209,6 +1424,18 @@ public class Controller extends Thread {
|
||||
TransactionMessage transactionMessage = (TransactionMessage) message;
|
||||
TransactionData transactionData = transactionMessage.getTransactionData();
|
||||
|
||||
/*
|
||||
* If we can't obtain blockchain lock immediately,
|
||||
* e.g. Synchronizer is active, or another transaction is taking a while to validate,
|
||||
* then we're using up a network thread for ages and clogging things up
|
||||
* so bail out early
|
||||
*/
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
if (!blockchainLock.tryLock()) {
|
||||
LOGGER.trace(() -> String.format("Too busy to import %s transaction %s from peer %s", transactionData.getType().name(), Base58.encode(transactionData.getSignature()), peer));
|
||||
return;
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Transaction transaction = Transaction.fromData(repository, transactionData);
|
||||
|
||||
@@ -1238,6 +1465,8 @@ public class Controller extends Thread {
|
||||
LOGGER.debug(() -> String.format("Imported %s transaction %s from peer %s", transactionData.getType().name(), Base58.encode(transactionData.getSignature()), peer));
|
||||
} catch (DataException e) {
|
||||
LOGGER.error(String.format("Repository issue while processing transaction %s from peer %s", Base58.encode(transactionData.getSignature()), peer), e);
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,6 +1503,14 @@ public class Controller extends Thread {
|
||||
|
||||
BlockData blockData = repository.getBlockRepository().fromReference(parentSignature);
|
||||
|
||||
if (blockData != null) {
|
||||
if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) {
|
||||
// If this request contains a pruned block, we likely only have partial data, so best not to sent anything
|
||||
// We always prune from the oldest first, so it's fine to just check the first block requested
|
||||
blockData = null;
|
||||
}
|
||||
}
|
||||
|
||||
while (blockData != null && blockSummaries.size() < numberRequested) {
|
||||
BlockSummaryData blockSummary = new BlockSummaryData(blockData);
|
||||
blockSummaries.add(blockSummary);
|
||||
|
||||
@@ -3,9 +3,7 @@ package org.qortal.controller;
|
||||
import java.math.BigInteger;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -15,8 +13,10 @@ import org.qortal.account.Account;
|
||||
import org.qortal.account.PublicKeyAccount;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.block.Block.ValidationResult;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.block.BlockSummaryData;
|
||||
import org.qortal.data.block.CommonBlockData;
|
||||
import org.qortal.data.network.PeerChainTipData;
|
||||
import org.qortal.data.transaction.RewardShareTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
@@ -32,17 +32,37 @@ import org.qortal.network.message.Message.MessageType;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
public class Synchronizer {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(Synchronizer.class);
|
||||
|
||||
/** Max number of new blocks we aim to add to chain tip in each sync round */
|
||||
private static final int SYNC_BATCH_SIZE = 200; // XXX move to Settings?
|
||||
|
||||
/** Initial jump back of block height when searching for common block with peer */
|
||||
private static final int INITIAL_BLOCK_STEP = 8;
|
||||
private static final int MAXIMUM_BLOCK_STEP = 500;
|
||||
/** Maximum jump back of block height when searching for common block with peer */
|
||||
private static final int MAXIMUM_BLOCK_STEP = 128;
|
||||
|
||||
/** Maximum difference in block height between tip and peer's common block before peer is considered TOO DIVERGENT */
|
||||
private static final int MAXIMUM_COMMON_DELTA = 240; // XXX move to Settings?
|
||||
private static final int SYNC_BATCH_SIZE = 200;
|
||||
|
||||
/** Maximum number of block signatures we ask from peer in one go */
|
||||
private static final int MAXIMUM_REQUEST_SIZE = 200; // XXX move to Settings?
|
||||
|
||||
|
||||
// Keep track of the size of the last re-org, so it can be logged
|
||||
private int lastReorgSize;
|
||||
|
||||
// Keep track of invalid blocks so that we don't keep trying to sync them
|
||||
private Map<String, Long> invalidBlockSignatures = Collections.synchronizedMap(new HashMap<>());
|
||||
public Long timeValidBlockLastReceived = null;
|
||||
public Long timeInvalidBlockLastReceived = null;
|
||||
|
||||
private static Synchronizer instance;
|
||||
|
||||
@@ -62,6 +82,477 @@ public class Synchronizer {
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Iterate through a list of supplied peers, and attempt to find our common block with each.
|
||||
* If a common block is found, its summary will be retained in the peer's commonBlockSummary property, for processing later.
|
||||
* <p>
|
||||
* Will return <tt>SynchronizationResult.OK</tt> on success.
|
||||
* <p>
|
||||
* @param peers
|
||||
* @return SynchronizationResult.OK if the process completed successfully, or a different SynchronizationResult if something went wrong.
|
||||
* @throws InterruptedException
|
||||
*/
|
||||
public SynchronizationResult findCommonBlocksWithPeers(List<Peer> peers) throws InterruptedException {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
try {
|
||||
|
||||
if (peers.size() == 0)
|
||||
return SynchronizationResult.NOTHING_TO_DO;
|
||||
|
||||
// If our latest block is very old, it's best that we don't try and determine the best peers to sync to.
|
||||
// This is because it can involve very large chain comparisons, which is too intensive.
|
||||
// In reality, most forking problems occur near the chain tips, so we will reserve this functionality for those situations.
|
||||
final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp();
|
||||
if (minLatestBlockTimestamp == null)
|
||||
return SynchronizationResult.REPOSITORY_ISSUE;
|
||||
|
||||
final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock();
|
||||
if (ourLatestBlockData.getTimestamp() < minLatestBlockTimestamp) {
|
||||
LOGGER.debug(String.format("Our latest block is very old, so we won't collect common block info from peers"));
|
||||
return SynchronizationResult.NOTHING_TO_DO;
|
||||
}
|
||||
|
||||
LOGGER.debug(String.format("Searching for common blocks with %d peers...", peers.size()));
|
||||
final long startTime = System.currentTimeMillis();
|
||||
int commonBlocksFound = 0;
|
||||
boolean wereNewRequestsMade = false;
|
||||
|
||||
for (Peer peer : peers) {
|
||||
// Are we shutting down?
|
||||
if (Controller.isStopping())
|
||||
return SynchronizationResult.SHUTTING_DOWN;
|
||||
|
||||
// Check if we can use the cached common block data, by comparing the peer's current chain tip against the peer's chain tip when we last found our common block
|
||||
if (peer.canUseCachedCommonBlockData()) {
|
||||
LOGGER.debug(String.format("Skipping peer %s because we already have the latest common block data in our cache. Cached common block sig is %.08s", peer, Base58.encode(peer.getCommonBlockData().getCommonBlockSummary().getSignature())));
|
||||
commonBlocksFound++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cached data is stale, so clear it and repopulate
|
||||
peer.setCommonBlockData(null);
|
||||
|
||||
// Search for the common block
|
||||
Synchronizer.getInstance().findCommonBlockWithPeer(peer, repository);
|
||||
if (peer.getCommonBlockData() != null)
|
||||
commonBlocksFound++;
|
||||
|
||||
// This round wasn't served entirely from the cache, so we may want to log the results
|
||||
wereNewRequestsMade = true;
|
||||
}
|
||||
|
||||
if (wereNewRequestsMade) {
|
||||
final long totalTimeTaken = System.currentTimeMillis() - startTime;
|
||||
LOGGER.debug(String.format("Finished searching for common blocks with %d peer%s. Found: %d. Total time taken: %d ms", peers.size(), (peers.size() != 1 ? "s" : ""), commonBlocksFound, totalTimeTaken));
|
||||
}
|
||||
|
||||
return SynchronizationResult.OK;
|
||||
} finally {
|
||||
repository.discardChanges(); // Free repository locks, if any, also in case anything went wrong
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Repository issue during synchronization with peer", e);
|
||||
return SynchronizationResult.REPOSITORY_ISSUE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to find the find our common block with supplied peer.
|
||||
* If a common block is found, its summary will be retained in the peer's commonBlockSummary property, for processing later.
|
||||
* <p>
|
||||
* Will return <tt>SynchronizationResult.OK</tt> on success.
|
||||
* <p>
|
||||
* @param peer
|
||||
* @param repository
|
||||
* @return SynchronizationResult.OK if the process completed successfully, or a different SynchronizationResult if something went wrong.
|
||||
* @throws InterruptedException
|
||||
*/
|
||||
public SynchronizationResult findCommonBlockWithPeer(Peer peer, Repository repository) throws InterruptedException {
|
||||
try {
|
||||
final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock();
|
||||
final int ourInitialHeight = ourLatestBlockData.getHeight();
|
||||
|
||||
PeerChainTipData peerChainTipData = peer.getChainTipData();
|
||||
int peerHeight = peerChainTipData.getLastHeight();
|
||||
byte[] peersLastBlockSignature = peerChainTipData.getLastBlockSignature();
|
||||
|
||||
byte[] ourLastBlockSignature = ourLatestBlockData.getSignature();
|
||||
LOGGER.debug(String.format("Fetching summaries from peer %s at height %d, sig %.8s, ts %d; our height %d, sig %.8s, ts %d", peer,
|
||||
peerHeight, Base58.encode(peersLastBlockSignature), peer.getChainTipData().getLastBlockTimestamp(),
|
||||
ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp()));
|
||||
|
||||
List<BlockSummaryData> peerBlockSummaries = new ArrayList<>();
|
||||
SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, false, peerBlockSummaries, false);
|
||||
if (findCommonBlockResult != SynchronizationResult.OK) {
|
||||
// Logging performed by fetchSummariesFromCommonBlock() above
|
||||
peer.setCommonBlockData(null);
|
||||
return findCommonBlockResult;
|
||||
}
|
||||
|
||||
// First summary is common block
|
||||
final BlockData commonBlockData = repository.getBlockRepository().fromSignature(peerBlockSummaries.get(0).getSignature());
|
||||
final BlockSummaryData commonBlockSummary = new BlockSummaryData(commonBlockData);
|
||||
final int commonBlockHeight = commonBlockData.getHeight();
|
||||
final byte[] commonBlockSig = commonBlockData.getSignature();
|
||||
final String commonBlockSig58 = Base58.encode(commonBlockSig);
|
||||
LOGGER.debug(String.format("Common block with peer %s is at height %d, sig %.8s, ts %d", peer,
|
||||
commonBlockHeight, commonBlockSig58, commonBlockData.getTimestamp()));
|
||||
peerBlockSummaries.remove(0);
|
||||
|
||||
// Store the common block summary against the peer, and the current chain tip (for caching)
|
||||
peer.setCommonBlockData(new CommonBlockData(commonBlockSummary, peerChainTipData));
|
||||
|
||||
return SynchronizationResult.OK;
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Repository issue during synchronization with peer", e);
|
||||
return SynchronizationResult.REPOSITORY_ISSUE;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Compare a list of peers to determine the best peer(s) to sync to next.
|
||||
* <p>
|
||||
* Will return a filtered list of peers on success, or an identical list of peers on failure.
|
||||
* This allows us to fall back to legacy behaviour (random selection from the entire list of peers), if we are unable to make the comparison.
|
||||
* <p>
|
||||
* @param peers
|
||||
* @return a list of peers, possibly filtered.
|
||||
* @throws InterruptedException
|
||||
*/
|
||||
public List<Peer> comparePeers(List<Peer> peers) throws InterruptedException {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
try {
|
||||
|
||||
// If our latest block is very old, it's best that we don't try and determine the best peers to sync to.
|
||||
// This is because it can involve very large chain comparisons, which is too intensive.
|
||||
// In reality, most forking problems occur near the chain tips, so we will reserve this functionality for those situations.
|
||||
final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp();
|
||||
if (minLatestBlockTimestamp == null)
|
||||
return peers;
|
||||
|
||||
final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock();
|
||||
if (ourLatestBlockData.getTimestamp() < minLatestBlockTimestamp) {
|
||||
LOGGER.debug(String.format("Our latest block is very old, so we won't filter the peers list"));
|
||||
return peers;
|
||||
}
|
||||
|
||||
// We will switch to a new chain weight consensus algorithm at a hard fork, so determine if this has happened yet
|
||||
boolean usingSameLengthChainWeight = (NTP.getTime() >= BlockChain.getInstance().getCalcChainWeightTimestamp());
|
||||
LOGGER.debug(String.format("Using %s chain weight consensus algorithm", (usingSameLengthChainWeight ? "same-length" : "variable-length")));
|
||||
|
||||
// Retrieve a list of unique common blocks from this list of peers
|
||||
List<BlockSummaryData> commonBlocks = this.uniqueCommonBlocks(peers);
|
||||
|
||||
// Order common blocks by height, in ascending order
|
||||
// This is essential for the logic below to make the correct decisions when discarding chains - do not remove
|
||||
commonBlocks.sort((b1, b2) -> Integer.valueOf(b1.getHeight()).compareTo(Integer.valueOf(b2.getHeight())));
|
||||
|
||||
// Get our latest height
|
||||
final int ourHeight = ourLatestBlockData.getHeight();
|
||||
|
||||
// Create a placeholder to track of common blocks that we can discard due to being inferior chains
|
||||
int dropPeersAfterCommonBlockHeight = 0;
|
||||
|
||||
// Remove peers with no common block data
|
||||
Iterator iterator = peers.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Peer peer = (Peer) iterator.next();
|
||||
if (peer.getCommonBlockData() == null) {
|
||||
LOGGER.debug(String.format("Removed peer %s because it has no common block data", peer));
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Loop through each group of common blocks
|
||||
for (BlockSummaryData commonBlockSummary : commonBlocks) {
|
||||
List<Peer> peersSharingCommonBlock = peers.stream().filter(peer -> peer.getCommonBlockData().getCommonBlockSummary().equals(commonBlockSummary)).collect(Collectors.toList());
|
||||
|
||||
// Check if we need to discard this group of peers
|
||||
if (dropPeersAfterCommonBlockHeight > 0) {
|
||||
if (commonBlockSummary.getHeight() > dropPeersAfterCommonBlockHeight) {
|
||||
// We have already determined that the correct chain diverged from a lower height. We are safe to skip these peers.
|
||||
for (Peer peer : peersSharingCommonBlock) {
|
||||
LOGGER.debug(String.format("Peer %s has common block at height %d but the superior chain is at height %d. Removing it from this round.", peer, commonBlockSummary.getHeight(), dropPeersAfterCommonBlockHeight));
|
||||
Controller.getInstance().addInferiorChainSignature(peer.getChainTipData().getLastBlockSignature());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the length of the shortest peer chain sharing this common block, including our chain
|
||||
final int ourAdditionalBlocksAfterCommonBlock = ourHeight - commonBlockSummary.getHeight();
|
||||
int minChainLength = this.calculateMinChainLengthOfPeers(peersSharingCommonBlock, commonBlockSummary);
|
||||
|
||||
// Fetch block summaries from each peer
|
||||
for (Peer peer : peersSharingCommonBlock) {
|
||||
|
||||
// If we're shutting down, just return the latest peer list
|
||||
if (Controller.isStopping())
|
||||
return peers;
|
||||
|
||||
// Count the number of blocks this peer has beyond our common block
|
||||
final PeerChainTipData peerChainTipData = peer.getChainTipData();
|
||||
final int peerHeight = peerChainTipData.getLastHeight();
|
||||
final byte[] peerLastBlockSignature = peerChainTipData.getLastBlockSignature();
|
||||
final int peerAdditionalBlocksAfterCommonBlock = peerHeight - commonBlockSummary.getHeight();
|
||||
// Limit the number of blocks we are comparing. FUTURE: we could request more in batches, but there may not be a case when this is needed
|
||||
int summariesRequired = Math.min(peerAdditionalBlocksAfterCommonBlock, MAXIMUM_REQUEST_SIZE);
|
||||
|
||||
// Check if we can use the cached common block summaries, by comparing the peer's current chain tip against the peer's chain tip when we last found our common block
|
||||
boolean useCachedSummaries = false;
|
||||
if (peer.canUseCachedCommonBlockData()) {
|
||||
if (peer.getCommonBlockData().getBlockSummariesAfterCommonBlock() != null) {
|
||||
if (peer.getCommonBlockData().getBlockSummariesAfterCommonBlock().size() == summariesRequired) {
|
||||
LOGGER.trace(String.format("Using cached block summaries for peer %s", peer));
|
||||
useCachedSummaries = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useCachedSummaries == false) {
|
||||
if (summariesRequired > 0) {
|
||||
LOGGER.trace(String.format("Requesting %d block summar%s from peer %s after common block %.8s. Peer height: %d", summariesRequired, (summariesRequired != 1 ? "ies" : "y"), peer, Base58.encode(commonBlockSummary.getSignature()), peerHeight));
|
||||
|
||||
// Forget any cached summaries
|
||||
peer.getCommonBlockData().setBlockSummariesAfterCommonBlock(null);
|
||||
|
||||
// Request new block summaries
|
||||
List<BlockSummaryData> blockSummaries = this.getBlockSummaries(peer, commonBlockSummary.getSignature(), summariesRequired);
|
||||
if (blockSummaries != null) {
|
||||
LOGGER.trace(String.format("Peer %s returned %d block summar%s", peer, blockSummaries.size(), (blockSummaries.size() != 1 ? "ies" : "y")));
|
||||
|
||||
if (blockSummaries.size() < summariesRequired)
|
||||
// This could mean that the peer has re-orged. Exclude this peer until they return the summaries we expect.
|
||||
LOGGER.debug(String.format("Peer %s returned %d block summar%s instead of expected %d - excluding them from this round", peer, blockSummaries.size(), (blockSummaries.size() != 1 ? "ies" : "y"), summariesRequired));
|
||||
else if (blockSummaryWithSignature(peerLastBlockSignature, blockSummaries) == null)
|
||||
// We don't have a block summary for the peer's reported chain tip, so should exclude it
|
||||
LOGGER.debug(String.format("Peer %s didn't return a block summary with signature %.8s - excluding them from this round", peer, Base58.encode(peerLastBlockSignature)));
|
||||
else
|
||||
// All looks good, so store the retrieved block summaries in the peer's cache
|
||||
peer.getCommonBlockData().setBlockSummariesAfterCommonBlock(blockSummaries);
|
||||
}
|
||||
} else {
|
||||
// There are no block summaries after this common block
|
||||
peer.getCommonBlockData().setBlockSummariesAfterCommonBlock(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore this peer if it holds an invalid block
|
||||
if (this.containsInvalidBlockSummary(peer.getCommonBlockData().getBlockSummariesAfterCommonBlock())) {
|
||||
LOGGER.debug("Ignoring peer %s because it holds an invalid block", peer);
|
||||
peers.remove(peer);
|
||||
}
|
||||
|
||||
// Reduce minChainLength if needed. If we don't have any blocks, this peer will be excluded from chain weight comparisons later in the process, so we shouldn't update minChainLength
|
||||
List <BlockSummaryData> peerBlockSummaries = peer.getCommonBlockData().getBlockSummariesAfterCommonBlock();
|
||||
if (peerBlockSummaries != null && peerBlockSummaries.size() > 0)
|
||||
if (peerBlockSummaries.size() < minChainLength)
|
||||
minChainLength = peerBlockSummaries.size();
|
||||
}
|
||||
|
||||
// Fetch our corresponding block summaries. Limit to MAXIMUM_REQUEST_SIZE, in order to make the comparison fairer, as peers have been limited too
|
||||
final int ourSummariesRequired = Math.min(ourAdditionalBlocksAfterCommonBlock, MAXIMUM_REQUEST_SIZE);
|
||||
LOGGER.trace(String.format("About to fetch our block summaries from %d to %d. Our height: %d", commonBlockSummary.getHeight() + 1, commonBlockSummary.getHeight() + ourSummariesRequired, ourHeight));
|
||||
List<BlockSummaryData> ourBlockSummaries = repository.getBlockRepository().getBlockSummaries(commonBlockSummary.getHeight() + 1, commonBlockSummary.getHeight() + ourSummariesRequired);
|
||||
if (ourBlockSummaries.isEmpty()) {
|
||||
LOGGER.debug(String.format("We don't have any block summaries so can't compare our chain against peers with this common block. We can still compare them against each other."));
|
||||
}
|
||||
else {
|
||||
populateBlockSummariesMinterLevels(repository, ourBlockSummaries);
|
||||
// Reduce minChainLength if we have less summaries
|
||||
if (ourBlockSummaries.size() < minChainLength)
|
||||
minChainLength = ourBlockSummaries.size();
|
||||
}
|
||||
|
||||
// Create array to hold peers for comparison
|
||||
List<Peer> superiorPeersForComparison = new ArrayList<>();
|
||||
|
||||
// Calculate max height for chain weight comparisons
|
||||
int maxHeightForChainWeightComparisons = commonBlockSummary.getHeight() + minChainLength;
|
||||
|
||||
// Calculate our chain weight
|
||||
BigInteger ourChainWeight = BigInteger.valueOf(0);
|
||||
if (ourBlockSummaries.size() > 0)
|
||||
ourChainWeight = Block.calcChainWeight(commonBlockSummary.getHeight(), commonBlockSummary.getSignature(), ourBlockSummaries, maxHeightForChainWeightComparisons);
|
||||
|
||||
NumberFormat formatter = new DecimalFormat("0.###E0");
|
||||
NumberFormat accurateFormatter = new DecimalFormat("0.################E0");
|
||||
LOGGER.debug(String.format("Our chain weight based on %d blocks is %s", (usingSameLengthChainWeight ? minChainLength : ourBlockSummaries.size()), formatter.format(ourChainWeight)));
|
||||
|
||||
LOGGER.debug(String.format("Listing peers with common block %.8s...", Base58.encode(commonBlockSummary.getSignature())));
|
||||
for (Peer peer : peersSharingCommonBlock) {
|
||||
final int peerHeight = peer.getChainTipData().getLastHeight();
|
||||
final int peerAdditionalBlocksAfterCommonBlock = peerHeight - commonBlockSummary.getHeight();
|
||||
final CommonBlockData peerCommonBlockData = peer.getCommonBlockData();
|
||||
|
||||
if (peerCommonBlockData == null || peerCommonBlockData.getBlockSummariesAfterCommonBlock() == null || peerCommonBlockData.getBlockSummariesAfterCommonBlock().isEmpty()) {
|
||||
// No response - remove this peer for now
|
||||
LOGGER.debug(String.format("Peer %s doesn't have any block summaries - removing it from this round", peer));
|
||||
peers.remove(peer);
|
||||
continue;
|
||||
}
|
||||
|
||||
final List<BlockSummaryData> peerBlockSummariesAfterCommonBlock = peerCommonBlockData.getBlockSummariesAfterCommonBlock();
|
||||
populateBlockSummariesMinterLevels(repository, peerBlockSummariesAfterCommonBlock);
|
||||
|
||||
// Calculate cumulative chain weight of this blockchain subset, from common block to highest mutual block held by all peers in this group.
|
||||
LOGGER.debug(String.format("About to calculate chain weight based on %d blocks for peer %s with common block %.8s (peer has %d blocks after common block)", (usingSameLengthChainWeight ? minChainLength : peerBlockSummariesAfterCommonBlock.size()), peer, Base58.encode(commonBlockSummary.getSignature()), peerAdditionalBlocksAfterCommonBlock));
|
||||
BigInteger peerChainWeight = Block.calcChainWeight(commonBlockSummary.getHeight(), commonBlockSummary.getSignature(), peerBlockSummariesAfterCommonBlock, maxHeightForChainWeightComparisons);
|
||||
peer.getCommonBlockData().setChainWeight(peerChainWeight);
|
||||
LOGGER.debug(String.format("Chain weight of peer %s based on %d blocks (%d - %d) is %s", peer, (usingSameLengthChainWeight ? minChainLength : peerBlockSummariesAfterCommonBlock.size()), peerBlockSummariesAfterCommonBlock.get(0).getHeight(), peerBlockSummariesAfterCommonBlock.get(peerBlockSummariesAfterCommonBlock.size()-1).getHeight(), formatter.format(peerChainWeight)));
|
||||
|
||||
// Compare against our chain - if our blockchain has greater weight then don't synchronize with peer (or any others in this group)
|
||||
if (ourChainWeight.compareTo(peerChainWeight) > 0) {
|
||||
// This peer is on an inferior chain - remove it
|
||||
LOGGER.debug(String.format("Peer %s is on an inferior chain to us - removing it from this round", peer));
|
||||
peers.remove(peer);
|
||||
}
|
||||
else {
|
||||
// Our chain is inferior or equal
|
||||
LOGGER.debug(String.format("Peer %s is on an equal or better chain to us. We will compare the other peers sharing this common block against each other, and drop all peers sharing higher common blocks.", peer));
|
||||
dropPeersAfterCommonBlockHeight = commonBlockSummary.getHeight();
|
||||
superiorPeersForComparison.add(peer);
|
||||
}
|
||||
}
|
||||
|
||||
// Now that we have selected the best peers, compare them against each other and remove any with lower weights
|
||||
if (superiorPeersForComparison.size() > 0) {
|
||||
BigInteger bestChainWeight = null;
|
||||
for (Peer peer : superiorPeersForComparison) {
|
||||
// Increase bestChainWeight if needed
|
||||
if (bestChainWeight == null || peer.getCommonBlockData().getChainWeight().compareTo(bestChainWeight) >= 0)
|
||||
bestChainWeight = peer.getCommonBlockData().getChainWeight();
|
||||
}
|
||||
for (Peer peer : superiorPeersForComparison) {
|
||||
// Check if we should discard an inferior peer
|
||||
if (peer.getCommonBlockData().getChainWeight().compareTo(bestChainWeight) < 0) {
|
||||
BigInteger difference = bestChainWeight.subtract(peer.getCommonBlockData().getChainWeight());
|
||||
LOGGER.debug(String.format("Peer %s has a lower chain weight (difference: %s) than other peer(s) in this group - removing it from this round.", peer, accurateFormatter.format(difference)));
|
||||
peers.remove(peer);
|
||||
}
|
||||
}
|
||||
// FUTURE: we may want to prefer peers with additional blocks, and compare the additional blocks against each other.
|
||||
// This would fast track us to the best candidate for the latest block.
|
||||
// Right now, peers with the exact same chain as us are treated equally to those with an additional block.
|
||||
}
|
||||
}
|
||||
|
||||
return peers;
|
||||
} finally {
|
||||
repository.discardChanges(); // Free repository locks, if any, also in case anything went wrong
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Repository issue during peer comparison", e);
|
||||
return peers;
|
||||
}
|
||||
}
|
||||
|
||||
private List<BlockSummaryData> uniqueCommonBlocks(List<Peer> peers) {
|
||||
List<BlockSummaryData> commonBlocks = new ArrayList<>();
|
||||
|
||||
for (Peer peer : peers) {
|
||||
if (peer.getCommonBlockData() != null && peer.getCommonBlockData().getCommonBlockSummary() != null) {
|
||||
LOGGER.trace(String.format("Peer %s has common block %.8s", peer, Base58.encode(peer.getCommonBlockData().getCommonBlockSummary().getSignature())));
|
||||
|
||||
BlockSummaryData commonBlockSummary = peer.getCommonBlockData().getCommonBlockSummary();
|
||||
if (!commonBlocks.contains(commonBlockSummary))
|
||||
commonBlocks.add(commonBlockSummary);
|
||||
}
|
||||
else {
|
||||
LOGGER.trace(String.format("Peer %s has no common block data. Skipping...", peer));
|
||||
}
|
||||
}
|
||||
|
||||
return commonBlocks;
|
||||
}
|
||||
|
||||
private int calculateMinChainLengthOfPeers(List<Peer> peersSharingCommonBlock, BlockSummaryData commonBlockSummary) {
|
||||
// Calculate the length of the shortest peer chain sharing this common block
|
||||
int minChainLength = 0;
|
||||
for (Peer peer : peersSharingCommonBlock) {
|
||||
final int peerHeight = peer.getChainTipData().getLastHeight();
|
||||
final int peerAdditionalBlocksAfterCommonBlock = peerHeight - commonBlockSummary.getHeight();
|
||||
|
||||
if (peerAdditionalBlocksAfterCommonBlock < minChainLength || minChainLength == 0)
|
||||
minChainLength = peerAdditionalBlocksAfterCommonBlock;
|
||||
}
|
||||
return minChainLength;
|
||||
}
|
||||
|
||||
private BlockSummaryData blockSummaryWithSignature(byte[] signature, List<BlockSummaryData> blockSummaries) {
|
||||
if (blockSummaries != null)
|
||||
return blockSummaries.stream().filter(blockSummary -> Arrays.equals(blockSummary.getSignature(), signature)).findAny().orElse(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Invalid block signature tracking */
|
||||
|
||||
private void addInvalidBlockSignature(byte[] signature) {
|
||||
Long now = NTP.getTime();
|
||||
if (now == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add or update existing entry
|
||||
String sig58 = Base58.encode(signature);
|
||||
invalidBlockSignatures.put(sig58, now);
|
||||
}
|
||||
private void deleteOlderInvalidSignatures(Long now) {
|
||||
if (now == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete signatures with older timestamps
|
||||
Iterator it = invalidBlockSignatures.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry pair = (Map.Entry)it.next();
|
||||
Long lastSeen = (Long) pair.getValue();
|
||||
|
||||
// Remove signature if we haven't seen it for more than 1 hour
|
||||
if (now - lastSeen > 60 * 60 * 1000L) {
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
private boolean containsInvalidBlockSummary(List<BlockSummaryData> blockSummaries) {
|
||||
if (blockSummaries == null || invalidBlockSignatures == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Loop through our known invalid blocks and check each one against supplied block summaries
|
||||
for (String invalidSignature58 : invalidBlockSignatures.keySet()) {
|
||||
byte[] invalidSignature = Base58.decode(invalidSignature58);
|
||||
for (BlockSummaryData blockSummary : blockSummaries) {
|
||||
byte[] signature = blockSummary.getSignature();
|
||||
if (Arrays.equals(signature, invalidSignature)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private boolean containsInvalidBlockSignature(List<byte[]> blockSignatures) {
|
||||
if (blockSignatures == null || invalidBlockSignatures == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Loop through our known invalid blocks and check each one against supplied block signatures
|
||||
for (String invalidSignature58 : invalidBlockSignatures.keySet()) {
|
||||
byte[] invalidSignature = Base58.decode(invalidSignature58);
|
||||
for (byte[] signature : blockSignatures) {
|
||||
if (Arrays.equals(signature, invalidSignature)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Attempt to synchronize blockchain with peer.
|
||||
* <p>
|
||||
@@ -91,15 +582,40 @@ public class Synchronizer {
|
||||
byte[] peersLastBlockSignature = peerChainTipData.getLastBlockSignature();
|
||||
|
||||
byte[] ourLastBlockSignature = ourLatestBlockData.getSignature();
|
||||
LOGGER.debug(String.format("Synchronizing with peer %s at height %d, sig %.8s, ts %d; our height %d, sig %.8s, ts %d", peer,
|
||||
String syncString = String.format("Synchronizing with peer %s at height %d, sig %.8s, ts %d; our height %d, sig %.8s, ts %d", peer,
|
||||
peerHeight, Base58.encode(peersLastBlockSignature), peer.getChainTipData().getLastBlockTimestamp(),
|
||||
ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp()));
|
||||
ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp());
|
||||
|
||||
// If our latest block is very old, we should log that we're attempting to sync with a peer
|
||||
// Otherwise, it can appear as though nothing is happening for a while after launch
|
||||
final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp();
|
||||
if (minLatestBlockTimestamp != null && ourLatestBlockData.getTimestamp() < minLatestBlockTimestamp) {
|
||||
LOGGER.info(syncString);
|
||||
}
|
||||
else {
|
||||
LOGGER.debug(syncString);
|
||||
}
|
||||
|
||||
// Reset last re-org size as we are starting a new sync round
|
||||
this.lastReorgSize = 0;
|
||||
|
||||
// Set the initial value of timeValidBlockLastReceived if it's null
|
||||
Long now = NTP.getTime();
|
||||
if (this.timeValidBlockLastReceived == null) {
|
||||
this.timeValidBlockLastReceived = now;
|
||||
}
|
||||
|
||||
// Delete invalid signatures with older timestamps
|
||||
this.deleteOlderInvalidSignatures(now);
|
||||
|
||||
List<BlockSummaryData> peerBlockSummaries = new ArrayList<>();
|
||||
SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, force, peerBlockSummaries);
|
||||
if (findCommonBlockResult != SynchronizationResult.OK)
|
||||
SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, force, peerBlockSummaries, true);
|
||||
if (findCommonBlockResult != SynchronizationResult.OK) {
|
||||
// Logging performed by fetchSummariesFromCommonBlock() above
|
||||
// Clear our common block cache for this peer
|
||||
peer.setCommonBlockData(null);
|
||||
return findCommonBlockResult;
|
||||
}
|
||||
|
||||
// First summary is common block
|
||||
final BlockData commonBlockData = repository.getBlockRepository().fromSignature(peerBlockSummaries.get(0).getSignature());
|
||||
@@ -149,10 +665,19 @@ public class Synchronizer {
|
||||
// Commit
|
||||
repository.saveChanges();
|
||||
|
||||
// Create string for logging
|
||||
final BlockData newLatestBlockData = repository.getBlockRepository().getLastBlock();
|
||||
LOGGER.info(String.format("Synchronized with peer %s to height %d, sig %.8s, ts: %d", peer,
|
||||
String syncLog = String.format("Synchronized with peer %s to height %d, sig %.8s, ts: %d", peer,
|
||||
newLatestBlockData.getHeight(), Base58.encode(newLatestBlockData.getSignature()),
|
||||
newLatestBlockData.getTimestamp()));
|
||||
newLatestBlockData.getTimestamp());
|
||||
|
||||
// Append re-org info
|
||||
if (this.lastReorgSize > 0) {
|
||||
syncLog = syncLog.concat(String.format(", size: %d", this.lastReorgSize));
|
||||
}
|
||||
|
||||
// Log sync info
|
||||
LOGGER.info(syncLog);
|
||||
|
||||
return SynchronizationResult.OK;
|
||||
} finally {
|
||||
@@ -175,7 +700,7 @@ public class Synchronizer {
|
||||
* @throws DataException
|
||||
* @throws InterruptedException
|
||||
*/
|
||||
public SynchronizationResult fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight, boolean force, List<BlockSummaryData> blockSummariesFromCommon) throws DataException, InterruptedException {
|
||||
public SynchronizationResult fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight, boolean force, List<BlockSummaryData> blockSummariesFromCommon, boolean infoLogWhenNotFound) throws DataException, InterruptedException {
|
||||
// Start by asking for a few recent block hashes as this will cover a majority of reorgs
|
||||
// Failing that, back off exponentially
|
||||
int step = INITIAL_BLOCK_STEP;
|
||||
@@ -204,8 +729,12 @@ public class Synchronizer {
|
||||
blockSummariesBatch = this.getBlockSummaries(peer, testSignature, step);
|
||||
|
||||
if (blockSummariesBatch == null) {
|
||||
if (infoLogWhenNotFound)
|
||||
LOGGER.info(String.format("Error while trying to find common block with peer %s", peer));
|
||||
else
|
||||
LOGGER.debug(String.format("Error while trying to find common block with peer %s", peer));
|
||||
|
||||
// No response - give up this time
|
||||
LOGGER.info(String.format("Error while trying to find common block with peer %s", peer));
|
||||
return SynchronizationResult.NO_REPLY;
|
||||
}
|
||||
|
||||
@@ -244,9 +773,13 @@ public class Synchronizer {
|
||||
// Currently we work forward from common block until we hit a block we don't have
|
||||
// TODO: rewrite as modified binary search!
|
||||
int i;
|
||||
for (i = 1; i < blockSummariesFromCommon.size(); ++i)
|
||||
for (i = 1; i < blockSummariesFromCommon.size(); ++i) {
|
||||
if (Controller.isStopping())
|
||||
return SynchronizationResult.SHUTTING_DOWN;
|
||||
|
||||
if (!repository.getBlockRepository().exists(blockSummariesFromCommon.get(i).getSignature()))
|
||||
break;
|
||||
}
|
||||
|
||||
// Note: index i - 1 isn't cleared: List.subList is fromIndex inclusive to toIndex exclusive
|
||||
blockSummariesFromCommon.subList(0, i - 1).clear();
|
||||
@@ -295,6 +828,9 @@ public class Synchronizer {
|
||||
|
||||
// Check peer sent valid heights
|
||||
for (int i = 0; i < moreBlockSummaries.size(); ++i) {
|
||||
if (Controller.isStopping())
|
||||
return SynchronizationResult.SHUTTING_DOWN;
|
||||
|
||||
++lastSummaryHeight;
|
||||
|
||||
BlockSummaryData blockSummary = moreBlockSummaries.get(i);
|
||||
@@ -316,7 +852,7 @@ public class Synchronizer {
|
||||
populateBlockSummariesMinterLevels(repository, ourBlockSummaries);
|
||||
populateBlockSummariesMinterLevels(repository, peerBlockSummaries);
|
||||
|
||||
final int mutualHeight = commonBlockHeight - 1 + Math.min(ourBlockSummaries.size(), peerBlockSummaries.size());
|
||||
final int mutualHeight = commonBlockHeight + Math.min(ourBlockSummaries.size(), peerBlockSummaries.size());
|
||||
|
||||
// Calculate cumulative chain weights of both blockchain subsets, from common block to highest mutual block.
|
||||
BigInteger ourChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, ourBlockSummaries, mutualHeight);
|
||||
@@ -341,52 +877,148 @@ public class Synchronizer {
|
||||
final byte[] commonBlockSig = commonBlockData.getSignature();
|
||||
String commonBlockSig58 = Base58.encode(commonBlockSig);
|
||||
|
||||
byte[] latestPeerSignature = commonBlockSig;
|
||||
int height = commonBlockHeight;
|
||||
|
||||
LOGGER.debug(() -> String.format("Fetching peer %s chain from height %d, sig %.8s", peer, commonBlockHeight, commonBlockSig58));
|
||||
|
||||
int ourHeight = ourInitialHeight;
|
||||
final int maxRetries = Settings.getInstance().getMaxRetries();
|
||||
|
||||
// Overall plan: fetch peer's blocks first, then orphan, then apply
|
||||
|
||||
// Convert any leftover (post-common) block summaries into signatures to request from peer
|
||||
List<byte[]> peerBlockSignatures = peerBlockSummaries.stream().map(BlockSummaryData::getSignature).collect(Collectors.toList());
|
||||
|
||||
// Fetch remaining block signatures, if needed
|
||||
int numberSignaturesRequired = peerBlockSignatures.size() - (peerHeight - commonBlockHeight);
|
||||
if (numberSignaturesRequired > 0) {
|
||||
byte[] latestPeerSignature = peerBlockSignatures.isEmpty() ? commonBlockSig : peerBlockSignatures.get(peerBlockSignatures.size() - 1);
|
||||
|
||||
LOGGER.trace(String.format("Requesting %d signature%s after height %d, sig %.8s",
|
||||
numberSignaturesRequired, (numberSignaturesRequired != 1 ? "s": ""), ourHeight, Base58.encode(latestPeerSignature)));
|
||||
|
||||
List<byte[]> moreBlockSignatures = this.getBlockSignatures(peer, latestPeerSignature, numberSignaturesRequired);
|
||||
|
||||
if (moreBlockSignatures == null || moreBlockSignatures.isEmpty()) {
|
||||
LOGGER.info(String.format("Peer %s failed to respond with more block signatures after height %d, sig %.8s", peer,
|
||||
ourHeight, Base58.encode(latestPeerSignature)));
|
||||
return SynchronizationResult.NO_REPLY;
|
||||
}
|
||||
|
||||
LOGGER.trace(String.format("Received %s signature%s", peerBlockSignatures.size(), (peerBlockSignatures.size() != 1 ? "s" : "")));
|
||||
|
||||
peerBlockSignatures.addAll(moreBlockSignatures);
|
||||
}
|
||||
|
||||
// Fetch blocks using signatures
|
||||
LOGGER.debug(String.format("Fetching new blocks from peer %s", peer));
|
||||
// Keep a list of blocks received so far
|
||||
List<Block> peerBlocks = new ArrayList<>();
|
||||
|
||||
for (byte[] blockSignature : peerBlockSignatures) {
|
||||
Block newBlock = this.fetchBlock(repository, peer, blockSignature);
|
||||
// Calculate the total number of additional blocks this peer has beyond the common block
|
||||
int additionalPeerBlocksAfterCommonBlock = peerHeight - commonBlockHeight;
|
||||
// Subtract the number of signatures that we already have, as we don't need to request them again
|
||||
int numberSignaturesRequired = additionalPeerBlocksAfterCommonBlock - peerBlockSignatures.size();
|
||||
|
||||
int retryCount = 0;
|
||||
while (height < peerHeight) {
|
||||
if (Controller.isStopping())
|
||||
return SynchronizationResult.SHUTTING_DOWN;
|
||||
|
||||
// Ensure we don't request more than MAXIMUM_REQUEST_SIZE
|
||||
int numberRequested = Math.min(numberSignaturesRequired, MAXIMUM_REQUEST_SIZE);
|
||||
|
||||
// Do we need more signatures?
|
||||
if (peerBlockSignatures.isEmpty() && numberRequested > 0) {
|
||||
LOGGER.trace(String.format("Requesting %d signature%s after height %d, sig %.8s",
|
||||
numberRequested, (numberRequested != 1 ? "s" : ""), height, Base58.encode(latestPeerSignature)));
|
||||
|
||||
peerBlockSignatures = this.getBlockSignatures(peer, latestPeerSignature, numberRequested);
|
||||
|
||||
if (peerBlockSignatures == null || peerBlockSignatures.isEmpty()) {
|
||||
LOGGER.info(String.format("Peer %s failed to respond with more block signatures after height %d, sig %.8s", peer,
|
||||
height, Base58.encode(latestPeerSignature)));
|
||||
|
||||
// Clear our cache of common block summaries for this peer, as they are likely to be invalid
|
||||
CommonBlockData cachedCommonBlockData = peer.getCommonBlockData();
|
||||
if (cachedCommonBlockData != null)
|
||||
cachedCommonBlockData.setBlockSummariesAfterCommonBlock(null);
|
||||
|
||||
// If we have already received newer blocks from this peer that what we have already, go ahead and apply them
|
||||
if (peerBlocks.size() > 0) {
|
||||
final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock();
|
||||
final Block peerLatestBlock = peerBlocks.get(peerBlocks.size() - 1);
|
||||
final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp();
|
||||
if (ourLatestBlockData != null && peerLatestBlock != null && minLatestBlockTimestamp != null) {
|
||||
|
||||
// If our latest block is very old....
|
||||
if (ourLatestBlockData.getTimestamp() < minLatestBlockTimestamp) {
|
||||
// ... and we have received a block that is more recent than our latest block ...
|
||||
if (peerLatestBlock.getBlockData().getTimestamp() > ourLatestBlockData.getTimestamp()) {
|
||||
// ... then apply the blocks, as it takes us a step forward.
|
||||
// This is particularly useful when starting up a node that was on a small fork when it was last shut down.
|
||||
// In these cases, we now allow the node to sync forward, and get onto the main chain again.
|
||||
// Without this, we would require that the node syncs ENTIRELY with this peer,
|
||||
// and any problems downloading a block would cause all progress to be lost.
|
||||
LOGGER.debug(String.format("Newly received blocks are %d ms newer than our latest block - so we will apply them", peerLatestBlock.getBlockData().getTimestamp() - ourLatestBlockData.getTimestamp()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, give up and move on to the next peer, to avoid putting our chain into an outdated or incomplete state
|
||||
return SynchronizationResult.NO_REPLY;
|
||||
}
|
||||
|
||||
numberSignaturesRequired = peerHeight - height - peerBlockSignatures.size();
|
||||
LOGGER.trace(String.format("Received %s signature%s", peerBlockSignatures.size(), (peerBlockSignatures.size() != 1 ? "s" : "")));
|
||||
}
|
||||
|
||||
if (peerBlockSignatures.isEmpty()) {
|
||||
LOGGER.trace(String.format("No more signatures or blocks to request from peer %s", peer));
|
||||
break;
|
||||
}
|
||||
|
||||
// Catch a block with an invalid signature before orphaning, so that we retain our existing valid candidate
|
||||
if (this.containsInvalidBlockSignature(peerBlockSignatures)) {
|
||||
LOGGER.info(String.format("Peer %s sent invalid block signature: %.8s", peer, Base58.encode(latestPeerSignature)));
|
||||
return SynchronizationResult.INVALID_DATA;
|
||||
}
|
||||
|
||||
byte[] nextPeerSignature = peerBlockSignatures.get(0);
|
||||
int nextHeight = height + 1;
|
||||
|
||||
LOGGER.trace(String.format("Fetching block %d, sig %.8s from %s", nextHeight, Base58.encode(nextPeerSignature), peer));
|
||||
Block newBlock = this.fetchBlock(repository, peer, nextPeerSignature);
|
||||
|
||||
if (newBlock == null) {
|
||||
LOGGER.info(String.format("Peer %s failed to respond with block for height %d, sig %.8s", peer,
|
||||
ourHeight, Base58.encode(blockSignature)));
|
||||
return SynchronizationResult.NO_REPLY;
|
||||
nextHeight, Base58.encode(nextPeerSignature)));
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
// If we have already received newer blocks from this peer that what we have already, go ahead and apply them
|
||||
if (peerBlocks.size() > 0) {
|
||||
final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock();
|
||||
final Block peerLatestBlock = peerBlocks.get(peerBlocks.size() - 1);
|
||||
final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp();
|
||||
if (ourLatestBlockData != null && peerLatestBlock != null && minLatestBlockTimestamp != null) {
|
||||
|
||||
// If our latest block is very old....
|
||||
if (ourLatestBlockData.getTimestamp() < minLatestBlockTimestamp) {
|
||||
// ... and we have received a block that is more recent than our latest block ...
|
||||
if (peerLatestBlock.getBlockData().getTimestamp() > ourLatestBlockData.getTimestamp()) {
|
||||
// ... then apply the blocks, as it takes us a step forward.
|
||||
// This is particularly useful when starting up a node that was on a small fork when it was last shut down.
|
||||
// In these cases, we now allow the node to sync forward, and get onto the main chain again.
|
||||
// Without this, we would require that the node syncs ENTIRELY with this peer,
|
||||
// and any problems downloading a block would cause all progress to be lost.
|
||||
LOGGER.debug(String.format("Newly received blocks are %d ms newer than our latest block - so we will apply them", peerLatestBlock.getBlockData().getTimestamp() - ourLatestBlockData.getTimestamp()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, give up and move on to the next peer, to avoid putting our chain into an outdated or incomplete state
|
||||
return SynchronizationResult.NO_REPLY;
|
||||
|
||||
} else {
|
||||
// Re-fetch signatures, in case the peer is now on a different fork
|
||||
peerBlockSignatures.clear();
|
||||
numberSignaturesRequired = peerHeight - height;
|
||||
|
||||
// Retry until retryCount reaches maxRetries
|
||||
retryCount++;
|
||||
int triesRemaining = maxRetries - retryCount;
|
||||
LOGGER.info(String.format("Re-issuing request to peer %s (%d attempt%s remaining)", peer, triesRemaining, (triesRemaining != 1 ? "s" : "")));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset retryCount because the last request succeeded
|
||||
retryCount = 0;
|
||||
|
||||
LOGGER.trace(String.format("Fetched block %d, sig %.8s from %s", nextHeight, Base58.encode(latestPeerSignature), peer));
|
||||
|
||||
if (!newBlock.isSignatureValid()) {
|
||||
LOGGER.info(String.format("Peer %s sent block with invalid signature for height %d, sig %.8s", peer,
|
||||
ourHeight, Base58.encode(blockSignature)));
|
||||
nextHeight, Base58.encode(latestPeerSignature)));
|
||||
return SynchronizationResult.INVALID_DATA;
|
||||
}
|
||||
|
||||
@@ -395,12 +1027,19 @@ public class Synchronizer {
|
||||
transaction.setInitialApprovalStatus();
|
||||
|
||||
peerBlocks.add(newBlock);
|
||||
|
||||
// Now that we've received this block, we can increase our height and move on to the next one
|
||||
latestPeerSignature = nextPeerSignature;
|
||||
peerBlockSignatures.remove(0);
|
||||
++height;
|
||||
}
|
||||
|
||||
// Unwind to common block (unless common block is our latest block)
|
||||
LOGGER.debug(String.format("Orphaning blocks back to common block height %d, sig %.8s", commonBlockHeight, commonBlockSig58));
|
||||
int ourHeight = ourInitialHeight;
|
||||
LOGGER.debug(String.format("Orphaning blocks back to common block height %d, sig %.8s. Our height: %d", commonBlockHeight, commonBlockSig58, ourHeight));
|
||||
int reorgSize = ourHeight - commonBlockHeight;
|
||||
|
||||
BlockData orphanBlockData = repository.getBlockRepository().fromHeight(ourHeight);
|
||||
BlockData orphanBlockData = repository.getBlockRepository().fromHeight(ourInitialHeight);
|
||||
while (ourHeight > commonBlockHeight) {
|
||||
if (Controller.isStopping())
|
||||
return SynchronizationResult.SHUTTING_DOWN;
|
||||
@@ -422,13 +1061,23 @@ public class Synchronizer {
|
||||
LOGGER.debug(String.format("Orphaned blocks back to height %d, sig %.8s - applying new blocks from peer %s", commonBlockHeight, commonBlockSig58, peer));
|
||||
|
||||
for (Block newBlock : peerBlocks) {
|
||||
if (Controller.isStopping())
|
||||
return SynchronizationResult.SHUTTING_DOWN;
|
||||
|
||||
newBlock.preProcess();
|
||||
|
||||
ValidationResult blockResult = newBlock.isValid();
|
||||
if (blockResult != ValidationResult.OK) {
|
||||
LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer,
|
||||
ourHeight, Base58.encode(newBlock.getSignature()), blockResult.name()));
|
||||
newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getSignature()), blockResult.name()));
|
||||
this.addInvalidBlockSignature(newBlock.getSignature());
|
||||
this.timeInvalidBlockLastReceived = NTP.getTime();
|
||||
return SynchronizationResult.INVALID_DATA;
|
||||
}
|
||||
|
||||
// Block is valid
|
||||
this.timeValidBlockLastReceived = NTP.getTime();
|
||||
|
||||
// Save transactions attached to this block
|
||||
for (Transaction transaction : newBlock.getTransactions()) {
|
||||
TransactionData transactionData = transaction.getTransactionData();
|
||||
@@ -444,6 +1093,7 @@ public class Synchronizer {
|
||||
Controller.getInstance().onNewBlock(newBlock.getBlockData());
|
||||
}
|
||||
|
||||
this.lastReorgSize = reorgSize;
|
||||
return SynchronizationResult.OK;
|
||||
}
|
||||
|
||||
@@ -469,7 +1119,8 @@ public class Synchronizer {
|
||||
|
||||
// Do we need more signatures?
|
||||
if (peerBlockSignatures.isEmpty()) {
|
||||
int numberRequested = maxBatchHeight - ourHeight;
|
||||
int numberRequested = Math.min(maxBatchHeight - ourHeight, MAXIMUM_REQUEST_SIZE);
|
||||
|
||||
LOGGER.trace(String.format("Requesting %d signature%s after height %d, sig %.8s",
|
||||
numberRequested, (numberRequested != 1 ? "s": ""), ourHeight, Base58.encode(latestPeerSignature)));
|
||||
|
||||
@@ -508,13 +1159,20 @@ public class Synchronizer {
|
||||
for (Transaction transaction : newBlock.getTransactions())
|
||||
transaction.setInitialApprovalStatus();
|
||||
|
||||
newBlock.preProcess();
|
||||
|
||||
ValidationResult blockResult = newBlock.isValid();
|
||||
if (blockResult != ValidationResult.OK) {
|
||||
LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer,
|
||||
ourHeight, Base58.encode(latestPeerSignature), blockResult.name()));
|
||||
this.addInvalidBlockSignature(newBlock.getSignature());
|
||||
this.timeInvalidBlockLastReceived = NTP.getTime();
|
||||
return SynchronizationResult.INVALID_DATA;
|
||||
}
|
||||
|
||||
// Block is valid
|
||||
this.timeValidBlockLastReceived = NTP.getTime();
|
||||
|
||||
// Save transactions attached to this block
|
||||
for (Transaction transaction : newBlock.getTransactions()) {
|
||||
TransactionData transactionData = transaction.getTransactionData();
|
||||
@@ -573,6 +1231,9 @@ public class Synchronizer {
|
||||
final int firstBlockHeight = blockSummaries.get(0).getHeight();
|
||||
|
||||
for (int i = 0; i < blockSummaries.size(); ++i) {
|
||||
if (Controller.isStopping())
|
||||
return;
|
||||
|
||||
BlockSummaryData blockSummary = blockSummaries.get(i);
|
||||
|
||||
// Qortal: minter is always a reward-share, so find actual minter and get their effective minting level
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.qortal.controller.repository;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
public class AtStatesPruner implements Runnable {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(AtStatesPruner.class);
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Thread.currentThread().setName("AT States pruner");
|
||||
|
||||
boolean archiveMode = false;
|
||||
if (!Settings.getInstance().isTopOnly()) {
|
||||
// Top-only mode isn't enabled, but we might want to prune for the purposes of archiving
|
||||
if (!Settings.getInstance().isArchiveEnabled()) {
|
||||
// No pruning or archiving, so we must not prune anything
|
||||
return;
|
||||
}
|
||||
else {
|
||||
// We're allowed to prune blocks that have already been archived
|
||||
archiveMode = true;
|
||||
}
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int pruneStartHeight = repository.getATRepository().getAtPruneHeight();
|
||||
|
||||
repository.discardChanges();
|
||||
repository.getATRepository().rebuildLatestAtStates();
|
||||
|
||||
while (!Controller.isStopping()) {
|
||||
repository.discardChanges();
|
||||
|
||||
Thread.sleep(Settings.getInstance().getAtStatesPruneInterval());
|
||||
|
||||
BlockData chainTip = Controller.getInstance().getChainTip();
|
||||
if (chainTip == null || NTP.getTime() == null)
|
||||
continue;
|
||||
|
||||
// Don't even attempt if we're mid-sync as our repository requests will be delayed for ages
|
||||
if (Controller.getInstance().isSynchronizing())
|
||||
continue;
|
||||
|
||||
// Prune AT states for all blocks up until our latest minus pruneBlockLimit
|
||||
final int ourLatestHeight = chainTip.getHeight();
|
||||
int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit();
|
||||
|
||||
// In archive mode we are only allowed to trim blocks that have already been archived
|
||||
if (archiveMode) {
|
||||
upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1;
|
||||
|
||||
// TODO: validate that the actual archived data exists before pruning it?
|
||||
}
|
||||
|
||||
int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize();
|
||||
int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight);
|
||||
|
||||
if (pruneStartHeight >= upperPruneHeight)
|
||||
continue;
|
||||
|
||||
LOGGER.debug(String.format("Pruning AT states between blocks %d and %d...", pruneStartHeight, upperPruneHeight));
|
||||
|
||||
int numAtStatesPruned = repository.getATRepository().pruneAtStates(pruneStartHeight, upperPruneHeight);
|
||||
repository.saveChanges();
|
||||
int numAtStateDataRowsTrimmed = repository.getATRepository().trimAtStates(
|
||||
pruneStartHeight, upperPruneHeight, Settings.getInstance().getAtStatesTrimLimit());
|
||||
repository.saveChanges();
|
||||
|
||||
if (numAtStatesPruned > 0 || numAtStateDataRowsTrimmed > 0) {
|
||||
final int finalPruneStartHeight = pruneStartHeight;
|
||||
LOGGER.debug(() -> String.format("Pruned %d AT state%s between blocks %d and %d",
|
||||
numAtStatesPruned, (numAtStatesPruned != 1 ? "s" : ""),
|
||||
finalPruneStartHeight, upperPruneHeight));
|
||||
} else {
|
||||
// Can we move onto next batch?
|
||||
if (upperPrunableHeight > upperBatchHeight) {
|
||||
pruneStartHeight = upperBatchHeight;
|
||||
repository.getATRepository().setAtPruneHeight(pruneStartHeight);
|
||||
repository.getATRepository().rebuildLatestAtStates();
|
||||
repository.saveChanges();
|
||||
|
||||
final int finalPruneStartHeight = pruneStartHeight;
|
||||
LOGGER.debug(() -> String.format("Bumping AT state base prune height to %d", finalPruneStartHeight));
|
||||
}
|
||||
else {
|
||||
// We've pruned up to the upper prunable height
|
||||
// Back off for a while to save CPU for syncing
|
||||
repository.discardChanges();
|
||||
Thread.sleep(5*60*1000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to prune AT states: %s", e.getMessage()));
|
||||
} catch (InterruptedException e) {
|
||||
// Time to exit
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package org.qortal.controller;
|
||||
package org.qortal.controller.repository;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
@@ -20,8 +21,8 @@ public class AtStatesTrimmer implements Runnable {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int trimStartHeight = repository.getATRepository().getAtTrimHeight();
|
||||
|
||||
repository.getATRepository().prepareForAtStateTrimming();
|
||||
repository.saveChanges();
|
||||
repository.discardChanges();
|
||||
repository.getATRepository().rebuildLatestAtStates();
|
||||
|
||||
while (!Controller.isStopping()) {
|
||||
repository.discardChanges();
|
||||
@@ -62,7 +63,7 @@ public class AtStatesTrimmer implements Runnable {
|
||||
if (upperTrimmableHeight > upperBatchHeight) {
|
||||
trimStartHeight = upperBatchHeight;
|
||||
repository.getATRepository().setAtTrimHeight(trimStartHeight);
|
||||
repository.getATRepository().prepareForAtStateTrimming();
|
||||
repository.getATRepository().rebuildLatestAtStates();
|
||||
repository.saveChanges();
|
||||
|
||||
final int finalTrimStartHeight = trimStartHeight;
|
||||
@@ -0,0 +1,113 @@
|
||||
package org.qortal.controller.repository;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.repository.*;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class BlockArchiver implements Runnable {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(BlockArchiver.class);
|
||||
|
||||
private static final long INITIAL_SLEEP_PERIOD = 0L; // TODO: 5 * 60 * 1000L + 1234L; // ms
|
||||
|
||||
public void run() {
|
||||
Thread.currentThread().setName("Block archiver");
|
||||
|
||||
if (!Settings.getInstance().isArchiveEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Don't even start building until initial rush has ended
|
||||
Thread.sleep(INITIAL_SLEEP_PERIOD);
|
||||
|
||||
int startHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight();
|
||||
|
||||
// Don't attempt to archive if we have no ATStatesHeightIndex, as it will be too slow
|
||||
boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex();
|
||||
if (!hasAtStatesHeightIndex) {
|
||||
LOGGER.info("Unable to start block archiver due to missing ATStatesHeightIndex. Bootstrapping is recommended.");
|
||||
repository.discardChanges();
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.info("Starting block archiver...");
|
||||
|
||||
while (!Controller.isStopping()) {
|
||||
repository.discardChanges();
|
||||
|
||||
Thread.sleep(Settings.getInstance().getArchiveInterval());
|
||||
|
||||
BlockData chainTip = Controller.getInstance().getChainTip();
|
||||
if (chainTip == null || NTP.getTime() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't even attempt if we're mid-sync as our repository requests will be delayed for ages
|
||||
if (Controller.getInstance().isSynchronizing()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't attempt to archive if we're not synced yet
|
||||
final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp();
|
||||
if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Build cache of blocks
|
||||
try {
|
||||
final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository);
|
||||
BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository);
|
||||
BlockArchiveWriter.BlockArchiveWriteResult result = writer.write();
|
||||
switch (result) {
|
||||
case OK:
|
||||
// Increment block archive height
|
||||
startHeight += writer.getWrittenCount();
|
||||
repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight);
|
||||
repository.saveChanges();
|
||||
break;
|
||||
|
||||
case STOPPING:
|
||||
return;
|
||||
|
||||
// We've reached the limit of the blocks we can archive
|
||||
// Sleep for a while to allow more to become available
|
||||
case NOT_ENOUGH_BLOCKS:
|
||||
// We didn't reach our file size target, so that must mean that we don't have enough blocks
|
||||
// yet or something went wrong. Sleep for a while and then try again.
|
||||
repository.discardChanges();
|
||||
Thread.sleep(60 * 60 * 1000L); // 1 hour
|
||||
break;
|
||||
|
||||
case BLOCK_NOT_FOUND:
|
||||
// We tried to archive a block that didn't exist. This is a major failure and likely means
|
||||
// that a bootstrap or re-sync is needed. Try again every minute until then.
|
||||
LOGGER.info("Error: block not found when building archive. If this error persists, " +
|
||||
"a bootstrap or re-sync may be needed.");
|
||||
repository.discardChanges();
|
||||
Thread.sleep( 60 * 1000L); // 1 minute
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (IOException | TransformationException e) {
|
||||
LOGGER.info("Caught exception when creating block cache", e);
|
||||
}
|
||||
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("Caught exception when creating block cache", e);
|
||||
} catch (InterruptedException e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
114
src/main/java/org/qortal/controller/repository/BlockPruner.java
Normal file
114
src/main/java/org/qortal/controller/repository/BlockPruner.java
Normal file
@@ -0,0 +1,114 @@
|
||||
package org.qortal.controller.repository;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
public class BlockPruner implements Runnable {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(BlockPruner.class);
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Thread.currentThread().setName("Block pruner");
|
||||
|
||||
boolean archiveMode = false;
|
||||
if (!Settings.getInstance().isTopOnly()) {
|
||||
// Top-only mode isn't enabled, but we might want to prune for the purposes of archiving
|
||||
if (!Settings.getInstance().isArchiveEnabled()) {
|
||||
// No pruning or archiving, so we must not prune anything
|
||||
return;
|
||||
}
|
||||
else {
|
||||
// We're allowed to prune blocks that have already been archived
|
||||
archiveMode = true;
|
||||
}
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int pruneStartHeight = repository.getBlockRepository().getBlockPruneHeight();
|
||||
|
||||
// Don't attempt to prune if we have no ATStatesHeightIndex, as it will be too slow
|
||||
boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex();
|
||||
if (!hasAtStatesHeightIndex) {
|
||||
LOGGER.info("Unable to start block pruner due to missing ATStatesHeightIndex. Bootstrapping is recommended.");
|
||||
return;
|
||||
}
|
||||
|
||||
while (!Controller.isStopping()) {
|
||||
repository.discardChanges();
|
||||
|
||||
Thread.sleep(Settings.getInstance().getBlockPruneInterval());
|
||||
|
||||
BlockData chainTip = Controller.getInstance().getChainTip();
|
||||
if (chainTip == null || NTP.getTime() == null)
|
||||
continue;
|
||||
|
||||
// Don't even attempt if we're mid-sync as our repository requests will be delayed for ages
|
||||
if (Controller.getInstance().isSynchronizing()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't attempt to prune if we're not synced yet
|
||||
final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp();
|
||||
if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prune all blocks up until our latest minus pruneBlockLimit
|
||||
final int ourLatestHeight = chainTip.getHeight();
|
||||
int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit();
|
||||
|
||||
// In archive mode we are only allowed to trim blocks that have already been archived
|
||||
if (archiveMode) {
|
||||
upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1;
|
||||
}
|
||||
|
||||
int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize();
|
||||
int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight);
|
||||
|
||||
if (pruneStartHeight >= upperPruneHeight) {
|
||||
continue;
|
||||
}
|
||||
|
||||
LOGGER.debug(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight));
|
||||
|
||||
int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight);
|
||||
repository.saveChanges();
|
||||
|
||||
if (numBlocksPruned > 0) {
|
||||
LOGGER.debug(String.format("Pruned %d block%s between %d and %d",
|
||||
numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""),
|
||||
pruneStartHeight, upperPruneHeight));
|
||||
} else {
|
||||
final int nextPruneHeight = upperPruneHeight + 1;
|
||||
repository.getBlockRepository().setBlockPruneHeight(nextPruneHeight);
|
||||
repository.saveChanges();
|
||||
LOGGER.debug(String.format("Bumping block base prune height to %d", pruneStartHeight));
|
||||
|
||||
// Can we move onto next batch?
|
||||
if (upperPrunableHeight > nextPruneHeight) {
|
||||
pruneStartHeight = nextPruneHeight;
|
||||
}
|
||||
else {
|
||||
// We've pruned up to the upper prunable height
|
||||
// Back off for a while to save CPU for syncing
|
||||
repository.discardChanges();
|
||||
Thread.sleep(10*60*1000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to prune blocks: %s", e.getMessage()));
|
||||
} catch (InterruptedException e) {
|
||||
// Time to exit
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package org.qortal.controller.repository;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.account.PublicKeyAccount;
|
||||
import org.qortal.api.resource.TransactionsResource.ConfirmationStatus;
|
||||
import org.qortal.data.naming.NameData;
|
||||
import org.qortal.data.transaction.*;
|
||||
import org.qortal.naming.Name;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.utils.Unicode;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class NamesDatabaseIntegrityCheck {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(NamesDatabaseIntegrityCheck.class);
|
||||
|
||||
private static final List<TransactionType> ALL_NAME_TX_TYPE = Arrays.asList(
|
||||
TransactionType.REGISTER_NAME,
|
||||
TransactionType.UPDATE_NAME,
|
||||
TransactionType.BUY_NAME,
|
||||
TransactionType.SELL_NAME
|
||||
);
|
||||
|
||||
private List<TransactionData> nameTransactions = new ArrayList<>();
|
||||
|
||||
public int rebuildName(String name, Repository repository) {
|
||||
int modificationCount = 0;
|
||||
try {
|
||||
List<TransactionData> transactions = this.fetchAllTransactionsInvolvingName(name, repository);
|
||||
if (transactions.isEmpty()) {
|
||||
// This name was never registered, so there's nothing to do
|
||||
return modificationCount;
|
||||
}
|
||||
|
||||
// Loop through each past transaction and re-apply it to the Names table
|
||||
for (TransactionData currentTransaction : transactions) {
|
||||
|
||||
// Process REGISTER_NAME transactions
|
||||
if (currentTransaction.getType() == TransactionType.REGISTER_NAME) {
|
||||
RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) currentTransaction;
|
||||
Name nameObj = new Name(repository, registerNameTransactionData);
|
||||
nameObj.register();
|
||||
modificationCount++;
|
||||
LOGGER.trace("Processed REGISTER_NAME transaction for name {}", name);
|
||||
}
|
||||
|
||||
// Process UPDATE_NAME transactions
|
||||
if (currentTransaction.getType() == TransactionType.UPDATE_NAME) {
|
||||
UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) currentTransaction;
|
||||
|
||||
if (Objects.equals(updateNameTransactionData.getNewName(), name) &&
|
||||
!Objects.equals(updateNameTransactionData.getName(), updateNameTransactionData.getNewName())) {
|
||||
// This renames an existing name, so we need to process that instead
|
||||
this.rebuildName(updateNameTransactionData.getName(), repository);
|
||||
}
|
||||
else {
|
||||
Name nameObj = new Name(repository, name);
|
||||
if (nameObj != null && nameObj.getNameData() != null) {
|
||||
nameObj.update(updateNameTransactionData);
|
||||
modificationCount++;
|
||||
LOGGER.trace("Processed UPDATE_NAME transaction for name {}", name);
|
||||
} else {
|
||||
// Something went wrong
|
||||
throw new DataException(String.format("Name data not found for name %s", updateNameTransactionData.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process SELL_NAME transactions
|
||||
if (currentTransaction.getType() == TransactionType.SELL_NAME) {
|
||||
SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) currentTransaction;
|
||||
Name nameObj = new Name(repository, sellNameTransactionData.getName());
|
||||
if (nameObj != null && nameObj.getNameData() != null) {
|
||||
nameObj.sell(sellNameTransactionData);
|
||||
modificationCount++;
|
||||
LOGGER.trace("Processed SELL_NAME transaction for name {}", name);
|
||||
}
|
||||
else {
|
||||
// Something went wrong
|
||||
throw new DataException(String.format("Name data not found for name %s", sellNameTransactionData.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
// Process BUY_NAME transactions
|
||||
if (currentTransaction.getType() == TransactionType.BUY_NAME) {
|
||||
BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) currentTransaction;
|
||||
Name nameObj = new Name(repository, buyNameTransactionData.getName());
|
||||
if (nameObj != null && nameObj.getNameData() != null) {
|
||||
nameObj.buy(buyNameTransactionData);
|
||||
modificationCount++;
|
||||
LOGGER.trace("Processed BUY_NAME transaction for name {}", name);
|
||||
}
|
||||
else {
|
||||
// Something went wrong
|
||||
throw new DataException(String.format("Name data not found for name %s", buyNameTransactionData.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("Unable to run integrity check for name {}: {}", name, e.getMessage());
|
||||
}
|
||||
|
||||
return modificationCount;
|
||||
}
|
||||
|
||||
public int rebuildAllNames() {
|
||||
int modificationCount = 0;
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<String> names = this.fetchAllNames(repository);
|
||||
for (String name : names) {
|
||||
modificationCount += this.rebuildName(name, repository);
|
||||
}
|
||||
repository.saveChanges();
|
||||
}
|
||||
catch (DataException e) {
|
||||
LOGGER.info("Error when running integrity check for all names: {}", e.getMessage());
|
||||
}
|
||||
|
||||
//LOGGER.info("modificationCount: {}", modificationCount);
|
||||
return modificationCount;
|
||||
}
|
||||
|
||||
public void runIntegrityCheck() {
|
||||
boolean integrityCheckFailed = false;
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
// Fetch all the (confirmed) REGISTER_NAME transactions
|
||||
List<RegisterNameTransactionData> registerNameTransactions = this.fetchRegisterNameTransactions();
|
||||
|
||||
// Loop through each REGISTER_NAME txn signature and request the full transaction data
|
||||
for (RegisterNameTransactionData registerNameTransactionData : registerNameTransactions) {
|
||||
String registeredName = registerNameTransactionData.getName();
|
||||
NameData nameData = repository.getNameRepository().fromName(registeredName);
|
||||
|
||||
// Check to see if this name has been updated or bought at any point
|
||||
TransactionData latestUpdate = this.fetchLatestModificationTransactionInvolvingName(registeredName, repository);
|
||||
if (latestUpdate == null) {
|
||||
// Name was never updated once registered
|
||||
// We expect this name to still be registered to this transaction's creator
|
||||
|
||||
if (nameData == null) {
|
||||
LOGGER.info("Error: registered name {} doesn't exist in Names table. Adding...", registeredName);
|
||||
integrityCheckFailed = true;
|
||||
}
|
||||
else {
|
||||
LOGGER.trace("Registered name {} is correctly registered", registeredName);
|
||||
}
|
||||
|
||||
// Check the owner is correct
|
||||
PublicKeyAccount creator = new PublicKeyAccount(repository, registerNameTransactionData.getCreatorPublicKey());
|
||||
if (!Objects.equals(creator.getAddress(), nameData.getOwner())) {
|
||||
LOGGER.info("Error: registered name {} is owned by {}, but it should be {}",
|
||||
registeredName, nameData.getOwner(), creator.getAddress());
|
||||
integrityCheckFailed = true;
|
||||
}
|
||||
else {
|
||||
LOGGER.trace("Registered name {} has the correct owner", registeredName);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Check if owner is correct after update
|
||||
|
||||
// Check for name updates
|
||||
if (latestUpdate.getType() == TransactionType.UPDATE_NAME) {
|
||||
UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) latestUpdate;
|
||||
PublicKeyAccount creator = new PublicKeyAccount(repository, updateNameTransactionData.getCreatorPublicKey());
|
||||
|
||||
// When this name is the "new name", we expect the current owner to match the txn creator
|
||||
if (Objects.equals(updateNameTransactionData.getNewName(), registeredName)) {
|
||||
if (!Objects.equals(creator.getAddress(), nameData.getOwner())) {
|
||||
LOGGER.info("Error: registered name {} is owned by {}, but it should be {}",
|
||||
registeredName, nameData.getOwner(), creator.getAddress());
|
||||
integrityCheckFailed = true;
|
||||
}
|
||||
else {
|
||||
LOGGER.trace("Registered name {} has the correct owner after being updated", registeredName);
|
||||
}
|
||||
}
|
||||
|
||||
// When this name is the old name, we expect the "new name"'s owner to match the txn creator
|
||||
// The old name will then be unregistered, or re-registered.
|
||||
// FUTURE: check database integrity for names that have been updated and then the original name re-registered
|
||||
else if (Objects.equals(updateNameTransactionData.getName(), registeredName)) {
|
||||
NameData newNameData = repository.getNameRepository().fromName(updateNameTransactionData.getNewName());
|
||||
if (!Objects.equals(creator.getAddress(), newNameData.getOwner())) {
|
||||
LOGGER.info("Error: registered name {} is owned by {}, but it should be {}",
|
||||
updateNameTransactionData.getNewName(), newNameData.getOwner(), creator.getAddress());
|
||||
integrityCheckFailed = true;
|
||||
}
|
||||
else {
|
||||
LOGGER.trace("Registered name {} has the correct owner after being updated", updateNameTransactionData.getNewName());
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
LOGGER.info("Unhandled update case for name {}", registeredName);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for name buys
|
||||
else if (latestUpdate.getType() == TransactionType.BUY_NAME) {
|
||||
BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) latestUpdate;
|
||||
PublicKeyAccount creator = new PublicKeyAccount(repository, buyNameTransactionData.getCreatorPublicKey());
|
||||
if (!Objects.equals(creator.getAddress(), nameData.getOwner())) {
|
||||
LOGGER.info("Error: registered name {} is owned by {}, but it should be {}",
|
||||
registeredName, nameData.getOwner(), creator.getAddress());
|
||||
integrityCheckFailed = true;
|
||||
}
|
||||
else {
|
||||
LOGGER.trace("Registered name {} has the correct owner after being bought", registeredName);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for name sells
|
||||
else if (latestUpdate.getType() == TransactionType.SELL_NAME) {
|
||||
SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) latestUpdate;
|
||||
PublicKeyAccount creator = new PublicKeyAccount(repository, sellNameTransactionData.getCreatorPublicKey());
|
||||
if (!Objects.equals(creator.getAddress(), nameData.getOwner())) {
|
||||
LOGGER.info("Error: registered name {} is owned by {}, but it should be {}",
|
||||
registeredName, nameData.getOwner(), creator.getAddress());
|
||||
integrityCheckFailed = true;
|
||||
}
|
||||
else {
|
||||
LOGGER.trace("Registered name {} has the correct owner after being listed for sale", registeredName);
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
LOGGER.info("Unhandled case for name {}", registeredName);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to trim online accounts signatures: %s", e.getMessage()));
|
||||
integrityCheckFailed = true;
|
||||
}
|
||||
|
||||
if (integrityCheckFailed) {
|
||||
LOGGER.info("Registered names database integrity check failed. Bootstrapping is recommended.");
|
||||
} else {
|
||||
LOGGER.info("Registered names database integrity check passed.");
|
||||
}
|
||||
}
|
||||
|
||||
private List<RegisterNameTransactionData> fetchRegisterNameTransactions() {
|
||||
List<RegisterNameTransactionData> registerNameTransactions = new ArrayList<>();
|
||||
|
||||
for (TransactionData transactionData : this.nameTransactions) {
|
||||
if (transactionData.getType() == TransactionType.REGISTER_NAME) {
|
||||
RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) transactionData;
|
||||
registerNameTransactions.add(registerNameTransactionData);
|
||||
}
|
||||
}
|
||||
return registerNameTransactions;
|
||||
}
|
||||
|
||||
private List<UpdateNameTransactionData> fetchUpdateNameTransactions() {
|
||||
List<UpdateNameTransactionData> updateNameTransactions = new ArrayList<>();
|
||||
|
||||
for (TransactionData transactionData : this.nameTransactions) {
|
||||
if (transactionData.getType() == TransactionType.UPDATE_NAME) {
|
||||
UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData;
|
||||
updateNameTransactions.add(updateNameTransactionData);
|
||||
}
|
||||
}
|
||||
return updateNameTransactions;
|
||||
}
|
||||
|
||||
private List<SellNameTransactionData> fetchSellNameTransactions() {
|
||||
List<SellNameTransactionData> sellNameTransactions = new ArrayList<>();
|
||||
|
||||
for (TransactionData transactionData : this.nameTransactions) {
|
||||
if (transactionData.getType() == TransactionType.SELL_NAME) {
|
||||
SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) transactionData;
|
||||
sellNameTransactions.add(sellNameTransactionData);
|
||||
}
|
||||
}
|
||||
return sellNameTransactions;
|
||||
}
|
||||
|
||||
private List<BuyNameTransactionData> fetchBuyNameTransactions() {
|
||||
List<BuyNameTransactionData> buyNameTransactions = new ArrayList<>();
|
||||
|
||||
for (TransactionData transactionData : this.nameTransactions) {
|
||||
if (transactionData.getType() == TransactionType.BUY_NAME) {
|
||||
BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) transactionData;
|
||||
buyNameTransactions.add(buyNameTransactionData);
|
||||
}
|
||||
}
|
||||
return buyNameTransactions;
|
||||
}
|
||||
|
||||
private void fetchAllNameTransactions(Repository repository) throws DataException {
|
||||
List<TransactionData> nameTransactions = new ArrayList<>();
|
||||
|
||||
// Fetch all the confirmed REGISTER_NAME transaction signatures
|
||||
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(
|
||||
null, null, null, ALL_NAME_TX_TYPE, null, null,
|
||||
ConfirmationStatus.CONFIRMED, null, null, false);
|
||||
|
||||
for (byte[] signature : signatures) {
|
||||
TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature);
|
||||
nameTransactions.add(transactionData);
|
||||
}
|
||||
this.nameTransactions = nameTransactions;
|
||||
}
|
||||
|
||||
private List<TransactionData> fetchAllTransactionsInvolvingName(String name, Repository repository) throws DataException {
|
||||
List<TransactionData> transactions = new ArrayList<>();
|
||||
String reducedName = Unicode.sanitize(name);
|
||||
|
||||
// Fetch all the confirmed name-modification transactions
|
||||
if (this.nameTransactions.isEmpty()) {
|
||||
this.fetchAllNameTransactions(repository);
|
||||
}
|
||||
|
||||
for (TransactionData transactionData : this.nameTransactions) {
|
||||
|
||||
if ((transactionData instanceof RegisterNameTransactionData)) {
|
||||
RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) transactionData;
|
||||
if (Objects.equals(registerNameTransactionData.getReducedName(), reducedName)) {
|
||||
transactions.add(transactionData);
|
||||
}
|
||||
}
|
||||
if ((transactionData instanceof UpdateNameTransactionData)) {
|
||||
UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData;
|
||||
if (Objects.equals(updateNameTransactionData.getName(), name) ||
|
||||
Objects.equals(updateNameTransactionData.getReducedNewName(), reducedName)) {
|
||||
transactions.add(transactionData);
|
||||
}
|
||||
}
|
||||
if ((transactionData instanceof BuyNameTransactionData)) {
|
||||
BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) transactionData;
|
||||
if (Objects.equals(buyNameTransactionData.getName(), name)) {
|
||||
transactions.add(transactionData);
|
||||
}
|
||||
}
|
||||
if ((transactionData instanceof SellNameTransactionData)) {
|
||||
SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) transactionData;
|
||||
if (Objects.equals(sellNameTransactionData.getName(), name)) {
|
||||
transactions.add(transactionData);
|
||||
}
|
||||
}
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
|
||||
private TransactionData fetchLatestModificationTransactionInvolvingName(String registeredName, Repository repository) throws DataException {
|
||||
List<TransactionData> transactionsInvolvingName = this.fetchAllTransactionsInvolvingName(registeredName, repository);
|
||||
|
||||
// Get the latest update for this name (excluding REGISTER_NAME transactions)
|
||||
TransactionData latestUpdateToName = transactionsInvolvingName.stream()
|
||||
.filter(txn -> txn.getType() != TransactionType.REGISTER_NAME)
|
||||
.max(Comparator.comparing(TransactionData::getTimestamp))
|
||||
.orElse(null);
|
||||
|
||||
return latestUpdateToName;
|
||||
}
|
||||
|
||||
private List<String> fetchAllNames(Repository repository) throws DataException {
|
||||
List<String> names = new ArrayList<>();
|
||||
|
||||
// Fetch all the confirmed name transactions
|
||||
if (this.nameTransactions.isEmpty()) {
|
||||
this.fetchAllNameTransactions(repository);
|
||||
}
|
||||
|
||||
for (TransactionData transactionData : this.nameTransactions) {
|
||||
|
||||
if ((transactionData instanceof RegisterNameTransactionData)) {
|
||||
RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) transactionData;
|
||||
if (!names.contains(registerNameTransactionData.getName())) {
|
||||
names.add(registerNameTransactionData.getName());
|
||||
}
|
||||
}
|
||||
if ((transactionData instanceof UpdateNameTransactionData)) {
|
||||
UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData;
|
||||
if (!names.contains(updateNameTransactionData.getName())) {
|
||||
names.add(updateNameTransactionData.getName());
|
||||
}
|
||||
if (!names.contains(updateNameTransactionData.getNewName())) {
|
||||
names.add(updateNameTransactionData.getNewName());
|
||||
}
|
||||
}
|
||||
if ((transactionData instanceof BuyNameTransactionData)) {
|
||||
BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) transactionData;
|
||||
if (!names.contains(buyNameTransactionData.getName())) {
|
||||
names.add(buyNameTransactionData.getName());
|
||||
}
|
||||
}
|
||||
if ((transactionData instanceof SellNameTransactionData)) {
|
||||
SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) transactionData;
|
||||
if (!names.contains(sellNameTransactionData.getName())) {
|
||||
names.add(sellNameTransactionData.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package org.qortal.controller;
|
||||
package org.qortal.controller.repository;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
155
src/main/java/org/qortal/controller/repository/PruneManager.java
Normal file
155
src/main/java/org/qortal/controller/repository/PruneManager.java
Normal file
@@ -0,0 +1,155 @@
|
||||
package org.qortal.controller.repository;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.controller.Controller;
|
||||
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.DaemonThreadFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class PruneManager {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(PruneManager.class);
|
||||
|
||||
private static PruneManager instance;
|
||||
|
||||
private boolean isTopOnly = Settings.getInstance().isTopOnly();
|
||||
private int pruneBlockLimit = Settings.getInstance().getPruneBlockLimit();
|
||||
|
||||
private ExecutorService executorService;
|
||||
|
||||
private PruneManager() {
|
||||
|
||||
}
|
||||
|
||||
public static synchronized PruneManager getInstance() {
|
||||
if (instance == null)
|
||||
instance = new PruneManager();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
this.executorService = Executors.newCachedThreadPool(new DaemonThreadFactory());
|
||||
|
||||
if (Settings.getInstance().isTopOnly()) {
|
||||
// Top-only-sync
|
||||
this.startTopOnlySyncMode();
|
||||
}
|
||||
else if (Settings.getInstance().isArchiveEnabled()) {
|
||||
// Full node with block archive
|
||||
this.startFullNodeWithBlockArchive();
|
||||
}
|
||||
else {
|
||||
// Full node with full SQL support
|
||||
this.startFullSQLNode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-only-sync
|
||||
* In this mode, we delete (prune) all blocks except
|
||||
* a small number of recent ones. There is no need for
|
||||
* trimming or archiving, because all relevant blocks
|
||||
* are deleted.
|
||||
*/
|
||||
private void startTopOnlySyncMode() {
|
||||
this.startPruning();
|
||||
|
||||
// We don't need the block archive in top-only mode
|
||||
this.deleteArchive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full node with block archive
|
||||
* In this mode we archive trimmed blocks, and then
|
||||
* prune archived blocks to keep the database small
|
||||
*/
|
||||
private void startFullNodeWithBlockArchive() {
|
||||
this.startTrimming();
|
||||
this.startArchiving();
|
||||
this.startPruning();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full node with full SQL support
|
||||
* In this mode we trim the database but don't prune
|
||||
* or archive any data, because we want to maintain
|
||||
* full SQL support of old blocks. This mode will not
|
||||
* be actively maintained but can be used by those who
|
||||
* need to perform SQL analysis on older blocks.
|
||||
*/
|
||||
private void startFullSQLNode() {
|
||||
this.startTrimming();
|
||||
}
|
||||
|
||||
|
||||
private void startPruning() {
|
||||
this.executorService.execute(new AtStatesPruner());
|
||||
this.executorService.execute(new BlockPruner());
|
||||
}
|
||||
|
||||
private void startTrimming() {
|
||||
this.executorService.execute(new AtStatesTrimmer());
|
||||
this.executorService.execute(new OnlineAccountsSignaturesTrimmer());
|
||||
}
|
||||
|
||||
private void startArchiving() {
|
||||
this.executorService.execute(new BlockArchiver());
|
||||
}
|
||||
|
||||
private void deleteArchive() {
|
||||
if (!Settings.getInstance().isTopOnly()) {
|
||||
LOGGER.error("Refusing to delete archive when not in top-only mode");
|
||||
}
|
||||
|
||||
try {
|
||||
Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive");
|
||||
if (archivePath.toFile().exists()) {
|
||||
LOGGER.info("Deleting block archive because we are in top-only mode...");
|
||||
FileUtils.deleteDirectory(archivePath.toFile());
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.info("Couldn't delete archive: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
this.executorService.shutdownNow();
|
||||
|
||||
try {
|
||||
this.executorService.awaitTermination(2L, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
// We tried...
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isBlockPruned(int height, Repository repository) throws DataException {
|
||||
if (!this.isTopOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BlockData chainTip = Controller.getInstance().getChainTip();
|
||||
if (chainTip == null) {
|
||||
throw new DataException("Unable to determine chain tip when checking if a block is pruned");
|
||||
}
|
||||
|
||||
final int ourLatestHeight = chainTip.getHeight();
|
||||
final int latestUnprunedHeight = ourLatestHeight - this.pruneBlockLimit;
|
||||
|
||||
return (height < latestUnprunedHeight);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -360,6 +360,7 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot {
|
||||
case BOB_DONE:
|
||||
case ALICE_REFUNDED:
|
||||
case BOB_REFUNDED:
|
||||
case ALICE_REFUNDING_A:
|
||||
return true;
|
||||
|
||||
default:
|
||||
@@ -383,15 +384,6 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot {
|
||||
atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
|
||||
if (atData == null) {
|
||||
LOGGER.debug(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
|
||||
|
||||
// If it has been over 24 hours since we last updated this trade-bot entry then assume AT is never coming back
|
||||
// and so wipe the trade-bot entry
|
||||
if (tradeBotData.getTimestamp() + MAX_AT_CONFIRMATION_PERIOD < NTP.getTime()) {
|
||||
LOGGER.info(() -> String.format("AT %s has been gone for too long - deleting trade-bot entry", tradeBotData.getAtAddress()));
|
||||
repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
|
||||
repository.saveChanges();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1042,7 +1034,7 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot {
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] secretA = BitcoinACCTv1.findSecretA(repository, crossChainTradeData);
|
||||
byte[] secretA = BitcoinACCTv1.getInstance().findSecretA(repository, crossChainTradeData);
|
||||
if (secretA == null) {
|
||||
LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress()));
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,884 @@
|
||||
package org.qortal.controller.tradebot;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bitcoinj.core.*;
|
||||
import org.bitcoinj.script.Script.ScriptType;
|
||||
import org.qortal.account.PrivateKeyAccount;
|
||||
import org.qortal.account.PublicKeyAccount;
|
||||
import org.qortal.api.model.crosschain.TradeBotCreateRequest;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.crosschain.*;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.data.crosschain.TradeBotData;
|
||||
import org.qortal.data.transaction.BaseTransactionData;
|
||||
import org.qortal.data.transaction.DeployAtTransactionData;
|
||||
import org.qortal.data.transaction.MessageTransactionData;
|
||||
import org.qortal.group.Group;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.transaction.DeployAtTransaction;
|
||||
import org.qortal.transaction.MessageTransaction;
|
||||
import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.transaction.DeployAtTransactionTransformer;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static java.util.Arrays.stream;
|
||||
import static java.util.stream.Collectors.toMap;
|
||||
|
||||
/**
|
||||
* Performing cross-chain trading steps on behalf of user.
|
||||
* <p>
|
||||
* We deal with three different independent state-spaces here:
|
||||
* <ul>
|
||||
* <li>Qortal blockchain</li>
|
||||
* <li>Foreign blockchain</li>
|
||||
* <li>Trade-bot entries</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class DogecoinACCTv1TradeBot implements AcctTradeBot {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv1TradeBot.class);
|
||||
|
||||
public enum State implements TradeBot.StateNameAndValueSupplier {
|
||||
BOB_WAITING_FOR_AT_CONFIRM(10, false, false),
|
||||
BOB_WAITING_FOR_MESSAGE(15, true, true),
|
||||
BOB_WAITING_FOR_AT_REDEEM(25, true, true),
|
||||
BOB_DONE(30, false, false),
|
||||
BOB_REFUNDED(35, false, false),
|
||||
|
||||
ALICE_WAITING_FOR_AT_LOCK(85, true, true),
|
||||
ALICE_DONE(95, false, false),
|
||||
ALICE_REFUNDING_A(105, true, true),
|
||||
ALICE_REFUNDED(110, false, false);
|
||||
|
||||
private static final Map<Integer, State> map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
|
||||
|
||||
public final int value;
|
||||
public final boolean requiresAtData;
|
||||
public final boolean requiresTradeData;
|
||||
|
||||
State(int value, boolean requiresAtData, boolean requiresTradeData) {
|
||||
this.value = value;
|
||||
this.requiresAtData = requiresAtData;
|
||||
this.requiresTradeData = requiresTradeData;
|
||||
}
|
||||
|
||||
public static State valueOf(int value) {
|
||||
return map.get(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getState() {
|
||||
return this.name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getStateValue() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */
|
||||
private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms
|
||||
|
||||
private static DogecoinACCTv1TradeBot instance;
|
||||
|
||||
private final List<String> endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDED).stream()
|
||||
.map(State::name)
|
||||
.collect(Collectors.toUnmodifiableList());
|
||||
|
||||
private DogecoinACCTv1TradeBot() {
|
||||
}
|
||||
|
||||
public static synchronized DogecoinACCTv1TradeBot getInstance() {
|
||||
if (instance == null)
|
||||
instance = new DogecoinACCTv1TradeBot();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getEndStates() {
|
||||
return this.endStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for DOGE.
|
||||
* <p>
|
||||
* Generates:
|
||||
* <ul>
|
||||
* <li>new 'trade' private key</li>
|
||||
* </ul>
|
||||
* Derives:
|
||||
* <ul>
|
||||
* <li>'native' (as in Qortal) public key, public key hash, address (starting with Q)</li>
|
||||
* <li>'foreign' (as in Dogecoin) public key, public key hash</li>
|
||||
* </ul>
|
||||
* A Qortal AT is then constructed including the following as constants in the 'data segment':
|
||||
* <ul>
|
||||
* <li>'native'/Qortal 'trade' address - used as a MESSAGE contact</li>
|
||||
* <li>'foreign'/Dogecoin public key hash - used by Alice's P2SH scripts to allow redeem</li>
|
||||
* <li>QORT amount on offer by Bob</li>
|
||||
* <li>DOGE amount expected in return by Bob (from Alice)</li>
|
||||
* <li>trading timeout, in case things go wrong and everyone needs to refund</li>
|
||||
* </ul>
|
||||
* Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network.
|
||||
* <p>
|
||||
* Trade-bot will wait for Bob's AT to be deployed before taking next step.
|
||||
* <p>
|
||||
* @param repository
|
||||
* @param tradeBotCreateRequest
|
||||
* @return raw, unsigned DEPLOY_AT transaction
|
||||
* @throws DataException
|
||||
*/
|
||||
public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
|
||||
byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
|
||||
|
||||
byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
|
||||
byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
|
||||
String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
|
||||
|
||||
byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
|
||||
byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
|
||||
|
||||
// Convert Dogecoin receiving address into public key hash (we only support P2PKH at this time)
|
||||
Address dogecoinReceivingAddress;
|
||||
try {
|
||||
dogecoinReceivingAddress = Address.fromString(Dogecoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress);
|
||||
} catch (AddressFormatException e) {
|
||||
throw new DataException("Unsupported Dogecoin receiving address: " + tradeBotCreateRequest.receivingAddress);
|
||||
}
|
||||
if (dogecoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||
throw new DataException("Unsupported Dogecoin receiving address: " + tradeBotCreateRequest.receivingAddress);
|
||||
|
||||
byte[] dogecoinReceivingAccountInfo = dogecoinReceivingAddress.getHash();
|
||||
|
||||
PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey);
|
||||
|
||||
// Deploy AT
|
||||
long timestamp = NTP.getTime();
|
||||
byte[] reference = creator.getLastReference();
|
||||
long fee = 0L;
|
||||
byte[] signature = null;
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, creator.getPublicKey(), fee, signature);
|
||||
|
||||
String name = "QORT/DOGE ACCT";
|
||||
String description = "QORT/DOGE cross-chain trade";
|
||||
String aTType = "ACCT";
|
||||
String tags = "ACCT QORT DOGE";
|
||||
byte[] creationBytes = DogecoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, tradeBotCreateRequest.qortAmount,
|
||||
tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout);
|
||||
long amount = tradeBotCreateRequest.fundingQortAmount;
|
||||
|
||||
DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT);
|
||||
|
||||
DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
|
||||
fee = deployAtTransaction.calcRecommendedFee();
|
||||
deployAtTransactionData.setFee(fee);
|
||||
|
||||
DeployAtTransaction.ensureATAddress(deployAtTransactionData);
|
||||
String atAddress = deployAtTransactionData.getAtAddress();
|
||||
|
||||
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, DogecoinACCTv1.NAME,
|
||||
State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value,
|
||||
creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount,
|
||||
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
|
||||
null, null,
|
||||
SupportedBlockchain.DOGECOIN.name(),
|
||||
tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
||||
tradeBotCreateRequest.foreignAmount, null, null, null, dogecoinReceivingAccountInfo);
|
||||
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress));
|
||||
|
||||
// Attempt to backup the trade bot data
|
||||
TradeBot.backupTradeBotData(repository);
|
||||
|
||||
// Return to user for signing and broadcast as we don't have their Qortal private key
|
||||
try {
|
||||
return DeployAtTransactionTransformer.toBytes(deployAtTransactionData);
|
||||
} catch (TransformationException e) {
|
||||
throw new DataException("Failed to transform DEPLOY_AT transaction?", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching DOGE to an existing offer.
|
||||
* <p>
|
||||
* Requires a chosen trade offer from Bob, passed by <tt>crossChainTradeData</tt>
|
||||
* and access to a Dogecoin wallet via <tt>xprv58</tt>.
|
||||
* <p>
|
||||
* The <tt>crossChainTradeData</tt> contains the current trade offer state
|
||||
* as extracted from the AT's data segment.
|
||||
* <p>
|
||||
* Access to a funded wallet is via a Dogecoin BIP32 hierarchical deterministic key,
|
||||
* passed via <tt>xprv58</tt>.
|
||||
* <b>This key will be stored in your node's database</b>
|
||||
* to allow trade-bot to create/fund the necessary P2SH transactions!
|
||||
* However, due to the nature of BIP32 keys, it is possible to give the trade-bot
|
||||
* only a subset of wallet access (see BIP32 for more details).
|
||||
* <p>
|
||||
* As an example, the xprv58 can be extract from a <i>legacy, password-less</i>
|
||||
* Electrum wallet by going to the console tab and entering:<br>
|
||||
* <tt>wallet.keystore.xprv</tt><br>
|
||||
* which should result in a base58 string starting with either 'xprv' (for Dogecoin main-net)
|
||||
* or 'tprv' for (Dogecoin test-net).
|
||||
* <p>
|
||||
* It is envisaged that the value in <tt>xprv58</tt> will actually come from a Qortal-UI-managed wallet.
|
||||
* <p>
|
||||
* If sufficient funds are available, <b>this method will actually fund the P2SH-A</b>
|
||||
* with the Dogecoin amount expected by 'Bob'.
|
||||
* <p>
|
||||
* If the Dogecoin transaction is successfully broadcast to the network then
|
||||
* we also send a MESSAGE to Bob's trade-bot to let them know.
|
||||
* <p>
|
||||
* The trade-bot entry is saved to the repository and the cross-chain trading process commences.
|
||||
* <p>
|
||||
* @param repository
|
||||
* @param crossChainTradeData chosen trade OFFER that Alice wants to match
|
||||
* @param xprv58 funded wallet xprv in base58
|
||||
* @return true if P2SH-A funding transaction successfully broadcast to Dogecoin network, false otherwise
|
||||
* @throws DataException
|
||||
*/
|
||||
public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException {
|
||||
byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
|
||||
byte[] secretA = TradeBot.generateSecret();
|
||||
byte[] hashOfSecretA = Crypto.hash160(secretA);
|
||||
|
||||
byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
|
||||
byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
|
||||
String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
|
||||
|
||||
byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
|
||||
byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
|
||||
byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH
|
||||
|
||||
// We need to generate lockTime-A: add tradeTimeout to now
|
||||
long now = NTP.getTime();
|
||||
int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L);
|
||||
|
||||
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, DogecoinACCTv1.NAME,
|
||||
State.ALICE_WAITING_FOR_AT_LOCK.name(), State.ALICE_WAITING_FOR_AT_LOCK.value,
|
||||
receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount,
|
||||
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
|
||||
secretA, hashOfSecretA,
|
||||
SupportedBlockchain.DOGECOIN.name(),
|
||||
tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
||||
crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash);
|
||||
|
||||
// Attempt to backup the trade bot data
|
||||
TradeBot.backupTradeBotData(repository);
|
||||
|
||||
// Check we have enough funds via xprv58 to fund P2SH to cover expectedForeignAmount
|
||||
long p2shFee;
|
||||
try {
|
||||
p2shFee = Dogecoin.getInstance().getP2shFee(now);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
LOGGER.debug("Couldn't estimate Dogecoin fees?");
|
||||
return ResponseResult.NETWORK_ISSUE;
|
||||
}
|
||||
|
||||
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||
// Do not include fee for funding transaction as this is covered by buildSpend()
|
||||
long amountA = crossChainTradeData.expectedForeignAmount + p2shFee /*redeeming/refunding P2SH-A*/;
|
||||
|
||||
// P2SH-A to be funded
|
||||
byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA);
|
||||
String p2shAddress = Dogecoin.getInstance().deriveP2shAddress(redeemScriptBytes);
|
||||
|
||||
// Build transaction for funding P2SH-A
|
||||
Transaction p2shFundingTransaction = Dogecoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA);
|
||||
if (p2shFundingTransaction == null) {
|
||||
LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?");
|
||||
return ResponseResult.BALANCE_ISSUE;
|
||||
}
|
||||
|
||||
try {
|
||||
Dogecoin.getInstance().broadcastTransaction(p2shFundingTransaction);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
// We couldn't fund P2SH-A at this time
|
||||
LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?");
|
||||
return ResponseResult.NETWORK_ISSUE;
|
||||
}
|
||||
|
||||
// Attempt to send MESSAGE to Bob's Qortal trade address
|
||||
byte[] messageData = DogecoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA());
|
||||
String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress;
|
||||
|
||||
boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
|
||||
if (!isMessageAlreadySent) {
|
||||
PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
|
||||
MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false);
|
||||
|
||||
messageTransaction.computeNonce();
|
||||
messageTransaction.sign(sender);
|
||||
|
||||
// reset repository state to prevent deadlock
|
||||
repository.discardChanges();
|
||||
ValidationResult result = messageTransaction.importAsUnconfirmed();
|
||||
|
||||
if (result != ValidationResult.OK) {
|
||||
LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name()));
|
||||
return ResponseResult.NETWORK_ISSUE;
|
||||
}
|
||||
}
|
||||
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress));
|
||||
|
||||
return ResponseResult.OK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canDelete(Repository repository, TradeBotData tradeBotData) throws DataException {
|
||||
State tradeBotState = State.valueOf(tradeBotData.getStateValue());
|
||||
if (tradeBotState == null)
|
||||
return true;
|
||||
|
||||
// If the AT doesn't exist then we might as well let the user tidy up
|
||||
if (!repository.getATRepository().exists(tradeBotData.getAtAddress()))
|
||||
return true;
|
||||
|
||||
switch (tradeBotState) {
|
||||
case BOB_WAITING_FOR_AT_CONFIRM:
|
||||
case ALICE_DONE:
|
||||
case BOB_DONE:
|
||||
case ALICE_REFUNDED:
|
||||
case BOB_REFUNDED:
|
||||
case ALICE_REFUNDING_A:
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException {
|
||||
State tradeBotState = State.valueOf(tradeBotData.getStateValue());
|
||||
if (tradeBotState == null) {
|
||||
LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress()));
|
||||
return;
|
||||
}
|
||||
|
||||
ATData atData = null;
|
||||
CrossChainTradeData tradeData = null;
|
||||
|
||||
if (tradeBotState.requiresAtData) {
|
||||
// Attempt to fetch AT data
|
||||
atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
|
||||
if (atData == null) {
|
||||
LOGGER.debug(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tradeBotState.requiresTradeData) {
|
||||
tradeData = DogecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||
if (tradeData == null) {
|
||||
LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (tradeBotState) {
|
||||
case BOB_WAITING_FOR_AT_CONFIRM:
|
||||
handleBobWaitingForAtConfirm(repository, tradeBotData);
|
||||
break;
|
||||
|
||||
case BOB_WAITING_FOR_MESSAGE:
|
||||
TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
|
||||
handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData);
|
||||
break;
|
||||
|
||||
case ALICE_WAITING_FOR_AT_LOCK:
|
||||
TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
|
||||
handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData);
|
||||
break;
|
||||
|
||||
case BOB_WAITING_FOR_AT_REDEEM:
|
||||
TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
|
||||
handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData);
|
||||
break;
|
||||
|
||||
case ALICE_DONE:
|
||||
case BOB_DONE:
|
||||
break;
|
||||
|
||||
case ALICE_REFUNDING_A:
|
||||
TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
|
||||
handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData);
|
||||
break;
|
||||
|
||||
case ALICE_REFUNDED:
|
||||
case BOB_REFUNDED:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade-bot is waiting for Bob's AT to deploy.
|
||||
* <p>
|
||||
* If AT is deployed, then trade-bot's next step is to wait for MESSAGE from Alice.
|
||||
*/
|
||||
private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException {
|
||||
if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) {
|
||||
if (NTP.getTime() - tradeBotData.getTimestamp() <= MAX_AT_CONFIRMATION_PERIOD)
|
||||
return;
|
||||
|
||||
// We've waited ages for AT to be confirmed into a block but something has gone awry.
|
||||
// After this long we assume transaction loss so give up with trade-bot entry too.
|
||||
tradeBotData.setState(State.BOB_REFUNDED.name());
|
||||
tradeBotData.setStateValue(State.BOB_REFUNDED.value);
|
||||
tradeBotData.setTimestamp(NTP.getTime());
|
||||
// We delete trade-bot entry here instead of saving, hence not using updateTradeBotState()
|
||||
repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
|
||||
repository.saveChanges();
|
||||
|
||||
LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress()));
|
||||
TradeBot.notifyStateChange(tradeBotData);
|
||||
return;
|
||||
}
|
||||
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE,
|
||||
() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info.
|
||||
* <p>
|
||||
* It's possible Bob has cancelling his trade offer, receiving an automatic QORT refund,
|
||||
* in which case trade-bot is done with this specific trade and finalizes on refunded state.
|
||||
* <p>
|
||||
* Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot.
|
||||
* <p>
|
||||
* Details from Alice are used to derive P2SH-A address and this is checked for funding balance.
|
||||
* <p>
|
||||
* Assuming P2SH-A has at least expected Dogecoin balance,
|
||||
* Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details.
|
||||
* <p>
|
||||
* On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice.
|
||||
* <p>
|
||||
* Trade-bot's next step is to wait for Alice to redeem the AT, which will allow Bob to
|
||||
* extract secret-A needed to redeem Alice's P2SH.
|
||||
* @throws ForeignBlockchainException
|
||||
*/
|
||||
private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData,
|
||||
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||
// If AT has finished then Bob likely cancelled his trade offer
|
||||
if (atData.getIsFinished()) {
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
|
||||
() -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress()));
|
||||
return;
|
||||
}
|
||||
|
||||
Dogecoin dogecoin = Dogecoin.getInstance();
|
||||
|
||||
String address = tradeBotData.getTradeNativeAddress();
|
||||
List<MessageTransactionData> messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null);
|
||||
|
||||
for (MessageTransactionData messageTransactionData : messageTransactionsData) {
|
||||
if (messageTransactionData.isText())
|
||||
continue;
|
||||
|
||||
// We're expecting: HASH160(secret-A), Alice's Dogecoin pubkeyhash and lockTime-A
|
||||
byte[] messageData = messageTransactionData.getData();
|
||||
DogecoinACCTv1.OfferMessageData offerMessageData = DogecoinACCTv1.extractOfferMessageData(messageData);
|
||||
if (offerMessageData == null)
|
||||
continue;
|
||||
|
||||
byte[] aliceForeignPublicKeyHash = offerMessageData.partnerDogecoinPKH;
|
||||
byte[] hashOfSecretA = offerMessageData.hashOfSecretA;
|
||||
int lockTimeA = (int) offerMessageData.lockTimeA;
|
||||
long messageTimestamp = messageTransactionData.getTimestamp();
|
||||
int refundTimeout = DogecoinACCTv1.calcRefundTimeout(messageTimestamp, lockTimeA);
|
||||
|
||||
// Determine P2SH-A address and confirm funded
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA);
|
||||
String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA);
|
||||
|
||||
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee;
|
||||
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
|
||||
switch (htlcStatusA) {
|
||||
case UNFUNDED:
|
||||
case FUNDING_IN_PROGRESS:
|
||||
// There might be another MESSAGE from someone else with an actually funded P2SH-A...
|
||||
continue;
|
||||
|
||||
case REDEEM_IN_PROGRESS:
|
||||
case REDEEMED:
|
||||
// We've already redeemed this?
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE,
|
||||
() -> String.format("P2SH-A %s already spent? Assuming trade complete", p2shAddressA));
|
||||
return;
|
||||
|
||||
case REFUND_IN_PROGRESS:
|
||||
case REFUNDED:
|
||||
// This P2SH-A is burnt, but there might be another MESSAGE from someone else with an actually funded P2SH-A...
|
||||
continue;
|
||||
|
||||
case FUNDED:
|
||||
// Fall-through out of switch...
|
||||
break;
|
||||
}
|
||||
|
||||
// Good to go - send MESSAGE to AT
|
||||
|
||||
String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey());
|
||||
|
||||
// Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume
|
||||
byte[] outgoingMessageData = DogecoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
|
||||
String messageRecipient = tradeBotData.getAtAddress();
|
||||
|
||||
boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData);
|
||||
if (!isMessageAlreadySent) {
|
||||
PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
|
||||
MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false);
|
||||
|
||||
outgoingMessageTransaction.computeNonce();
|
||||
outgoingMessageTransaction.sign(sender);
|
||||
|
||||
// reset repository state to prevent deadlock
|
||||
repository.discardChanges();
|
||||
ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed();
|
||||
|
||||
if (result != ValidationResult.OK) {
|
||||
LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM,
|
||||
() -> String.format("Locked AT %s to %s. Waiting for AT redeem", tradeBotData.getAtAddress(), aliceNativeAddress));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only.
|
||||
* <p>
|
||||
* It's possible that Bob has cancelled his trade offer in the mean time, or that somehow
|
||||
* this process has taken so long that we've reached P2SH-A's locktime, or that someone else
|
||||
* has managed to trade with Bob. In any of these cases, trade-bot switches to begin the refunding process.
|
||||
* <p>
|
||||
* Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct.
|
||||
* <p>
|
||||
* If all is well, trade-bot then redeems AT using Alice's secret-A, releasing Bob's QORT to Alice.
|
||||
* <p>
|
||||
* In revealing a valid secret-A, Bob can then redeem the DOGE funds from P2SH-A.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException
|
||||
*/
|
||||
private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData,
|
||||
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||
if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
|
||||
return;
|
||||
|
||||
Dogecoin dogecoin = Dogecoin.getInstance();
|
||||
int lockTimeA = tradeBotData.getLockTimeA();
|
||||
|
||||
// Refund P2SH-A if we've passed lockTime-A
|
||||
if (NTP.getTime() >= lockTimeA * 1000L) {
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||
String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA);
|
||||
|
||||
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
|
||||
switch (htlcStatusA) {
|
||||
case UNFUNDED:
|
||||
case FUNDING_IN_PROGRESS:
|
||||
case FUNDED:
|
||||
break;
|
||||
|
||||
case REDEEM_IN_PROGRESS:
|
||||
case REDEEMED:
|
||||
// Already redeemed?
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
|
||||
() -> String.format("P2SH-A %s already spent? Assuming trade completed", p2shAddressA));
|
||||
return;
|
||||
|
||||
case REFUND_IN_PROGRESS:
|
||||
case REFUNDED:
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
|
||||
() -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA));
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
|
||||
() -> atData.getIsFinished()
|
||||
? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA)
|
||||
: String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// We're waiting for AT to be in TRADE mode
|
||||
if (crossChainTradeData.mode != AcctMode.TRADING)
|
||||
return;
|
||||
|
||||
// AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above
|
||||
|
||||
// Find our MESSAGE to AT from previous state
|
||||
List<MessageTransactionData> messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(tradeBotData.getTradeNativePublicKey(),
|
||||
crossChainTradeData.qortalCreatorTradeAddress, null, null, null);
|
||||
if (messageTransactionsData == null || messageTransactionsData.isEmpty()) {
|
||||
LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress));
|
||||
return;
|
||||
}
|
||||
|
||||
long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp();
|
||||
int refundTimeout = DogecoinACCTv1.calcRefundTimeout(recipientMessageTimestamp, lockTimeA);
|
||||
|
||||
// Our calculated refundTimeout should match AT's refundTimeout
|
||||
if (refundTimeout != crossChainTradeData.refundTimeout) {
|
||||
LOGGER.debug(() -> String.format("Trade AT refundTimeout '%d' doesn't match our refundTimeout '%d'", crossChainTradeData.refundTimeout, refundTimeout));
|
||||
// We'll eventually refund
|
||||
return;
|
||||
}
|
||||
|
||||
// We're good to redeem AT
|
||||
|
||||
// Send 'redeem' MESSAGE to AT using both secret
|
||||
byte[] secretA = tradeBotData.getSecret();
|
||||
String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH
|
||||
byte[] messageData = DogecoinACCTv1.buildRedeemMessage(secretA, qortalReceivingAddress);
|
||||
String messageRecipient = tradeBotData.getAtAddress();
|
||||
|
||||
boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
|
||||
if (!isMessageAlreadySent) {
|
||||
PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
|
||||
MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false);
|
||||
|
||||
messageTransaction.computeNonce();
|
||||
messageTransaction.sign(sender);
|
||||
|
||||
// Reset repository state to prevent deadlock
|
||||
repository.discardChanges();
|
||||
ValidationResult result = messageTransaction.importAsUnconfirmed();
|
||||
|
||||
if (result != ValidationResult.OK) {
|
||||
LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
|
||||
() -> String.format("Redeeming AT %s. Funds should arrive at %s",
|
||||
tradeBotData.getAtAddress(), qortalReceivingAddress));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the DOGE funds from P2SH-A.
|
||||
* <p>
|
||||
* It's possible that Bob's AT has reached its trading timeout and automatically refunded QORT back to Bob. In which case,
|
||||
* trade-bot is done with this specific trade and finalizes in refunded state.
|
||||
* <p>
|
||||
* Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the DOGE funds from P2SH-A
|
||||
* to Bob's 'foreign'/Dogecoin trade legacy-format address, as derived from trade private key.
|
||||
* <p>
|
||||
* (This could potentially be 'improved' to send DOGE to any address of Bob's choosing by changing the transaction output).
|
||||
* <p>
|
||||
* If trade-bot successfully broadcasts the transaction, then this specific trade is done.
|
||||
* @throws ForeignBlockchainException
|
||||
*/
|
||||
private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData,
|
||||
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||
// AT should be 'finished' once Alice has redeemed QORT funds
|
||||
if (!atData.getIsFinished())
|
||||
// Not finished yet
|
||||
return;
|
||||
|
||||
// If AT is REFUNDED or CANCELLED then something has gone wrong
|
||||
if (crossChainTradeData.mode == AcctMode.REFUNDED || crossChainTradeData.mode == AcctMode.CANCELLED) {
|
||||
// Alice hasn't redeemed the QORT, so there is no point in trying to redeem the DOGE
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
|
||||
() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] secretA = DogecoinACCTv1.getInstance().findSecretA(repository, crossChainTradeData);
|
||||
if (secretA == null) {
|
||||
LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use secret-A to redeem P2SH-A
|
||||
|
||||
Dogecoin dogecoin = Dogecoin.getInstance();
|
||||
|
||||
byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo();
|
||||
int lockTimeA = crossChainTradeData.lockTimeA;
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA);
|
||||
String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA);
|
||||
|
||||
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
|
||||
switch (htlcStatusA) {
|
||||
case UNFUNDED:
|
||||
case FUNDING_IN_PROGRESS:
|
||||
// P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund
|
||||
return;
|
||||
|
||||
case REDEEM_IN_PROGRESS:
|
||||
case REDEEMED:
|
||||
// Double-check that we have redeemed P2SH-A...
|
||||
break;
|
||||
|
||||
case REFUND_IN_PROGRESS:
|
||||
case REFUNDED:
|
||||
// Wait for AT to auto-refund
|
||||
return;
|
||||
|
||||
case FUNDED: {
|
||||
Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
|
||||
ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
|
||||
List<TransactionOutput> fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(dogecoin.getNetworkParameters(), redeemAmount, redeemKey,
|
||||
fundingOutputs, redeemScriptA, secretA, receivingAccountInfo);
|
||||
|
||||
dogecoin.broadcastTransaction(p2shRedeemTransaction);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
String receivingAddress = dogecoin.pkhToAddress(receivingAccountInfo);
|
||||
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE,
|
||||
() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade-bot is attempting to refund P2SH-A.
|
||||
* @throws ForeignBlockchainException
|
||||
*/
|
||||
private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData,
|
||||
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||
int lockTimeA = tradeBotData.getLockTimeA();
|
||||
|
||||
// We can't refund P2SH-A until lockTime-A has passed
|
||||
if (NTP.getTime() <= lockTimeA * 1000L)
|
||||
return;
|
||||
|
||||
Dogecoin dogecoin = Dogecoin.getInstance();
|
||||
|
||||
// We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
|
||||
int medianBlockTime = dogecoin.getMedianBlockTime();
|
||||
if (medianBlockTime <= lockTimeA)
|
||||
return;
|
||||
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||
String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA);
|
||||
|
||||
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
|
||||
switch (htlcStatusA) {
|
||||
case UNFUNDED:
|
||||
case FUNDING_IN_PROGRESS:
|
||||
// Still waiting for P2SH-A to be funded...
|
||||
return;
|
||||
|
||||
case REDEEM_IN_PROGRESS:
|
||||
case REDEEMED:
|
||||
// Too late!
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
|
||||
() -> String.format("P2SH-A %s already spent!", p2shAddressA));
|
||||
return;
|
||||
|
||||
case REFUND_IN_PROGRESS:
|
||||
case REFUNDED:
|
||||
break;
|
||||
|
||||
case FUNDED:{
|
||||
Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
|
||||
ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
|
||||
List<TransactionOutput> fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
// Determine receive address for refund
|
||||
String receiveAddress = dogecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey());
|
||||
Address receiving = Address.fromString(dogecoin.getNetworkParameters(), receiveAddress);
|
||||
|
||||
Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(dogecoin.getNetworkParameters(), refundAmount, refundKey,
|
||||
fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash());
|
||||
|
||||
dogecoin.broadcastTransaction(p2shRefundTransaction);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
|
||||
() -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else.
|
||||
* <p>
|
||||
* Will automatically update trade-bot state to <tt>ALICE_REFUNDING_A</tt> or <tt>ALICE_DONE</tt> as necessary.
|
||||
*
|
||||
* @throws DataException
|
||||
* @throws ForeignBlockchainException
|
||||
*/
|
||||
private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData,
|
||||
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||
// This is OK
|
||||
if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING)
|
||||
return false;
|
||||
|
||||
boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress);
|
||||
|
||||
if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING)
|
||||
if (isAtLockedToUs) {
|
||||
// AT is trading with us - OK
|
||||
return false;
|
||||
} else {
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
|
||||
() -> String.format("AT %s trading with someone else: %s. Refunding & aborting trade", tradeBotData.getAtAddress(), crossChainTradeData.qortalPartnerAddress));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) {
|
||||
// We've redeemed already?
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
|
||||
() -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress()));
|
||||
} else {
|
||||
// Any other state is not good, so start defensive refund
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
|
||||
() -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress()));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) {
|
||||
return (lockTimeA - tradeTimeout * 60) * 1000L;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -211,6 +211,9 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot {
|
||||
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress));
|
||||
|
||||
// Attempt to backup the trade bot data
|
||||
TradeBot.backupTradeBotData(repository);
|
||||
|
||||
// Return to user for signing and broadcast as we don't have their Qortal private key
|
||||
try {
|
||||
return DeployAtTransactionTransformer.toBytes(deployAtTransactionData);
|
||||
@@ -283,6 +286,9 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot {
|
||||
tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
||||
crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash);
|
||||
|
||||
// Attempt to backup the trade bot data
|
||||
TradeBot.backupTradeBotData(repository);
|
||||
|
||||
// Check we have enough funds via xprv58 to fund P2SH to cover expectedForeignAmount
|
||||
long p2shFee;
|
||||
try {
|
||||
@@ -358,6 +364,7 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot {
|
||||
case BOB_DONE:
|
||||
case ALICE_REFUNDED:
|
||||
case BOB_REFUNDED:
|
||||
case ALICE_REFUNDING_A:
|
||||
return true;
|
||||
|
||||
default:
|
||||
@@ -381,15 +388,6 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot {
|
||||
atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
|
||||
if (atData == null) {
|
||||
LOGGER.debug(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
|
||||
|
||||
// If it has been over 24 hours since we last updated this trade-bot entry then assume AT is never coming back
|
||||
// and so wipe the trade-bot entry
|
||||
if (tradeBotData.getTimestamp() + MAX_AT_CONFIRMATION_PERIOD < NTP.getTime()) {
|
||||
LOGGER.info(() -> String.format("AT %s has been gone for too long - deleting trade-bot entry", tradeBotData.getAtAddress()));
|
||||
repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
|
||||
repository.saveChanges();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -719,16 +717,16 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot {
|
||||
// Not finished yet
|
||||
return;
|
||||
|
||||
// If AT is not REDEEMED then something has gone wrong
|
||||
if (crossChainTradeData.mode != AcctMode.REDEEMED) {
|
||||
// Not redeemed so must be refunded/cancelled
|
||||
// If AT is REFUNDED or CANCELLED then something has gone wrong
|
||||
if (crossChainTradeData.mode == AcctMode.REFUNDED || crossChainTradeData.mode == AcctMode.CANCELLED) {
|
||||
// Alice hasn't redeemed the QORT, so there is no point in trying to redeem the LTC
|
||||
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
|
||||
() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] secretA = LitecoinACCTv1.findSecretA(repository, crossChainTradeData);
|
||||
byte[] secretA = LitecoinACCTv1.getInstance().findSecretA(repository, crossChainTradeData);
|
||||
if (secretA == null) {
|
||||
LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress()));
|
||||
return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -16,11 +17,7 @@ import org.qortal.account.PrivateKeyAccount;
|
||||
import org.qortal.api.model.crosschain.TradeBotCreateRequest;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult;
|
||||
import org.qortal.crosschain.ACCT;
|
||||
import org.qortal.crosschain.BitcoinACCTv1;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.LitecoinACCTv1;
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
import org.qortal.crosschain.*;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.data.crosschain.TradeBotData;
|
||||
@@ -79,6 +76,7 @@ public class TradeBot implements Listener {
|
||||
static {
|
||||
acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance);
|
||||
acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance);
|
||||
acctTradeBotSuppliers.put(DogecoinACCTv1.class, DogecoinACCTv1TradeBot::getInstance);
|
||||
}
|
||||
|
||||
private static TradeBot instance;
|
||||
@@ -247,17 +245,17 @@ public class TradeBot implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/*package*/ static byte[] generateTradePrivateKey() {
|
||||
public static byte[] generateTradePrivateKey() {
|
||||
// The private key is used for both Curve25519 and secp256k1 so needs to be valid for both.
|
||||
// Curve25519 accepts any seed, so generate a valid secp256k1 key and use that.
|
||||
return new ECKey().getPrivKeyBytes();
|
||||
}
|
||||
|
||||
/*package*/ static byte[] deriveTradeNativePublicKey(byte[] privateKey) {
|
||||
public static byte[] deriveTradeNativePublicKey(byte[] privateKey) {
|
||||
return PrivateKeyAccount.toPublicKey(privateKey);
|
||||
}
|
||||
|
||||
/*package*/ static byte[] deriveTradeForeignPublicKey(byte[] privateKey) {
|
||||
public static byte[] deriveTradeForeignPublicKey(byte[] privateKey) {
|
||||
return ECKey.fromPrivate(privateKey).getPubKey();
|
||||
}
|
||||
|
||||
@@ -267,6 +265,16 @@ public class TradeBot implements Listener {
|
||||
return secret;
|
||||
}
|
||||
|
||||
/*package*/ static void backupTradeBotData(Repository repository) {
|
||||
// Attempt to backup the trade bot data. This an optional step and doesn't impact trading, so don't throw an exception on failure
|
||||
try {
|
||||
LOGGER.info("About to backup trade bot data...");
|
||||
repository.exportNodeLocalData();
|
||||
} catch (DataException e) {
|
||||
LOGGER.info(String.format("Repository issue when exporting trade bot data: %s", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
|
||||
/*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData,
|
||||
String newState, int newStateValue, Supplier<String> logMessageSupplier) throws DataException {
|
||||
|
||||
@@ -20,4 +20,6 @@ public interface ACCT {
|
||||
|
||||
public byte[] buildCancelMessage(String creatorQortalAddress);
|
||||
|
||||
public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException;
|
||||
|
||||
}
|
||||
|
||||
@@ -42,35 +42,36 @@ public class Bitcoin extends Bitcoiny {
|
||||
public Collection<ElectrumX.Server> getServers() {
|
||||
return Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
new Server("enode.duckdns.org", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrumx.ml", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.bitkoins.nl", Server.ConnectionType.SSL, 50512),
|
||||
new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002),
|
||||
new Server("electrumx.electricnewyear.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("dxm.no-ip.biz", Server.ConnectionType.TCP, 50001),
|
||||
new Server("kirsche.emzy.de", Server.ConnectionType.TCP, 50001),
|
||||
new Server("2AZZARITA.hopto.org", Server.ConnectionType.TCP, 50001),
|
||||
new Server("xtrum.com", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrum.srvmin.network", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrumx.alexridevski.net", Server.ConnectionType.TCP, 50001),
|
||||
new Server("bitcoin.lukechilds.co", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrum.poiuty.com", Server.ConnectionType.TCP, 50001),
|
||||
new Server("horsey.cryptocowboys.net", Server.ConnectionType.TCP, 50001),
|
||||
new Server("128.0.190.26", Server.ConnectionType.SSL, 50002),
|
||||
new Server("hodlers.beer", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrumx.erbium.eu", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrumx.erbium.eu", Server.ConnectionType.SSL, 50002),
|
||||
new Server("btc.lastingcoin.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.bitaroo.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("bitcoin.grey.pw", Server.ConnectionType.SSL, 50002),
|
||||
new Server("2electrumx.hopto.me", Server.ConnectionType.SSL, 56022),
|
||||
new Server("185.64.116.15", Server.ConnectionType.SSL, 50002),
|
||||
new Server("kirsche.emzy.de", Server.ConnectionType.SSL, 50002),
|
||||
new Server("alviss.coinjoined.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.emzy.de", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.emzy.de", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrum-server.ninja", Server.ConnectionType.TCP, 50081),
|
||||
new Server("bitcoin.electrumx.multicoin.co", Server.ConnectionType.TCP, 50001),
|
||||
new Server("esx.geekhosters.com", Server.ConnectionType.TCP, 50001),
|
||||
new Server("bitcoin.grey.pw", Server.ConnectionType.TCP, 50003),
|
||||
new Server("exs.ignorelist.com", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrum.coinext.com.br", Server.ConnectionType.TCP, 50001),
|
||||
new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001),
|
||||
new Server("skbxmit.coinjoined.com", Server.ConnectionType.TCP, 50001),
|
||||
new Server("alviss.coinjoined.com", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrum2.privateservers.network", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrumx.schulzemic.net", Server.ConnectionType.TCP, 50001),
|
||||
new Server("bitcoins.sk", Server.ConnectionType.TCP, 56001),
|
||||
new Server("node.mendonca.xyz", Server.ConnectionType.TCP, 50001),
|
||||
new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001));
|
||||
new Server("vmd71287.contaboserver.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("btc.litepay.ch", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.stippy.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("xtrum.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.acinq.co", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum2.taborsky.cz", Server.ConnectionType.SSL, 50002),
|
||||
new Server("vmd63185.contaboserver.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum2.privateservers.network", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrumx.alexridevski.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("192.166.219.200", Server.ConnectionType.SSL, 50002),
|
||||
new Server("2ex.digitaleveryware.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("dxm.no-ip.biz", Server.ConnectionType.SSL, 50002),
|
||||
new Server("caleb.vegas", Server.ConnectionType.SSL, 50002),
|
||||
new Server("ecdsa.net", Server.ConnectionType.SSL, 110),
|
||||
new Server("electrum.hsmiths.com", Server.ConnectionType.SSL, 995),
|
||||
new Server("elec.luggs.co", Server.ConnectionType.SSL, 443),
|
||||
new Server("btc.smsys.me", Server.ConnectionType.SSL, 995));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -96,10 +97,8 @@ public class Bitcoin extends Bitcoiny {
|
||||
@Override
|
||||
public Collection<ElectrumX.Server> getServers() {
|
||||
return Arrays.asList(
|
||||
new Server("electrum.blockstream.info", Server.ConnectionType.TCP, 60001),
|
||||
new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 60002),
|
||||
new Server("tn.not.fyi", Server.ConnectionType.SSL, 55002),
|
||||
new Server("electrumx-test.1209k.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("testnet.qtornado.com", Server.ConnectionType.TCP, 51001),
|
||||
new Server("testnet.qtornado.com", Server.ConnectionType.SSL, 51002),
|
||||
new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001),
|
||||
new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002),
|
||||
|
||||
@@ -872,7 +872,8 @@ public class BitcoinACCTv1 implements ACCT {
|
||||
return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L);
|
||||
}
|
||||
|
||||
public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
|
||||
@Override
|
||||
public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
|
||||
String atAddress = crossChainTradeData.qortalAtAddress;
|
||||
String redeemerAddress = crossChainTradeData.qortalPartnerAddress;
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
return this.params;
|
||||
}
|
||||
|
||||
// Interface obligations
|
||||
// Interface obligations
|
||||
|
||||
@Override
|
||||
public boolean isValidAddress(String address) {
|
||||
@@ -169,9 +169,14 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
return this.bitcoinjContext.getFeePerKb();
|
||||
}
|
||||
|
||||
/** Returns minimum order size in sats. To be overridden for coins that need to restrict order size. */
|
||||
public long getMinimumOrderAmount() {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns fixed P2SH spending fee, in sats per 1000bytes, optionally for historic timestamp.
|
||||
*
|
||||
*
|
||||
* @param timestamp optional milliseconds since epoch, or null for 'now'
|
||||
* @return sats per 1000bytes
|
||||
* @throws ForeignBlockchainException if something went wrong
|
||||
@@ -271,7 +276,7 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
|
||||
/**
|
||||
* Returns bitcoinj transaction sending <tt>amount</tt> to <tt>recipient</tt>.
|
||||
*
|
||||
*
|
||||
* @param xprv58 BIP32 private key
|
||||
* @param recipient P2PKH address
|
||||
* @param amount unscaled amount
|
||||
@@ -303,7 +308,7 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
|
||||
/**
|
||||
* Returns bitcoinj transaction sending <tt>amount</tt> to <tt>recipient</tt> using default fees.
|
||||
*
|
||||
*
|
||||
* @param xprv58 BIP32 private key
|
||||
* @param recipient P2PKH address
|
||||
* @param amount unscaled amount
|
||||
@@ -332,7 +337,7 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
return balance.value;
|
||||
}
|
||||
|
||||
public List<BitcoinyTransaction> getWalletTransactions(String key58) throws ForeignBlockchainException {
|
||||
public List<SimpleTransaction> getWalletTransactions(String key58) throws ForeignBlockchainException {
|
||||
Context.propagate(bitcoinjContext);
|
||||
|
||||
Wallet wallet = walletFromDeterministicKey58(key58);
|
||||
@@ -344,7 +349,12 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
List<DeterministicKey> keys = new ArrayList<>(keyChain.getLeafKeys());
|
||||
|
||||
Set<BitcoinyTransaction> walletTransactions = new HashSet<>();
|
||||
Set<String> keySet = new HashSet<>();
|
||||
|
||||
// Set the number of consecutive empty batches required before giving up
|
||||
final int numberOfAdditionalBatchesToSearch = 5;
|
||||
|
||||
int unusedCounter = 0;
|
||||
int ki = 0;
|
||||
do {
|
||||
boolean areAllKeysUnused = true;
|
||||
@@ -354,6 +364,7 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
|
||||
// Check for transactions
|
||||
Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH);
|
||||
keySet.add(address.toString());
|
||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||
|
||||
// Ask for transaction history - if it's empty then key has never been used
|
||||
@@ -367,9 +378,19 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
}
|
||||
}
|
||||
|
||||
if (areAllKeysUnused)
|
||||
// No transactions for this batch of keys so assume we're done searching.
|
||||
break;
|
||||
if (areAllKeysUnused) {
|
||||
// No transactions
|
||||
if (unusedCounter >= numberOfAdditionalBatchesToSearch) {
|
||||
// ... and we've hit our search limit
|
||||
break;
|
||||
}
|
||||
// We haven't hit our search limit yet so increment the counter and keep looking
|
||||
unusedCounter++;
|
||||
}
|
||||
else {
|
||||
// Some keys in this batch were used, so reset the counter
|
||||
unusedCounter = 0;
|
||||
}
|
||||
|
||||
// Generate some more keys
|
||||
keys.addAll(generateMoreKeys(keyChain));
|
||||
@@ -377,9 +398,56 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
// Process new keys
|
||||
} while (true);
|
||||
|
||||
Comparator<BitcoinyTransaction> newestTimestampFirstComparator = Comparator.comparingInt((BitcoinyTransaction txn) -> txn.timestamp).reversed();
|
||||
Comparator<SimpleTransaction> newestTimestampFirstComparator = Comparator.comparingInt(SimpleTransaction::getTimestamp).reversed();
|
||||
|
||||
return walletTransactions.stream().sorted(newestTimestampFirstComparator).collect(Collectors.toList());
|
||||
return walletTransactions.stream().map(t -> convertToSimpleTransaction(t, keySet)).sorted(newestTimestampFirstComparator).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
protected SimpleTransaction convertToSimpleTransaction(BitcoinyTransaction t, Set<String> keySet) {
|
||||
long amount = 0;
|
||||
long total = 0L;
|
||||
long totalInputAmount = 0L;
|
||||
long totalOutputAmount = 0L;
|
||||
List<SimpleTransaction.Input> inputs = new ArrayList<>();
|
||||
List<SimpleTransaction.Output> outputs = new ArrayList<>();
|
||||
|
||||
for (BitcoinyTransaction.Input input : t.inputs) {
|
||||
try {
|
||||
BitcoinyTransaction t2 = getTransaction(input.outputTxHash);
|
||||
List<String> senders = t2.outputs.get(input.outputVout).addresses;
|
||||
long inputAmount = t2.outputs.get(input.outputVout).value;
|
||||
totalInputAmount += inputAmount;
|
||||
for (String sender : senders) {
|
||||
boolean addressInWallet = false;
|
||||
if (keySet.contains(sender)) {
|
||||
total += inputAmount;
|
||||
addressInWallet = true;
|
||||
}
|
||||
inputs.add(new SimpleTransaction.Input(sender, inputAmount, addressInWallet));
|
||||
}
|
||||
} catch (ForeignBlockchainException e) {
|
||||
LOGGER.trace("Failed to retrieve transaction information {}", input.outputTxHash);
|
||||
}
|
||||
}
|
||||
if (t.outputs != null && !t.outputs.isEmpty()) {
|
||||
for (BitcoinyTransaction.Output output : t.outputs) {
|
||||
for (String address : output.addresses) {
|
||||
boolean addressInWallet = false;
|
||||
if (keySet.contains(address)) {
|
||||
if (total > 0L) {
|
||||
amount -= (total - output.value);
|
||||
} else {
|
||||
amount += output.value;
|
||||
}
|
||||
addressInWallet = true;
|
||||
}
|
||||
outputs.add(new SimpleTransaction.Output(address, output.value, addressInWallet));
|
||||
}
|
||||
totalOutputAmount += output.value;
|
||||
}
|
||||
}
|
||||
long fee = totalInputAmount - totalOutputAmount;
|
||||
return new SimpleTransaction(t.txHash, t.timestamp, amount, fee, inputs, outputs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,7 +489,7 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
* If there are no unspent outputs then either:
|
||||
* a) all the outputs have been spent
|
||||
* b) address has never been used
|
||||
*
|
||||
*
|
||||
* For case (a) we want to remember not to check this address (key) again.
|
||||
*/
|
||||
|
||||
@@ -501,7 +569,7 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
* If there are no unspent outputs then either:
|
||||
* a) all the outputs have been spent
|
||||
* b) address has never been used
|
||||
*
|
||||
*
|
||||
* For case (a) we want to remember not to check this address (key) again.
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.bitcoinj.core.Address;
|
||||
import org.bitcoinj.core.Base58;
|
||||
import org.bitcoinj.core.Coin;
|
||||
import org.bitcoinj.core.ECKey;
|
||||
import org.bitcoinj.core.LegacyAddress;
|
||||
@@ -25,6 +24,7 @@ import org.bitcoinj.script.ScriptBuilder;
|
||||
import org.bitcoinj.script.ScriptChunk;
|
||||
import org.bitcoinj.script.ScriptOpCodes;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
|
||||
171
src/main/java/org/qortal/crosschain/Dogecoin.java
Normal file
171
src/main/java/org/qortal/crosschain/Dogecoin.java
Normal file
@@ -0,0 +1,171 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import org.bitcoinj.core.Coin;
|
||||
import org.bitcoinj.core.Context;
|
||||
import org.bitcoinj.core.NetworkParameters;
|
||||
import org.libdohj.params.DogecoinMainNetParams;
|
||||
//import org.libdohj.params.DogecoinRegTestParams;
|
||||
import org.libdohj.params.DogecoinTestNet3Params;
|
||||
import org.qortal.crosschain.ElectrumX.Server;
|
||||
import org.qortal.crosschain.ElectrumX.Server.ConnectionType;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class Dogecoin extends Bitcoiny {
|
||||
|
||||
public static final String CURRENCY_CODE = "DOGE";
|
||||
|
||||
private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(500000000); // 5 DOGE per 1000 bytes
|
||||
|
||||
private static final long MINIMUM_ORDER_AMOUNT = 300000000L; // 3 DOGE minimum order. The RPC dust threshold is around 2 DOGE
|
||||
|
||||
// Temporary values until a dynamic fee system is written.
|
||||
private static final long MAINNET_FEE = 110000000L;
|
||||
private static final long NON_MAINNET_FEE = 10000L; // TODO: calibrate this
|
||||
|
||||
private static final Map<ConnectionType, Integer> DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ConnectionType.class);
|
||||
static {
|
||||
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001);
|
||||
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002);
|
||||
}
|
||||
|
||||
public enum DogecoinNet {
|
||||
MAIN {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return DogecoinMainNetParams.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Server> getServers() {
|
||||
return Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
new Server("electrum1.cipig.net", ConnectionType.TCP, 10060),
|
||||
new Server("electrum2.cipig.net", ConnectionType.TCP, 10060),
|
||||
new Server("electrum3.cipig.net", ConnectionType.TCP, 10060));
|
||||
// TODO: add more mainnet servers. It's too centralized.
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGenesisHash() {
|
||||
return "1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691";
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getP2shFee(Long timestamp) {
|
||||
// TODO: This will need to be replaced with something better in the near future!
|
||||
return MAINNET_FEE;
|
||||
}
|
||||
},
|
||||
TEST3 {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return DogecoinTestNet3Params.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Server> getServers() {
|
||||
return Arrays.asList(); // TODO: find testnet servers
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGenesisHash() {
|
||||
return "4966625a4b2851d9fdee139e56211a0d88575f59ed816ff5e6a63deb4e3e29a0";
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getP2shFee(Long timestamp) {
|
||||
return NON_MAINNET_FEE;
|
||||
}
|
||||
},
|
||||
REGTEST {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return null; // TODO: DogecoinRegTestParams.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Server> getServers() {
|
||||
return Arrays.asList(
|
||||
new Server("localhost", ConnectionType.TCP, 50001),
|
||||
new Server("localhost", ConnectionType.SSL, 50002));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGenesisHash() {
|
||||
// This is unique to each regtest instance
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getP2shFee(Long timestamp) {
|
||||
return NON_MAINNET_FEE;
|
||||
}
|
||||
};
|
||||
|
||||
public abstract NetworkParameters getParams();
|
||||
public abstract Collection<Server> getServers();
|
||||
public abstract String getGenesisHash();
|
||||
public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException;
|
||||
}
|
||||
|
||||
private static Dogecoin instance;
|
||||
|
||||
private final DogecoinNet dogecoinNet;
|
||||
|
||||
// Constructors and instance
|
||||
|
||||
private Dogecoin(DogecoinNet dogecoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) {
|
||||
super(blockchain, bitcoinjContext, currencyCode);
|
||||
this.dogecoinNet = dogecoinNet;
|
||||
|
||||
LOGGER.info(() -> String.format("Starting Dogecoin support using %s", this.dogecoinNet.name()));
|
||||
}
|
||||
|
||||
public static synchronized Dogecoin getInstance() {
|
||||
if (instance == null) {
|
||||
DogecoinNet dogecoinNet = Settings.getInstance().getDogecoinNet();
|
||||
|
||||
BitcoinyBlockchainProvider electrumX = new ElectrumX("Dogecoin-" + dogecoinNet.name(), dogecoinNet.getGenesisHash(), dogecoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS);
|
||||
Context bitcoinjContext = new Context(dogecoinNet.getParams());
|
||||
|
||||
instance = new Dogecoin(dogecoinNet, electrumX, bitcoinjContext, CURRENCY_CODE);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Getters & setters
|
||||
|
||||
public static synchronized void resetForTesting() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
// Actual useful methods for use by other classes
|
||||
|
||||
@Override
|
||||
public Coin getFeePerKb() {
|
||||
return DEFAULT_FEE_PER_KB;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getMinimumOrderAmount() {
|
||||
return MINIMUM_ORDER_AMOUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns estimated DOGE fee, in sats per 1000bytes, optionally for historic timestamp.
|
||||
*
|
||||
* @param timestamp optional milliseconds since epoch, or null for 'now'
|
||||
* @return sats per 1000bytes, or throws ForeignBlockchainException if something went wrong
|
||||
*/
|
||||
@Override
|
||||
public long getP2shFee(Long timestamp) throws ForeignBlockchainException {
|
||||
return this.dogecoinNet.getP2shFee(timestamp);
|
||||
}
|
||||
|
||||
}
|
||||
855
src/main/java/org/qortal/crosschain/DogecoinACCTv1.java
Normal file
855
src/main/java/org/qortal/crosschain/DogecoinACCTv1.java
Normal file
@@ -0,0 +1,855 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
import com.google.common.primitives.Bytes;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.ciyam.at.*;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.at.QortalFunctionCode;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.data.transaction.MessageTransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.ciyam.at.OpCode.calcOffset;
|
||||
|
||||
/**
|
||||
* Cross-chain trade AT
|
||||
*
|
||||
* <p>
|
||||
* <ul>
|
||||
* <li>Bob generates Dogecoin & Qortal 'trade' keys
|
||||
* <ul>
|
||||
* <li>private key required to sign P2SH redeem tx</li>
|
||||
* <li>private key could be used to create 'secret' (e.g. double-SHA256)</li>
|
||||
* <li>encrypted private key could be stored in Qortal AT for access by Bob from any node</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Bob deploys Qortal AT
|
||||
* <ul>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Alice finds Qortal AT and wants to trade
|
||||
* <ul>
|
||||
* <li>Alice generates Dogecoin & Qortal 'trade' keys</li>
|
||||
* <li>Alice funds Dogecoin P2SH-A</li>
|
||||
* <li>Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing:
|
||||
* <ul>
|
||||
* <li>hash-of-secret-A</li>
|
||||
* <li>her 'trade' Dogecoin PKH</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Bob receives "offer" MESSAGE
|
||||
* <ul>
|
||||
* <li>Checks Alice's P2SH-A</li>
|
||||
* <li>Sends 'trade' MESSAGE to Qortal AT from his trade address, containing:
|
||||
* <ul>
|
||||
* <li>Alice's trade Qortal address</li>
|
||||
* <li>Alice's trade Dogecoin PKH</li>
|
||||
* <li>hash-of-secret-A</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Alice checks Qortal AT to confirm it's locked to her
|
||||
* <ul>
|
||||
* <li>Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing:
|
||||
* <ul>
|
||||
* <li>secret-A</li>
|
||||
* <li>Qortal receiving address of her chosing</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>AT's QORT funds are sent to Qortal receiving address</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Bob checks AT, extracts secret-A
|
||||
* <ul>
|
||||
* <li>Bob redeems P2SH-A using his Dogecoin trade key and secret-A</li>
|
||||
* <li>P2SH-A DOGE funds end up at Dogecoin address determined by redeem transaction output(s)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
*/
|
||||
public class DogecoinACCTv1 implements ACCT {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv1.class);
|
||||
|
||||
public static final String NAME = DogecoinACCTv1.class.getSimpleName();
|
||||
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0eb49b0313ff3855a29d860c2a8203faa2ef62e28ea30459321f176079cfa3a5").asBytes(); // SHA256 of AT code bytes
|
||||
|
||||
public static final int SECRET_LENGTH = 32;
|
||||
|
||||
/** <b>Value</b> offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */
|
||||
private static final int MODE_VALUE_OFFSET = 61;
|
||||
/** <b>Byte</b> offset into AT state data where 'mode' variable (long) is stored. */
|
||||
public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE);
|
||||
|
||||
public static class OfferMessageData {
|
||||
public byte[] partnerDogecoinPKH;
|
||||
public byte[] hashOfSecretA;
|
||||
public long lockTimeA;
|
||||
}
|
||||
public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerDogecoinPKH*/ + 20 /*hashOfSecretA*/ + 8 /*lockTimeA*/;
|
||||
public static final int TRADE_MESSAGE_LENGTH = 32 /*partner's Qortal trade address (padded from 25 to 32)*/
|
||||
+ 24 /*partner's Dogecoin PKH (padded from 20 to 24)*/
|
||||
+ 8 /*AT trade timeout (minutes)*/
|
||||
+ 24 /*hash of secret-A (padded from 20 to 24)*/
|
||||
+ 8 /*lockTimeA*/;
|
||||
public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret-A*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/;
|
||||
public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/;
|
||||
|
||||
private static DogecoinACCTv1 instance;
|
||||
|
||||
private DogecoinACCTv1() {
|
||||
}
|
||||
|
||||
public static synchronized DogecoinACCTv1 getInstance() {
|
||||
if (instance == null)
|
||||
instance = new DogecoinACCTv1();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getCodeBytesHash() {
|
||||
return CODE_BYTES_HASH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getModeByteOffset() {
|
||||
return MODE_BYTE_OFFSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ForeignBlockchain getBlockchain() {
|
||||
return Dogecoin.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Qortal AT creation bytes for cross-chain trading AT.
|
||||
* <p>
|
||||
* <tt>tradeTimeout</tt> (minutes) is the time window for the trade partner to send the
|
||||
* 32-byte secret to the AT, before the AT automatically refunds the AT's creator.
|
||||
*
|
||||
* @param creatorTradeAddress AT creator's trade Qortal address
|
||||
* @param dogecoinPublicKeyHash 20-byte HASH160 of creator's trade Dogecoin public key
|
||||
* @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT
|
||||
* @param dogecoinAmount how much DOGE the AT creator is expecting to trade
|
||||
* @param tradeTimeout suggested timeout for entire trade
|
||||
*/
|
||||
public static byte[] buildQortalAT(String creatorTradeAddress, byte[] dogecoinPublicKeyHash, long qortAmount, long dogecoinAmount, int tradeTimeout) {
|
||||
if (dogecoinPublicKeyHash.length != 20)
|
||||
throw new IllegalArgumentException("Dogecoin public key hash should be 20 bytes");
|
||||
|
||||
// Labels for data segment addresses
|
||||
int addrCounter = 0;
|
||||
|
||||
// Constants (with corresponding dataByteBuffer.put*() calls below)
|
||||
|
||||
final int addrCreatorTradeAddress1 = addrCounter++;
|
||||
final int addrCreatorTradeAddress2 = addrCounter++;
|
||||
final int addrCreatorTradeAddress3 = addrCounter++;
|
||||
final int addrCreatorTradeAddress4 = addrCounter++;
|
||||
|
||||
final int addrDogecoinPublicKeyHash = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrQortAmount = addrCounter++;
|
||||
final int addrDogecoinAmount = addrCounter++;
|
||||
final int addrTradeTimeout = addrCounter++;
|
||||
|
||||
final int addrMessageTxnType = addrCounter++;
|
||||
final int addrExpectedTradeMessageLength = addrCounter++;
|
||||
final int addrExpectedRedeemMessageLength = addrCounter++;
|
||||
|
||||
final int addrCreatorAddressPointer = addrCounter++;
|
||||
final int addrQortalPartnerAddressPointer = addrCounter++;
|
||||
final int addrMessageSenderPointer = addrCounter++;
|
||||
|
||||
final int addrTradeMessagePartnerDogecoinPKHOffset = addrCounter++;
|
||||
final int addrPartnerDogecoinPKHPointer = addrCounter++;
|
||||
final int addrTradeMessageHashOfSecretAOffset = addrCounter++;
|
||||
final int addrHashOfSecretAPointer = addrCounter++;
|
||||
|
||||
final int addrRedeemMessageReceivingAddressOffset = addrCounter++;
|
||||
|
||||
final int addrMessageDataPointer = addrCounter++;
|
||||
final int addrMessageDataLength = addrCounter++;
|
||||
|
||||
final int addrPartnerReceivingAddressPointer = addrCounter++;
|
||||
|
||||
final int addrEndOfConstants = addrCounter;
|
||||
|
||||
// Variables
|
||||
|
||||
final int addrCreatorAddress1 = addrCounter++;
|
||||
final int addrCreatorAddress2 = addrCounter++;
|
||||
final int addrCreatorAddress3 = addrCounter++;
|
||||
final int addrCreatorAddress4 = addrCounter++;
|
||||
|
||||
final int addrQortalPartnerAddress1 = addrCounter++;
|
||||
final int addrQortalPartnerAddress2 = addrCounter++;
|
||||
final int addrQortalPartnerAddress3 = addrCounter++;
|
||||
final int addrQortalPartnerAddress4 = addrCounter++;
|
||||
|
||||
final int addrLockTimeA = addrCounter++;
|
||||
final int addrRefundTimeout = addrCounter++;
|
||||
final int addrRefundTimestamp = addrCounter++;
|
||||
final int addrLastTxnTimestamp = addrCounter++;
|
||||
final int addrBlockTimestamp = addrCounter++;
|
||||
final int addrTxnType = addrCounter++;
|
||||
final int addrResult = addrCounter++;
|
||||
|
||||
final int addrMessageSender1 = addrCounter++;
|
||||
final int addrMessageSender2 = addrCounter++;
|
||||
final int addrMessageSender3 = addrCounter++;
|
||||
final int addrMessageSender4 = addrCounter++;
|
||||
|
||||
final int addrMessageLength = addrCounter++;
|
||||
|
||||
final int addrMessageData = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrHashOfSecretA = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrPartnerDogecoinPKH = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrPartnerReceivingAddress = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrMode = addrCounter++;
|
||||
assert addrMode == MODE_VALUE_OFFSET : String.format("addrMode %d does not match MODE_VALUE_OFFSET %d", addrMode, MODE_VALUE_OFFSET);
|
||||
|
||||
// Data segment
|
||||
ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE);
|
||||
|
||||
// AT creator's trade Qortal address, decoded from Base58
|
||||
assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect";
|
||||
byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress);
|
||||
dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0));
|
||||
|
||||
// Dogecoin public key hash
|
||||
assert dataByteBuffer.position() == addrDogecoinPublicKeyHash * MachineState.VALUE_SIZE : "addrDogecoinPublicKeyHash incorrect";
|
||||
dataByteBuffer.put(Bytes.ensureCapacity(dogecoinPublicKeyHash, 32, 0));
|
||||
|
||||
// Redeem Qort amount
|
||||
assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect";
|
||||
dataByteBuffer.putLong(qortAmount);
|
||||
|
||||
// Expected Dogecoin amount
|
||||
assert dataByteBuffer.position() == addrDogecoinAmount * MachineState.VALUE_SIZE : "addrDogecoinAmount incorrect";
|
||||
dataByteBuffer.putLong(dogecoinAmount);
|
||||
|
||||
// Suggested trade timeout (minutes)
|
||||
assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect";
|
||||
dataByteBuffer.putLong(tradeTimeout);
|
||||
|
||||
// We're only interested in MESSAGE transactions
|
||||
assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect";
|
||||
dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value);
|
||||
|
||||
// Expected length of 'trade' MESSAGE data from AT creator
|
||||
assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect";
|
||||
dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH);
|
||||
|
||||
// Expected length of 'redeem' MESSAGE data from trade partner
|
||||
assert dataByteBuffer.position() == addrExpectedRedeemMessageLength * MachineState.VALUE_SIZE : "addrExpectedRedeemMessageLength incorrect";
|
||||
dataByteBuffer.putLong(REDEEM_MESSAGE_LENGTH);
|
||||
|
||||
// Index into data segment of AT creator's address, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect";
|
||||
dataByteBuffer.putLong(addrCreatorAddress1);
|
||||
|
||||
// Index into data segment of partner's Qortal address, used by SET_B_IND
|
||||
assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect";
|
||||
dataByteBuffer.putLong(addrQortalPartnerAddress1);
|
||||
|
||||
// Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect";
|
||||
dataByteBuffer.putLong(addrMessageSender1);
|
||||
|
||||
// Offset into 'trade' MESSAGE data payload for extracting partner's Dogecoin PKH
|
||||
assert dataByteBuffer.position() == addrTradeMessagePartnerDogecoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerDogecoinPKHOffset incorrect";
|
||||
dataByteBuffer.putLong(32L);
|
||||
|
||||
// Index into data segment of partner's Dogecoin PKH, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrPartnerDogecoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerDogecoinPKHPointer incorrect";
|
||||
dataByteBuffer.putLong(addrPartnerDogecoinPKH);
|
||||
|
||||
// Offset into 'trade' MESSAGE data payload for extracting hash-of-secret-A
|
||||
assert dataByteBuffer.position() == addrTradeMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrTradeMessageHashOfSecretAOffset incorrect";
|
||||
dataByteBuffer.putLong(64L);
|
||||
|
||||
// Index into data segment to hash of secret A, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect";
|
||||
dataByteBuffer.putLong(addrHashOfSecretA);
|
||||
|
||||
// Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address
|
||||
assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect";
|
||||
dataByteBuffer.putLong(32L);
|
||||
|
||||
// Source location and length for hashing any passed secret
|
||||
assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect";
|
||||
dataByteBuffer.putLong(addrMessageData);
|
||||
assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect";
|
||||
dataByteBuffer.putLong(32L);
|
||||
|
||||
// Pointer into data segment of where to save partner's receiving Qortal address, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrPartnerReceivingAddressPointer * MachineState.VALUE_SIZE : "addrPartnerReceivingAddressPointer incorrect";
|
||||
dataByteBuffer.putLong(addrPartnerReceivingAddress);
|
||||
|
||||
assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants";
|
||||
|
||||
// Code labels
|
||||
Integer labelRefund = null;
|
||||
|
||||
Integer labelTradeTxnLoop = null;
|
||||
Integer labelCheckTradeTxn = null;
|
||||
Integer labelCheckCancelTxn = null;
|
||||
Integer labelNotTradeNorCancelTxn = null;
|
||||
Integer labelCheckNonRefundTradeTxn = null;
|
||||
Integer labelTradeTxnExtract = null;
|
||||
Integer labelRedeemTxnLoop = null;
|
||||
Integer labelCheckRedeemTxn = null;
|
||||
Integer labelCheckRedeemTxnSender = null;
|
||||
Integer labelPayout = null;
|
||||
|
||||
ByteBuffer codeByteBuffer = ByteBuffer.allocate(768);
|
||||
|
||||
// Two-pass version
|
||||
for (int pass = 0; pass < 2; ++pass) {
|
||||
codeByteBuffer.clear();
|
||||
|
||||
try {
|
||||
/* Initialization */
|
||||
|
||||
/* NOP - to ensure DOGECOIN ACCT is unique */
|
||||
codeByteBuffer.put(OpCode.NOP.compile());
|
||||
|
||||
// Use AT creation 'timestamp' as starting point for finding transactions sent to AT
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxnTimestamp));
|
||||
|
||||
// Load B register with AT creator's address so we can save it into addrCreatorAddress1-4
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B));
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer));
|
||||
|
||||
// Set restart position to after this opcode
|
||||
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
||||
|
||||
/* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */
|
||||
|
||||
/* Transaction processing loop */
|
||||
labelTradeTxnLoop = codeByteBuffer.position();
|
||||
|
||||
// Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp));
|
||||
// If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
|
||||
// If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
|
||||
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTxn)));
|
||||
// Stop and wait for next block
|
||||
codeByteBuffer.put(OpCode.STP_IMD.compile());
|
||||
|
||||
/* Check transaction */
|
||||
labelCheckTradeTxn = codeByteBuffer.position();
|
||||
|
||||
// Update our 'last found transaction's timestamp' using 'timestamp' from transaction
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp));
|
||||
// Extract transaction type (message/payment) from transaction and save type in addrTxnType
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType));
|
||||
// If transaction type is not MESSAGE type then go look for another transaction
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop)));
|
||||
|
||||
/* Check transaction's sender. We're expecting AT creator's trade address for 'trade' message, or AT creator's own address for 'cancel' message. */
|
||||
|
||||
// Extract sender address from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
|
||||
// Compare each part of message sender's address with AT creator's trade address. If they don't match, check for cancel situation.
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
|
||||
// Message sender's address matches AT creator's trade address so go process 'trade' message
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelCheckNonRefundTradeTxn == null ? 0 : labelCheckNonRefundTradeTxn));
|
||||
|
||||
/* Checking message sender for possible cancel message */
|
||||
labelCheckCancelTxn = codeByteBuffer.position();
|
||||
|
||||
// Compare each part of message sender's address with AT creator's address. If they don't match, look for another transaction.
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
|
||||
// Partner address is AT creator's address, so cancel offer and finish.
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.CANCELLED.value));
|
||||
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||
|
||||
/* Not trade nor cancel message */
|
||||
labelNotTradeNorCancelTxn = codeByteBuffer.position();
|
||||
|
||||
// Loop to find another transaction
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop));
|
||||
|
||||
/* Possible switch-to-trade-mode message */
|
||||
labelCheckNonRefundTradeTxn = codeByteBuffer.position();
|
||||
|
||||
// Check 'trade' message we received has expected number of message bytes
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength));
|
||||
// If message length matches, branch to info extraction code
|
||||
codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract)));
|
||||
// Message length didn't match - go back to finding another 'trade' MESSAGE transaction
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop));
|
||||
|
||||
/* Extracting info from 'trade' MESSAGE transaction */
|
||||
labelTradeTxnExtract = codeByteBuffer.position();
|
||||
|
||||
// Extract message from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer));
|
||||
|
||||
// Extract trade partner's Dogecoin public key hash (PKH) from message into B
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerDogecoinPKHOffset));
|
||||
// Store partner's Dogecoin PKH (we only really use values from B1-B3)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerDogecoinPKHPointer));
|
||||
// Extract AT trade timeout (minutes) (from B4)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrRefundTimeout));
|
||||
|
||||
// Grab next 32 bytes
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset));
|
||||
|
||||
// Extract hash-of-secret-A (we only really use values from B1-B3)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer));
|
||||
// Extract lockTime-A (from B4)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA));
|
||||
|
||||
// Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this transaction's 'timestamp', then save into addrRefundTimestamp
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout));
|
||||
|
||||
/* We are in 'trade mode' */
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value));
|
||||
|
||||
// Set restart position to after this opcode
|
||||
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
||||
|
||||
/* Loop, waiting for trade timeout or 'redeem' MESSAGE from Qortal trade partner */
|
||||
|
||||
// Fetch current block 'timestamp'
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp));
|
||||
// If we're not past refund 'timestamp' then look for next transaction
|
||||
codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
// We're past refund 'timestamp' so go refund everything back to AT creator
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund));
|
||||
|
||||
/* Transaction processing loop */
|
||||
labelRedeemTxnLoop = codeByteBuffer.position();
|
||||
|
||||
// Find next transaction to this AT since the last one (if any)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp));
|
||||
// If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
|
||||
// If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
|
||||
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckRedeemTxn)));
|
||||
// Stop and wait for next block
|
||||
codeByteBuffer.put(OpCode.STP_IMD.compile());
|
||||
|
||||
/* Check transaction */
|
||||
labelCheckRedeemTxn = codeByteBuffer.position();
|
||||
|
||||
// Update our 'last found transaction's timestamp' using 'timestamp' from transaction
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp));
|
||||
// Extract transaction type (message/payment) from transaction and save type in addrTxnType
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType));
|
||||
// If transaction type is not MESSAGE type then go look for another transaction
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
|
||||
/* Check message payload length */
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength));
|
||||
// If message length matches, branch to sender checking code
|
||||
codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedRedeemMessageLength, calcOffset(codeByteBuffer, labelCheckRedeemTxnSender)));
|
||||
// Message length didn't match - go back to finding another 'redeem' MESSAGE transaction
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop));
|
||||
|
||||
/* Check transaction's sender */
|
||||
labelCheckRedeemTxnSender = codeByteBuffer.position();
|
||||
|
||||
// Extract sender address from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
|
||||
// Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction.
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalPartnerAddress1, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalPartnerAddress2, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalPartnerAddress3, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalPartnerAddress4, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
|
||||
/* Check 'secret-A' in transaction's message */
|
||||
|
||||
// Extract secret-A from first 32 bytes of message from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer));
|
||||
// Load B register with expected hash result (as pointed to by addrHashOfSecretAPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretAPointer));
|
||||
// Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength).
|
||||
// Save the equality result (1 if they match, 0 otherwise) into addrResult.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength));
|
||||
// If hashes don't match, addrResult will be zero so go find another transaction
|
||||
codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout)));
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop));
|
||||
|
||||
/* Success! Pay arranged amount to receiving address */
|
||||
labelPayout = codeByteBuffer.position();
|
||||
|
||||
// Extract Qortal receiving address from next 32 bytes of message from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset));
|
||||
// Save B register into data segment starting at addrPartnerReceivingAddress (as pointed to by addrPartnerReceivingAddressPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerReceivingAddressPointer));
|
||||
// Pay AT's balance to receiving address
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount));
|
||||
// Set redeemed mode
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REDEEMED.value));
|
||||
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||
|
||||
// Fall-through to refunding any remaining balance back to AT creator
|
||||
|
||||
/* Refund balance back to AT creator */
|
||||
labelRefund = codeByteBuffer.position();
|
||||
|
||||
// Set refunded mode
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REFUNDED.value));
|
||||
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||
} catch (CompilationException e) {
|
||||
throw new IllegalStateException("Unable to compile DOGE-QORT ACCT?", e);
|
||||
}
|
||||
}
|
||||
|
||||
codeByteBuffer.flip();
|
||||
|
||||
byte[] codeBytes = new byte[codeByteBuffer.limit()];
|
||||
codeByteBuffer.get(codeBytes);
|
||||
|
||||
assert Arrays.equals(Crypto.digest(codeBytes), DogecoinACCTv1.CODE_BYTES_HASH)
|
||||
: String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes)));
|
||||
|
||||
final short ciyamAtVersion = 2;
|
||||
final short numCallStackPages = 0;
|
||||
final short numUserStackPages = 0;
|
||||
final long minActivationAmount = 0L;
|
||||
|
||||
return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||
*/
|
||||
@Override
|
||||
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
||||
ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
|
||||
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||
*/
|
||||
@Override
|
||||
public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
|
||||
ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress());
|
||||
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||
*/
|
||||
public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException {
|
||||
byte[] addressBytes = new byte[25]; // for general use
|
||||
String atAddress = atStateData.getATAddress();
|
||||
|
||||
CrossChainTradeData tradeData = new CrossChainTradeData();
|
||||
|
||||
tradeData.foreignBlockchain = SupportedBlockchain.DOGECOIN.name();
|
||||
tradeData.acctName = NAME;
|
||||
|
||||
tradeData.qortalAtAddress = atAddress;
|
||||
tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey);
|
||||
tradeData.creationTimestamp = creationTimestamp;
|
||||
|
||||
Account atAccount = new Account(repository, atAddress);
|
||||
tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT);
|
||||
|
||||
byte[] stateData = atStateData.getStateData();
|
||||
ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData);
|
||||
dataByteBuffer.position(MachineState.HEADER_LENGTH);
|
||||
|
||||
/* Constants */
|
||||
|
||||
// Skip creator's trade address
|
||||
dataByteBuffer.get(addressBytes);
|
||||
tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
|
||||
|
||||
// Creator's Dogecoin/foreign public key hash
|
||||
tradeData.creatorForeignPKH = new byte[20];
|
||||
dataByteBuffer.get(tradeData.creatorForeignPKH);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes
|
||||
|
||||
// We don't use secret-B
|
||||
tradeData.hashOfSecretB = null;
|
||||
|
||||
// Redeem payout
|
||||
tradeData.qortAmount = dataByteBuffer.getLong();
|
||||
|
||||
// Expected DOGE amount
|
||||
tradeData.expectedForeignAmount = dataByteBuffer.getLong();
|
||||
|
||||
// Trade timeout
|
||||
tradeData.tradeTimeout = (int) dataByteBuffer.getLong();
|
||||
|
||||
// Skip MESSAGE transaction type
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip expected 'trade' message length
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip expected 'redeem' message length
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to creator's address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to partner's Qortal trade address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to message sender
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip 'trade' message data offset for partner's Dogecoin PKH
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to partner's Dogecoin PKH
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip 'trade' message data offset for hash-of-secret-A
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to hash-of-secret-A
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip 'redeem' message data offset for partner's Qortal receiving address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to message data
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip message data length
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to partner's receiving address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
/* End of constants / begin variables */
|
||||
|
||||
// Skip AT creator's address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
|
||||
|
||||
// Partner's trade address (if present)
|
||||
dataByteBuffer.get(addressBytes);
|
||||
String qortalRecipient = Base58.encode(addressBytes);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
|
||||
|
||||
// Potential lockTimeA (if in trade mode)
|
||||
int lockTimeA = (int) dataByteBuffer.getLong();
|
||||
|
||||
// AT refund timeout (probably only useful for debugging)
|
||||
int refundTimeout = (int) dataByteBuffer.getLong();
|
||||
|
||||
// Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height)
|
||||
long tradeRefundTimestamp = dataByteBuffer.getLong();
|
||||
|
||||
// Skip last transaction timestamp
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip block timestamp
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip transaction type
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip temporary result
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip temporary message sender
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
|
||||
|
||||
// Skip message length
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip temporary message data
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
|
||||
|
||||
// Potential hash160 of secret A
|
||||
byte[] hashOfSecretA = new byte[20];
|
||||
dataByteBuffer.get(hashOfSecretA);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes
|
||||
|
||||
// Potential partner's Dogecoin PKH
|
||||
byte[] partnerDogecoinPKH = new byte[20];
|
||||
dataByteBuffer.get(partnerDogecoinPKH);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerDogecoinPKH.length); // skip to 32 bytes
|
||||
|
||||
// Partner's receiving address (if present)
|
||||
byte[] partnerReceivingAddress = new byte[25];
|
||||
dataByteBuffer.get(partnerReceivingAddress);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerReceivingAddress.length); // skip to 32 bytes
|
||||
|
||||
// Trade AT's 'mode'
|
||||
long modeValue = dataByteBuffer.getLong();
|
||||
AcctMode mode = AcctMode.valueOf((int) (modeValue & 0xffL));
|
||||
|
||||
/* End of variables */
|
||||
|
||||
if (mode != null && mode != AcctMode.OFFERING) {
|
||||
tradeData.mode = mode;
|
||||
tradeData.refundTimeout = refundTimeout;
|
||||
tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
|
||||
tradeData.qortalPartnerAddress = qortalRecipient;
|
||||
tradeData.hashOfSecretA = hashOfSecretA;
|
||||
tradeData.partnerForeignPKH = partnerDogecoinPKH;
|
||||
tradeData.lockTimeA = lockTimeA;
|
||||
|
||||
if (mode == AcctMode.REDEEMED)
|
||||
tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress);
|
||||
} else {
|
||||
tradeData.mode = AcctMode.OFFERING;
|
||||
}
|
||||
|
||||
tradeData.duplicateDeprecated();
|
||||
|
||||
return tradeData;
|
||||
}
|
||||
|
||||
/** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */
|
||||
public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) {
|
||||
byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA);
|
||||
return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes);
|
||||
}
|
||||
|
||||
/** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */
|
||||
public static OfferMessageData extractOfferMessageData(byte[] messageData) {
|
||||
if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH)
|
||||
return null;
|
||||
|
||||
OfferMessageData offerMessageData = new OfferMessageData();
|
||||
offerMessageData.partnerDogecoinPKH = Arrays.copyOfRange(messageData, 0, 20);
|
||||
offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40);
|
||||
offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40);
|
||||
|
||||
return offerMessageData;
|
||||
}
|
||||
|
||||
/** Returns 'trade' MESSAGE payload for AT creator to send to AT. */
|
||||
public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) {
|
||||
byte[] data = new byte[TRADE_MESSAGE_LENGTH];
|
||||
byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress);
|
||||
byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA);
|
||||
byte[] refundTimeoutBytes = BitTwiddling.toBEByteArray((long) refundTimeout);
|
||||
|
||||
System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length);
|
||||
System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length);
|
||||
System.arraycopy(refundTimeoutBytes, 0, data, 56, refundTimeoutBytes.length);
|
||||
System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length);
|
||||
System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */
|
||||
@Override
|
||||
public byte[] buildCancelMessage(String creatorQortalAddress) {
|
||||
byte[] data = new byte[CANCEL_MESSAGE_LENGTH];
|
||||
byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress);
|
||||
|
||||
System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Returns 'redeem' MESSAGE payload for trade partner to send to AT. */
|
||||
public static byte[] buildRedeemMessage(byte[] secretA, String qortalReceivingAddress) {
|
||||
byte[] data = new byte[REDEEM_MESSAGE_LENGTH];
|
||||
byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress);
|
||||
|
||||
System.arraycopy(secretA, 0, data, 0, secretA.length);
|
||||
System.arraycopy(qortalReceivingAddressBytes, 0, data, 32, qortalReceivingAddressBytes.length);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Returns refund timeout (minutes) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */
|
||||
public static int calcRefundTimeout(long offerMessageTimestamp, int lockTimeA) {
|
||||
// refund should be triggered halfway between offerMessageTimestamp and lockTimeA
|
||||
return (int) ((lockTimeA - (offerMessageTimestamp / 1000L)) / 2L / 60L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
|
||||
String atAddress = crossChainTradeData.qortalAtAddress;
|
||||
String redeemerAddress = crossChainTradeData.qortalPartnerAddress;
|
||||
|
||||
// We don't have partner's public key so we check every message to AT
|
||||
List<MessageTransactionData> messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, atAddress, null, null, null);
|
||||
if (messageTransactionsData == null)
|
||||
return null;
|
||||
|
||||
// Find 'redeem' message
|
||||
for (MessageTransactionData messageTransactionData : messageTransactionsData) {
|
||||
// Check message payload type/encryption
|
||||
if (messageTransactionData.isText() || messageTransactionData.isEncrypted())
|
||||
continue;
|
||||
|
||||
// Check message payload size
|
||||
byte[] messageData = messageTransactionData.getData();
|
||||
if (messageData.length != REDEEM_MESSAGE_LENGTH)
|
||||
// Wrong payload length
|
||||
continue;
|
||||
|
||||
// Check sender
|
||||
if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress))
|
||||
// Wrong sender;
|
||||
continue;
|
||||
|
||||
// Extract secretA
|
||||
byte[] secretA = new byte[32];
|
||||
System.arraycopy(messageData, 0, secretA, 0, secretA.length);
|
||||
|
||||
byte[] hashOfSecretA = Crypto.hash160(secretA);
|
||||
if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA))
|
||||
continue;
|
||||
|
||||
return secretA;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import org.qortal.crypto.TrustlessSSLSocketFactory;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
import com.google.common.primitives.Bytes;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
/** ElectrumX network support for querying Bitcoiny-related info like block headers, transaction outputs, etc. */
|
||||
public class ElectrumX extends BitcoinyBlockchainProvider {
|
||||
@@ -171,13 +172,41 @@ public class ElectrumX extends BitcoinyBlockchainProvider {
|
||||
Long returnedCount = (Long) countObj;
|
||||
String hex = (String) hexObj;
|
||||
|
||||
byte[] raw = HashCode.fromString(hex).asBytes();
|
||||
if (raw.length != returnedCount * BLOCK_HEADER_LENGTH)
|
||||
throw new ForeignBlockchainException.NetworkException("Unexpected raw header length in JSON from ElectrumX blockchain.block.headers RPC");
|
||||
|
||||
List<byte[]> rawBlockHeaders = new ArrayList<>(returnedCount.intValue());
|
||||
for (int i = 0; i < returnedCount; ++i)
|
||||
rawBlockHeaders.add(Arrays.copyOfRange(raw, i * BLOCK_HEADER_LENGTH, (i + 1) * BLOCK_HEADER_LENGTH));
|
||||
|
||||
byte[] raw = HashCode.fromString(hex).asBytes();
|
||||
|
||||
// Most chains use a fixed length 80 byte header, so block headers can be split up by dividing the hex into
|
||||
// 80-byte segments. However, some chains such as DOGE use variable length headers due to AuxPoW or other
|
||||
// reasons. In these cases we can identify the start of each block header by the location of the block version
|
||||
// numbers. Each block starts with a version number, and for DOGE this is easily identifiable (6422788) at the
|
||||
// time of writing (Jul 2021). If we encounter a chain that is using more generic version numbers (e.g. 1)
|
||||
// and can't be used to accurately identify block indexes, then there are sufficient checks to ensure an
|
||||
// exception is thrown.
|
||||
|
||||
if (raw.length == returnedCount * BLOCK_HEADER_LENGTH) {
|
||||
// Fixed-length header (BTC, LTC, etc)
|
||||
for (int i = 0; i < returnedCount; ++i) {
|
||||
rawBlockHeaders.add(Arrays.copyOfRange(raw, i * BLOCK_HEADER_LENGTH, (i + 1) * BLOCK_HEADER_LENGTH));
|
||||
}
|
||||
}
|
||||
else if (raw.length > returnedCount * BLOCK_HEADER_LENGTH) {
|
||||
// Assume AuxPoW variable length header (DOGE)
|
||||
int referenceVersion = BitTwiddling.intFromLEBytes(raw, 0); // DOGE uses 6422788 at time of commit (Jul 2021)
|
||||
for (int i = 0; i < raw.length - 4; ++i) {
|
||||
// Locate the start of each block by its version number
|
||||
if (BitTwiddling.intFromLEBytes(raw, i) == referenceVersion) {
|
||||
rawBlockHeaders.add(Arrays.copyOfRange(raw, i, i + BLOCK_HEADER_LENGTH));
|
||||
}
|
||||
}
|
||||
// Ensure that we found the correct number of block headers
|
||||
if (rawBlockHeaders.size() != count) {
|
||||
throw new ForeignBlockchainException.NetworkException("Unexpected raw header contents in JSON from ElectrumX blockchain.block.headers RPC.");
|
||||
}
|
||||
}
|
||||
else if (raw.length != returnedCount * BLOCK_HEADER_LENGTH) {
|
||||
throw new ForeignBlockchainException.NetworkException("Unexpected raw header length in JSON from ElectrumX blockchain.block.headers RPC");
|
||||
}
|
||||
|
||||
return rawBlockHeaders;
|
||||
}
|
||||
@@ -518,6 +547,7 @@ public class ElectrumX extends BitcoinyBlockchainProvider {
|
||||
}
|
||||
|
||||
// Failed to perform RPC - maybe lack of servers?
|
||||
LOGGER.info("Error: No connected Electrum servers when trying to make RPC call");
|
||||
throw new ForeignBlockchainException.NetworkException(String.format("Failed to perform ElectrumX RPC %s", method));
|
||||
}
|
||||
}
|
||||
@@ -623,18 +653,27 @@ public class ElectrumX extends BitcoinyBlockchainProvider {
|
||||
|
||||
Object errorObj = responseJson.get("error");
|
||||
if (errorObj != null) {
|
||||
if (errorObj instanceof String)
|
||||
throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error message from ElectrumX RPC %s: %s", method, (String) errorObj), this.currentServer);
|
||||
if (errorObj instanceof String) {
|
||||
LOGGER.debug(String.format("Unexpected error message from ElectrumX server %s for RPC method %s: %s", this.currentServer, method, (String) errorObj));
|
||||
// Try another server
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(errorObj instanceof JSONObject))
|
||||
throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error response from ElectrumX RPC %s", method), this.currentServer);
|
||||
if (!(errorObj instanceof JSONObject)) {
|
||||
LOGGER.debug(String.format("Unexpected error response from ElectrumX server %s for RPC method %s", this.currentServer, method));
|
||||
// Try another server
|
||||
return null;
|
||||
}
|
||||
|
||||
JSONObject errorJson = (JSONObject) errorObj;
|
||||
|
||||
Object messageObj = errorJson.get("message");
|
||||
|
||||
if (!(messageObj instanceof String))
|
||||
throw new ForeignBlockchainException.NetworkException(String.format("Missing/invalid message in error response from ElectrumX RPC %s", method), this.currentServer);
|
||||
if (!(messageObj instanceof String)) {
|
||||
LOGGER.debug(String.format("Missing/invalid message in error response from ElectrumX server %s for RPC method %s", this.currentServer, method));
|
||||
// Try another server
|
||||
return null;
|
||||
}
|
||||
|
||||
String message = (String) messageObj;
|
||||
|
||||
|
||||
@@ -6,4 +6,6 @@ public interface ForeignBlockchain {
|
||||
|
||||
public boolean isValidWalletKey(String walletKey);
|
||||
|
||||
public long getMinimumOrderAmount();
|
||||
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public class Litecoin extends Bitcoiny {
|
||||
|
||||
private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(10000); // 0.0001 LTC per 1000 bytes
|
||||
|
||||
private static final long MINIMUM_ORDER_AMOUNT = 1000000; // 0.01 LTC minimum order, to avoid dust errors
|
||||
|
||||
// Temporary values until a dynamic fee system is written.
|
||||
private static final long MAINNET_FEE = 1000L;
|
||||
private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST
|
||||
@@ -51,7 +53,10 @@ public class Litecoin extends Bitcoiny {
|
||||
new Server("ltc.rentonisk.com", Server.ConnectionType.TCP, 50001),
|
||||
new Server("ltc.rentonisk.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002),
|
||||
new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022));
|
||||
new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022),
|
||||
new Server("electrum-ltc-bysh.me", Server.ConnectionType.TCP, 50002),
|
||||
new Server("electrum.jochen-hoenicke.de", Server.ConnectionType.TCP, 50005),
|
||||
new Server("node.ispol.sk", Server.ConnectionType.TCP, 50004));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -161,6 +166,11 @@ public class Litecoin extends Bitcoiny {
|
||||
return DEFAULT_FEE_PER_KB;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getMinimumOrderAmount() {
|
||||
return MINIMUM_ORDER_AMOUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns estimated LTC fee, in sats per 1000bytes, optionally for historic timestamp.
|
||||
*
|
||||
|
||||
@@ -810,7 +810,8 @@ public class LitecoinACCTv1 implements ACCT {
|
||||
return (int) ((lockTimeA - (offerMessageTimestamp / 1000L)) / 2L / 60L);
|
||||
}
|
||||
|
||||
public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
|
||||
@Override
|
||||
public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
|
||||
String atAddress = crossChainTradeData.qortalAtAddress;
|
||||
String redeemerAddress = crossChainTradeData.qortalPartnerAddress;
|
||||
|
||||
|
||||
109
src/main/java/org/qortal/crosschain/SimpleTransaction.java
Normal file
109
src/main/java/org/qortal/crosschain/SimpleTransaction.java
Normal file
@@ -0,0 +1,109 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import java.util.List;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class SimpleTransaction {
|
||||
private String txHash;
|
||||
private Integer timestamp;
|
||||
private long totalAmount;
|
||||
private long feeAmount;
|
||||
private List<Input> inputs;
|
||||
private List<Output> outputs;
|
||||
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public static class Input {
|
||||
private String address;
|
||||
private long amount;
|
||||
private boolean addressInWallet;
|
||||
|
||||
public Input() {
|
||||
}
|
||||
|
||||
public Input(String address, long amount, boolean addressInWallet) {
|
||||
this.address = address;
|
||||
this.amount = amount;
|
||||
this.addressInWallet = addressInWallet;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public long getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public boolean getAddressInWallet() {
|
||||
return addressInWallet;
|
||||
}
|
||||
}
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public static class Output {
|
||||
private String address;
|
||||
private long amount;
|
||||
private boolean addressInWallet;
|
||||
|
||||
public Output() {
|
||||
}
|
||||
|
||||
public Output(String address, long amount, boolean addressInWallet) {
|
||||
this.address = address;
|
||||
this.amount = amount;
|
||||
this.addressInWallet = addressInWallet;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public long getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public boolean getAddressInWallet() {
|
||||
return addressInWallet;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public SimpleTransaction() {
|
||||
}
|
||||
|
||||
public SimpleTransaction(String txHash, Integer timestamp, long totalAmount, long feeAmount, List<Input> inputs, List<Output> outputs) {
|
||||
this.txHash = txHash;
|
||||
this.timestamp = timestamp;
|
||||
this.totalAmount = totalAmount;
|
||||
this.feeAmount = feeAmount;
|
||||
this.inputs = inputs;
|
||||
this.outputs = outputs;
|
||||
}
|
||||
|
||||
public String getTxHash() {
|
||||
return txHash;
|
||||
}
|
||||
|
||||
public Integer getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getTotalAmount() {
|
||||
return totalAmount;
|
||||
}
|
||||
|
||||
public long getFeeAmount() {
|
||||
return feeAmount;
|
||||
}
|
||||
|
||||
public List<Input> getInputs() {
|
||||
return this.inputs;
|
||||
}
|
||||
|
||||
public List<Output> getOutputs() {
|
||||
return this.outputs;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,20 @@ public enum SupportedBlockchain {
|
||||
public ACCT getLatestAcct() {
|
||||
return LitecoinACCTv1.getInstance();
|
||||
}
|
||||
},
|
||||
|
||||
DOGECOIN(Arrays.asList(
|
||||
Triple.valueOf(DogecoinACCTv1.NAME, DogecoinACCTv1.CODE_BYTES_HASH, DogecoinACCTv1::getInstance)
|
||||
)) {
|
||||
@Override
|
||||
public ForeignBlockchain getInstance() {
|
||||
return Dogecoin.getInstance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ACCT getLatestAcct() {
|
||||
return DogecoinACCTv1.getInstance();
|
||||
}
|
||||
};
|
||||
|
||||
private static final Map<ByteArray, Supplier<ACCT>> supportedAcctsByCodeHash = Arrays.stream(SupportedBlockchain.values())
|
||||
@@ -110,4 +124,4 @@ public enum SupportedBlockchain {
|
||||
return acctInstanceSupplier.get();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlTransient;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.qortal.crypto.Crypto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema.AccessMode;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
// All properties to be converted to JSON via JAXB
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
@@ -61,4 +63,21 @@ public class MintingAccountData {
|
||||
return this.publicKey;
|
||||
}
|
||||
|
||||
|
||||
// JSON
|
||||
|
||||
public JSONObject toJson() {
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
jsonObject.put("privateKey", Base58.encode(this.getPrivateKey()));
|
||||
jsonObject.put("publicKey", Base58.encode(this.getPublicKey()));
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
public static MintingAccountData fromJson(JSONObject json) {
|
||||
return new MintingAccountData(
|
||||
json.isNull("privateKey") ? null : Base58.decode(json.getString("privateKey")),
|
||||
json.isNull("publicKey") ? null : Base58.decode(json.getString("publicKey"))
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public class ATData {
|
||||
private boolean isFrozen;
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
private Long frozenBalance;
|
||||
private Long sleepUntilMessageTimestamp;
|
||||
|
||||
// Constructors
|
||||
|
||||
@@ -31,7 +32,8 @@ public class ATData {
|
||||
}
|
||||
|
||||
public ATData(String ATAddress, byte[] creatorPublicKey, long creation, int version, long assetId, byte[] codeBytes, byte[] codeHash,
|
||||
boolean isSleeping, Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance) {
|
||||
boolean isSleeping, Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance,
|
||||
Long sleepUntilMessageTimestamp) {
|
||||
this.ATAddress = ATAddress;
|
||||
this.creatorPublicKey = creatorPublicKey;
|
||||
this.creation = creation;
|
||||
@@ -45,6 +47,7 @@ public class ATData {
|
||||
this.hadFatalError = hadFatalError;
|
||||
this.isFrozen = isFrozen;
|
||||
this.frozenBalance = frozenBalance;
|
||||
this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp;
|
||||
}
|
||||
|
||||
/** For constructing skeleton ATData with bare minimum info. */
|
||||
@@ -133,4 +136,12 @@ public class ATData {
|
||||
this.frozenBalance = frozenBalance;
|
||||
}
|
||||
|
||||
public Long getSleepUntilMessageTimestamp() {
|
||||
return this.sleepUntilMessageTimestamp;
|
||||
}
|
||||
|
||||
public void setSleepUntilMessageTimestamp(Long sleepUntilMessageTimestamp) {
|
||||
this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,35 +10,32 @@ public class ATStateData {
|
||||
private Long fees;
|
||||
private boolean isInitial;
|
||||
|
||||
// Qortal-AT-specific
|
||||
private Long sleepUntilMessageTimestamp;
|
||||
|
||||
// Constructors
|
||||
|
||||
/** Create new ATStateData */
|
||||
public ATStateData(String ATAddress, Integer height, byte[] stateData, byte[] stateHash, Long fees, boolean isInitial) {
|
||||
public ATStateData(String ATAddress, Integer height, byte[] stateData, byte[] stateHash, Long fees,
|
||||
boolean isInitial, Long sleepUntilMessageTimestamp) {
|
||||
this.ATAddress = ATAddress;
|
||||
this.height = height;
|
||||
this.stateData = stateData;
|
||||
this.stateHash = stateHash;
|
||||
this.fees = fees;
|
||||
this.isInitial = isInitial;
|
||||
this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp;
|
||||
}
|
||||
|
||||
/** For recreating per-block ATStateData from repository where not all info is needed */
|
||||
public ATStateData(String ATAddress, int height, byte[] stateHash, Long fees, boolean isInitial) {
|
||||
this(ATAddress, height, null, stateHash, fees, isInitial);
|
||||
}
|
||||
|
||||
/** For creating ATStateData from serialized bytes when we don't have all the info */
|
||||
public ATStateData(String ATAddress, byte[] stateHash) {
|
||||
// This won't ever be initial AT state from deployment as that's never serialized over the network,
|
||||
// but generated when the DeployAtTransaction is processed locally.
|
||||
this(ATAddress, null, null, stateHash, null, false);
|
||||
this(ATAddress, height, null, stateHash, fees, isInitial, null);
|
||||
}
|
||||
|
||||
/** For creating ATStateData from serialized bytes when we don't have all the info */
|
||||
public ATStateData(String ATAddress, byte[] stateHash, Long fees) {
|
||||
// This won't ever be initial AT state from deployment as that's never serialized over the network,
|
||||
// but generated when the DeployAtTransaction is processed locally.
|
||||
this(ATAddress, null, null, stateHash, fees, false);
|
||||
// This won't ever be initial AT state from deployment, as that's never serialized over the network.
|
||||
this(ATAddress, null, null, stateHash, fees, false, null);
|
||||
}
|
||||
|
||||
// Getters / setters
|
||||
@@ -72,4 +69,12 @@ public class ATStateData {
|
||||
return this.isInitial;
|
||||
}
|
||||
|
||||
public Long getSleepUntilMessageTimestamp() {
|
||||
return this.sleepUntilMessageTimestamp;
|
||||
}
|
||||
|
||||
public void setSleepUntilMessageTimestamp(Long sleepUntilMessageTimestamp) {
|
||||
this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
47
src/main/java/org/qortal/data/block/BlockArchiveData.java
Normal file
47
src/main/java/org/qortal/data/block/BlockArchiveData.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package org.qortal.data.block;
|
||||
|
||||
import org.qortal.block.Block;
|
||||
|
||||
public class BlockArchiveData {
|
||||
|
||||
// Properties
|
||||
private byte[] signature;
|
||||
private Integer height;
|
||||
private Long timestamp;
|
||||
private byte[] minterPublicKey;
|
||||
|
||||
// Constructors
|
||||
|
||||
public BlockArchiveData(byte[] signature, Integer height, long timestamp, byte[] minterPublicKey) {
|
||||
this.signature = signature;
|
||||
this.height = height;
|
||||
this.timestamp = timestamp;
|
||||
this.minterPublicKey = minterPublicKey;
|
||||
}
|
||||
|
||||
public BlockArchiveData(BlockData blockData) {
|
||||
this.signature = blockData.getSignature();
|
||||
this.height = blockData.getHeight();
|
||||
this.timestamp = blockData.getTimestamp();
|
||||
this.minterPublicKey = blockData.getMinterPublicKey();
|
||||
}
|
||||
|
||||
// Getters/setters
|
||||
|
||||
public byte[] getSignature() {
|
||||
return this.signature;
|
||||
}
|
||||
|
||||
public Integer getHeight() {
|
||||
return this.height;
|
||||
}
|
||||
|
||||
public Long getTimestamp() {
|
||||
return this.timestamp;
|
||||
}
|
||||
|
||||
public byte[] getMinterPublicKey() {
|
||||
return this.minterPublicKey;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.qortal.data.block;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import java.util.Arrays;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class BlockSummaryData {
|
||||
@@ -84,4 +85,21 @@ public class BlockSummaryData {
|
||||
this.minterLevel = minterLevel;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
|
||||
BlockSummaryData otherBlockSummary = (BlockSummaryData) o;
|
||||
if (this.getSignature() == null || otherBlockSummary.getSignature() == null)
|
||||
return false;
|
||||
|
||||
// Treat two block summaries as equal if they have matching signatures
|
||||
return Arrays.equals(this.getSignature(), otherBlockSummary.getSignature());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
56
src/main/java/org/qortal/data/block/CommonBlockData.java
Normal file
56
src/main/java/org/qortal/data/block/CommonBlockData.java
Normal file
@@ -0,0 +1,56 @@
|
||||
package org.qortal.data.block;
|
||||
|
||||
import org.qortal.data.network.PeerChainTipData;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CommonBlockData {
|
||||
|
||||
// Properties
|
||||
private BlockSummaryData commonBlockSummary = null;
|
||||
private List<BlockSummaryData> blockSummariesAfterCommonBlock = null;
|
||||
private BigInteger chainWeight = null;
|
||||
private PeerChainTipData chainTipData = null;
|
||||
|
||||
// Constructors
|
||||
|
||||
protected CommonBlockData() {
|
||||
}
|
||||
|
||||
public CommonBlockData(BlockSummaryData commonBlockSummary, PeerChainTipData chainTipData) {
|
||||
this.commonBlockSummary = commonBlockSummary;
|
||||
this.chainTipData = chainTipData;
|
||||
}
|
||||
|
||||
|
||||
// Getters / setters
|
||||
|
||||
public BlockSummaryData getCommonBlockSummary() {
|
||||
return this.commonBlockSummary;
|
||||
}
|
||||
|
||||
public List<BlockSummaryData> getBlockSummariesAfterCommonBlock() {
|
||||
return this.blockSummariesAfterCommonBlock;
|
||||
}
|
||||
|
||||
public void setBlockSummariesAfterCommonBlock(List<BlockSummaryData> blockSummariesAfterCommonBlock) {
|
||||
this.blockSummariesAfterCommonBlock = blockSummariesAfterCommonBlock;
|
||||
}
|
||||
|
||||
public BigInteger getChainWeight() {
|
||||
return this.chainWeight;
|
||||
}
|
||||
|
||||
public void setChainWeight(BigInteger chainWeight) {
|
||||
this.chainWeight = chainWeight;
|
||||
}
|
||||
|
||||
public PeerChainTipData getChainTipData() {
|
||||
return this.chainTipData;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import javax.xml.bind.annotation.XmlTransient;
|
||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
// All properties to be converted to JSON via JAXB
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
@@ -205,6 +208,58 @@ public class TradeBotData {
|
||||
return this.receivingAccountInfo;
|
||||
}
|
||||
|
||||
public JSONObject toJson() {
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
jsonObject.put("tradePrivateKey", Base58.encode(this.getTradePrivateKey()));
|
||||
jsonObject.put("acctName", this.getAcctName());
|
||||
jsonObject.put("tradeState", this.getState());
|
||||
jsonObject.put("tradeStateValue", this.getStateValue());
|
||||
jsonObject.put("creatorAddress", this.getCreatorAddress());
|
||||
jsonObject.put("atAddress", this.getAtAddress());
|
||||
jsonObject.put("timestamp", this.getTimestamp());
|
||||
jsonObject.put("qortAmount", this.getQortAmount());
|
||||
if (this.getTradeNativePublicKey() != null) jsonObject.put("tradeNativePublicKey", Base58.encode(this.getTradeNativePublicKey()));
|
||||
if (this.getTradeNativePublicKeyHash() != null) jsonObject.put("tradeNativePublicKeyHash", Base58.encode(this.getTradeNativePublicKeyHash()));
|
||||
jsonObject.put("tradeNativeAddress", this.getTradeNativeAddress());
|
||||
if (this.getSecret() != null) jsonObject.put("secret", Base58.encode(this.getSecret()));
|
||||
if (this.getHashOfSecret() != null) jsonObject.put("hashOfSecret", Base58.encode(this.getHashOfSecret()));
|
||||
jsonObject.put("foreignBlockchain", this.getForeignBlockchain());
|
||||
if (this.getTradeForeignPublicKey() != null) jsonObject.put("tradeForeignPublicKey", Base58.encode(this.getTradeForeignPublicKey()));
|
||||
if (this.getTradeForeignPublicKeyHash() != null) jsonObject.put("tradeForeignPublicKeyHash", Base58.encode(this.getTradeForeignPublicKeyHash()));
|
||||
jsonObject.put("foreignKey", this.getForeignKey());
|
||||
jsonObject.put("foreignAmount", this.getForeignAmount());
|
||||
if (this.getLastTransactionSignature() != null) jsonObject.put("lastTransactionSignature", Base58.encode(this.getLastTransactionSignature()));
|
||||
jsonObject.put("lockTimeA", this.getLockTimeA());
|
||||
if (this.getReceivingAccountInfo() != null) jsonObject.put("receivingAccountInfo", Base58.encode(this.getReceivingAccountInfo()));
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
public static TradeBotData fromJson(JSONObject json) {
|
||||
return new TradeBotData(
|
||||
json.isNull("tradePrivateKey") ? null : Base58.decode(json.getString("tradePrivateKey")),
|
||||
json.isNull("acctName") ? null : json.getString("acctName"),
|
||||
json.isNull("tradeState") ? null : json.getString("tradeState"),
|
||||
json.isNull("tradeStateValue") ? null : json.getInt("tradeStateValue"),
|
||||
json.isNull("creatorAddress") ? null : json.getString("creatorAddress"),
|
||||
json.isNull("atAddress") ? null : json.getString("atAddress"),
|
||||
json.isNull("timestamp") ? null : json.getLong("timestamp"),
|
||||
json.isNull("qortAmount") ? null : json.getLong("qortAmount"),
|
||||
json.isNull("tradeNativePublicKey") ? null : Base58.decode(json.getString("tradeNativePublicKey")),
|
||||
json.isNull("tradeNativePublicKeyHash") ? null : Base58.decode(json.getString("tradeNativePublicKeyHash")),
|
||||
json.isNull("tradeNativeAddress") ? null : json.getString("tradeNativeAddress"),
|
||||
json.isNull("secret") ? null : Base58.decode(json.getString("secret")),
|
||||
json.isNull("hashOfSecret") ? null : Base58.decode(json.getString("hashOfSecret")),
|
||||
json.isNull("foreignBlockchain") ? null : json.getString("foreignBlockchain"),
|
||||
json.isNull("tradeForeignPublicKey") ? null : Base58.decode(json.getString("tradeForeignPublicKey")),
|
||||
json.isNull("tradeForeignPublicKeyHash") ? null : Base58.decode(json.getString("tradeForeignPublicKeyHash")),
|
||||
json.isNull("foreignAmount") ? null : json.getLong("foreignAmount"),
|
||||
json.isNull("foreignKey") ? null : json.getString("foreignKey"),
|
||||
json.isNull("lastTransactionSignature") ? null : Base58.decode(json.getString("lastTransactionSignature")),
|
||||
json.isNull("lockTimeA") ? null : json.getInt("lockTimeA"),
|
||||
json.isNull("receivingAccountInfo") ? null : Base58.decode(json.getString("receivingAccountInfo"))
|
||||
);
|
||||
}
|
||||
|
||||
// Mostly for debugging
|
||||
public String toString() {
|
||||
return String.format("%s: %s (%d)", this.atAddress, this.tradeState, this.tradeStateValue);
|
||||
|
||||
@@ -26,7 +26,7 @@ public class RegisterNameTransactionData extends TransactionData {
|
||||
@Schema(description = "requested name", example = "my-name")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "simple name-related info in JSON format", example = "{ \"age\": 30 }")
|
||||
@Schema(description = "simple name-related info in JSON or text format", example = "Registered Name on the Qortal Chain")
|
||||
private String data;
|
||||
|
||||
// For internal use
|
||||
|
||||
@@ -26,7 +26,7 @@ public class UpdateNameTransactionData extends TransactionData {
|
||||
@Schema(description = "new name", example = "my-new-name")
|
||||
private String newName;
|
||||
|
||||
@Schema(description = "replacement simple name-related info in JSON format", example = "{ \"age\": 30 }")
|
||||
@Schema(description = "replacement simple name-related info in JSON or text format", example = "Registered Name on the Qortal Chain")
|
||||
private String newData;
|
||||
|
||||
// For internal use
|
||||
|
||||
@@ -23,17 +23,21 @@ public class Gui {
|
||||
private SysTray sysTray = null;
|
||||
|
||||
private Gui() {
|
||||
this.isHeadless = GraphicsEnvironment.isHeadless();
|
||||
try {
|
||||
this.isHeadless = GraphicsEnvironment.isHeadless();
|
||||
|
||||
if (!this.isHeadless) {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException
|
||||
| UnsupportedLookAndFeelException e) {
|
||||
// Use whatever look-and-feel comes by default then
|
||||
if (!this.isHeadless) {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException
|
||||
| UnsupportedLookAndFeelException e) {
|
||||
// Use whatever look-and-feel comes by default then
|
||||
}
|
||||
|
||||
showSplash();
|
||||
}
|
||||
|
||||
showSplash();
|
||||
} catch (Exception e) {
|
||||
LOGGER.info("Unable to initialize GUI: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +1,93 @@
|
||||
package org.qortal.gui;
|
||||
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Image;
|
||||
import java.awt.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.*;
|
||||
import javax.swing.border.EmptyBorder;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.controller.Controller;
|
||||
|
||||
public class SplashFrame {
|
||||
|
||||
protected static final Logger LOGGER = LogManager.getLogger(SplashFrame.class);
|
||||
|
||||
private static SplashFrame instance;
|
||||
private JDialog splashDialog;
|
||||
private JFrame splashDialog;
|
||||
private SplashPanel splashPanel;
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
public static class SplashPanel extends JPanel {
|
||||
private BufferedImage image;
|
||||
|
||||
private String defaultSplash = "Qlogo_512.png";
|
||||
|
||||
private JLabel statusLabel;
|
||||
|
||||
public SplashPanel() {
|
||||
image = Gui.loadImage("splash.png");
|
||||
this.setPreferredSize(new Dimension(image.getWidth(), image.getHeight()));
|
||||
this.setLayout(new BorderLayout());
|
||||
image = Gui.loadImage(defaultSplash);
|
||||
|
||||
setOpaque(true);
|
||||
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
|
||||
setBorder(new EmptyBorder(10, 10, 10, 10));
|
||||
setBackground(new Color(255, 255, 255));
|
||||
|
||||
// Add logo
|
||||
JLabel imageLabel = new JLabel(new ImageIcon(image));
|
||||
imageLabel.setSize(new Dimension(300, 300));
|
||||
add(imageLabel);
|
||||
|
||||
// Add spacing
|
||||
add(Box.createRigidArea(new Dimension(0, 16)));
|
||||
|
||||
// Add status label
|
||||
String text = String.format("Starting Qortal Core v%s...", Controller.getInstance().getVersionStringWithoutPrefix());
|
||||
statusLabel = new JLabel(text, JLabel.CENTER);
|
||||
statusLabel.setMaximumSize(new Dimension(500, 50));
|
||||
statusLabel.setFont(new Font("Verdana", Font.PLAIN, 20));
|
||||
statusLabel.setBackground(new Color(255, 255, 255));
|
||||
statusLabel.setOpaque(true);
|
||||
statusLabel.setBorder(null);
|
||||
add(statusLabel);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g) {
|
||||
super.paintComponent(g);
|
||||
g.drawImage(image, 0, 0, null);
|
||||
public Dimension getPreferredSize() {
|
||||
return new Dimension(500, 580);
|
||||
}
|
||||
|
||||
public void updateStatus(String text) {
|
||||
if (statusLabel != null) {
|
||||
statusLabel.setText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SplashFrame() {
|
||||
this.splashDialog = new JDialog();
|
||||
this.splashDialog = new JFrame();
|
||||
|
||||
List<Image> icons = new ArrayList<>();
|
||||
icons.add(Gui.loadImage("icons/icon16.png"));
|
||||
icons.add(Gui.loadImage("icons/icon32.png"));
|
||||
icons.add(Gui.loadImage("icons/qortal_ui_tray_synced.png"));
|
||||
icons.add(Gui.loadImage("icons/qortal_ui_tray_syncing_time-alt.png"));
|
||||
icons.add(Gui.loadImage("icons/qortal_ui_tray_minting.png"));
|
||||
icons.add(Gui.loadImage("icons/qortal_ui_tray_syncing.png"));
|
||||
icons.add(Gui.loadImage("icons/icon64.png"));
|
||||
icons.add(Gui.loadImage("icons/icon128.png"));
|
||||
icons.add(Gui.loadImage("icons/Qlogo_128.png"));
|
||||
this.splashDialog.setIconImages(icons);
|
||||
|
||||
this.splashDialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
|
||||
this.splashDialog.setTitle("qortal");
|
||||
this.splashDialog.setContentPane(new SplashPanel());
|
||||
|
||||
this.splashPanel = new SplashPanel();
|
||||
this.splashDialog.getContentPane().add(this.splashPanel);
|
||||
this.splashDialog.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
|
||||
this.splashDialog.setUndecorated(true);
|
||||
this.splashDialog.setModal(false);
|
||||
this.splashDialog.pack();
|
||||
this.splashDialog.setLocationRelativeTo(null);
|
||||
this.splashDialog.toFront();
|
||||
this.splashDialog.setBackground(new Color(0,0,0,0));
|
||||
this.splashDialog.setVisible(true);
|
||||
this.splashDialog.repaint();
|
||||
}
|
||||
|
||||
public static SplashFrame getInstance() {
|
||||
@@ -76,4 +105,8 @@ public class SplashFrame {
|
||||
this.splashDialog.dispose();
|
||||
}
|
||||
|
||||
public void updateStatus(String text) {
|
||||
this.splashPanel.updateStatus(text);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ public class SysTray {
|
||||
this.popupMenu = createJPopupMenu();
|
||||
|
||||
// Build TrayIcon without AWT PopupMenu (which doesn't support Unicode)...
|
||||
this.trayIcon = new TrayIcon(Gui.loadImage("icons/icon32.png"), "qortal", null);
|
||||
this.trayIcon = new TrayIcon(Gui.loadImage("icons/qortal_ui_tray_synced.png"), "qortal", null);
|
||||
// ...and attach mouse listener instead so we can use JPopupMenu (which does support Unicode)
|
||||
this.trayIcon.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
@@ -289,6 +289,29 @@ public class SysTray {
|
||||
this.trayIcon.setToolTip(text);
|
||||
}
|
||||
|
||||
public void setTrayIcon(int iconid) {
|
||||
if (trayIcon != null) {
|
||||
try {
|
||||
switch (iconid) {
|
||||
case 1:
|
||||
this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_syncing_time-alt.png"));
|
||||
break;
|
||||
case 2:
|
||||
this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_minting.png"));
|
||||
break;
|
||||
case 3:
|
||||
this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_syncing.png"));
|
||||
break;
|
||||
case 4:
|
||||
this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_synced.png"));
|
||||
break;
|
||||
}
|
||||
} catch (NullPointerException e) {
|
||||
LOGGER.info("Unable to set tray icon");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
if (trayIcon != null)
|
||||
SystemTray.getSystemTray().remove(this.trayIcon);
|
||||
|
||||
157
src/main/java/org/qortal/list/ResourceList.java
Normal file
157
src/main/java/org/qortal/list/ResourceList.java
Normal file
@@ -0,0 +1,157 @@
|
||||
package org.qortal.list;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.json.JSONArray;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ResourceList {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(ResourceList.class);
|
||||
|
||||
private String category;
|
||||
private String resourceName;
|
||||
private List<String> list;
|
||||
|
||||
/**
|
||||
* ResourceList
|
||||
* Creates or updates a list for the purpose of tracking resources on the Qortal network
|
||||
* This can be used for local blocking, or even for curating and sharing content lists
|
||||
* Lists are backed off to JSON files (in the lists folder) to ease sharing between nodes and users
|
||||
*
|
||||
* @param category - for instance "blacklist", "whitelist", or "userlist"
|
||||
* @param resourceName - for instance "address", "poll", or "group"
|
||||
* @throws IOException
|
||||
*/
|
||||
public ResourceList(String category, String resourceName) throws IOException {
|
||||
this.category = category;
|
||||
this.resourceName = resourceName;
|
||||
this.list = new ArrayList<>();
|
||||
this.load();
|
||||
}
|
||||
|
||||
|
||||
/* Filesystem */
|
||||
|
||||
private Path getFilePath() {
|
||||
String pathString = String.format("%s%s%s_%s.json", Settings.getInstance().getListsPath(),
|
||||
File.separator, this.resourceName, this.category);
|
||||
return Paths.get(pathString);
|
||||
}
|
||||
|
||||
public void save() throws IOException {
|
||||
if (this.resourceName == null) {
|
||||
throw new IllegalStateException("Can't save list with missing resource name");
|
||||
}
|
||||
if (this.category == null) {
|
||||
throw new IllegalStateException("Can't save list with missing category");
|
||||
}
|
||||
String jsonString = ResourceList.listToJSONString(this.list);
|
||||
Path filePath = this.getFilePath();
|
||||
|
||||
// Create parent directory if needed
|
||||
try {
|
||||
Files.createDirectories(filePath.getParent());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Unable to create lists directory");
|
||||
}
|
||||
|
||||
BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toString()));
|
||||
writer.write(jsonString);
|
||||
writer.close();
|
||||
}
|
||||
|
||||
private boolean load() throws IOException {
|
||||
Path path = this.getFilePath();
|
||||
File resourceListFile = new File(path.toString());
|
||||
if (!resourceListFile.exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String jsonString = new String(Files.readAllBytes(path));
|
||||
this.list = ResourceList.listFromJSONString(jsonString);
|
||||
} catch (IOException e) {
|
||||
throw new IOException(String.format("Couldn't read contents from file %s", path.toString()));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean revert() {
|
||||
try {
|
||||
return this.load();
|
||||
} catch (IOException e) {
|
||||
LOGGER.info("Unable to revert {} {}", this.resourceName, this.category);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/* List management */
|
||||
|
||||
public void add(String resource) {
|
||||
if (resource == null || this.list == null) {
|
||||
return;
|
||||
}
|
||||
if (!this.contains(resource)) {
|
||||
this.list.add(resource);
|
||||
}
|
||||
}
|
||||
|
||||
public void remove(String resource) {
|
||||
if (resource == null || this.list == null) {
|
||||
return;
|
||||
}
|
||||
this.list.remove(resource);
|
||||
}
|
||||
|
||||
public boolean contains(String resource) {
|
||||
if (resource == null || this.list == null) {
|
||||
return false;
|
||||
}
|
||||
return this.list.contains(resource);
|
||||
}
|
||||
|
||||
|
||||
/* Utils */
|
||||
|
||||
public static String listToJSONString(List<String> list) {
|
||||
if (list == null) {
|
||||
return null;
|
||||
}
|
||||
JSONArray items = new JSONArray();
|
||||
for (String item : list) {
|
||||
items.put(item);
|
||||
}
|
||||
return items.toString(4);
|
||||
}
|
||||
|
||||
private static List<String> listFromJSONString(String jsonString) {
|
||||
if (jsonString == null) {
|
||||
return null;
|
||||
}
|
||||
JSONArray jsonList = new JSONArray(jsonString);
|
||||
List<String> resourceList = new ArrayList<>();
|
||||
for (int i=0; i<jsonList.length(); i++) {
|
||||
String item = (String)jsonList.get(i);
|
||||
resourceList.add(item);
|
||||
}
|
||||
return resourceList;
|
||||
}
|
||||
|
||||
public String getJSONString() {
|
||||
return ResourceList.listToJSONString(this.list);
|
||||
}
|
||||
|
||||
}
|
||||
95
src/main/java/org/qortal/list/ResourceListManager.java
Normal file
95
src/main/java/org/qortal/list/ResourceListManager.java
Normal file
@@ -0,0 +1,95 @@
|
||||
package org.qortal.list;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class ResourceListManager {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(ResourceListManager.class);
|
||||
|
||||
private static ResourceListManager instance;
|
||||
private ResourceList addressBlacklist;
|
||||
|
||||
public ResourceListManager() {
|
||||
try {
|
||||
this.addressBlacklist = new ResourceList("blacklist", "address");
|
||||
} catch (IOException e) {
|
||||
LOGGER.info("Error while loading address blacklist. Blocking is currently unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized ResourceListManager getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new ResourceListManager();
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public boolean addAddressToBlacklist(String address, boolean save) {
|
||||
try {
|
||||
this.addressBlacklist.add(address);
|
||||
if (save) {
|
||||
this.addressBlacklist.save();
|
||||
}
|
||||
return true;
|
||||
|
||||
} catch (IllegalStateException | IOException e) {
|
||||
LOGGER.info("Unable to add address to blacklist", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean removeAddressFromBlacklist(String address, boolean save) {
|
||||
try {
|
||||
this.addressBlacklist.remove(address);
|
||||
|
||||
if (save) {
|
||||
this.addressBlacklist.save();
|
||||
}
|
||||
return true;
|
||||
|
||||
} catch (IllegalStateException | IOException e) {
|
||||
LOGGER.info("Unable to remove address from blacklist", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isAddressInBlacklist(String address) {
|
||||
if (this.addressBlacklist == null) {
|
||||
return false;
|
||||
}
|
||||
return this.addressBlacklist.contains(address);
|
||||
}
|
||||
|
||||
public void saveBlacklist() {
|
||||
if (this.addressBlacklist == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.addressBlacklist.save();
|
||||
} catch (IOException e) {
|
||||
LOGGER.info("Unable to save blacklist - reverting back to last saved state");
|
||||
this.addressBlacklist.revert();
|
||||
}
|
||||
}
|
||||
|
||||
public void revertBlacklist() {
|
||||
if (this.addressBlacklist == null) {
|
||||
return;
|
||||
}
|
||||
this.addressBlacklist.revert();
|
||||
}
|
||||
|
||||
public String getBlacklistJSONString() {
|
||||
if (this.addressBlacklist == null) {
|
||||
return null;
|
||||
}
|
||||
return this.addressBlacklist.getJSONString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -78,9 +78,10 @@ public class Name {
|
||||
// Set name's last-updated timestamp
|
||||
this.nameData.setUpdated(updateNameTransactionData.getTimestamp());
|
||||
|
||||
// Update name and data where appropriate
|
||||
// Update name, reduced name, and data where appropriate
|
||||
if (!updateNameTransactionData.getNewName().isEmpty()) {
|
||||
this.nameData.setName(updateNameTransactionData.getNewName());
|
||||
this.nameData.setReducedName(updateNameTransactionData.getReducedNewName());
|
||||
|
||||
// If we're changing the name, we need to delete old entry
|
||||
this.repository.getNameRepository().delete(updateNameTransactionData.getName());
|
||||
@@ -106,6 +107,9 @@ public class Name {
|
||||
// We can find previous 'name' from update transaction
|
||||
this.nameData.setName(updateNameTransactionData.getName());
|
||||
|
||||
// We can derive the previous 'reduced name' from the previous name
|
||||
this.nameData.setReducedName(Unicode.sanitize(updateNameTransactionData.getName()));
|
||||
|
||||
// We might need to hunt for previous data value
|
||||
if (!updateNameTransactionData.getNewData().isEmpty())
|
||||
this.nameData.setData(findPreviousData(nameReference));
|
||||
@@ -261,4 +265,8 @@ public class Name {
|
||||
return previousTransactionData.getTimestamp();
|
||||
}
|
||||
|
||||
public NameData getNameData() {
|
||||
return this.nameData;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import java.util.Arrays;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -51,7 +50,7 @@ public enum Handshake {
|
||||
|
||||
String versionString = helloMessage.getVersionString();
|
||||
|
||||
Matcher matcher = VERSION_PATTERN.matcher(versionString);
|
||||
Matcher matcher = peer.VERSION_PATTERN.matcher(versionString);
|
||||
if (!matcher.lookingAt()) {
|
||||
LOGGER.debug(() -> String.format("Peer %s sent invalid HELLO version string '%s'", peer, versionString));
|
||||
return null;
|
||||
@@ -72,6 +71,15 @@ public enum Handshake {
|
||||
peer.setPeersConnectionTimestamp(peersConnectionTimestamp);
|
||||
peer.setPeersVersion(versionString, version);
|
||||
|
||||
if (Settings.getInstance().getAllowConnectionsWithOlderPeerVersions() == false) {
|
||||
// Ensure the peer is running at least the minimum version allowed for connections
|
||||
final String minPeerVersion = Settings.getInstance().getMinPeerVersion();
|
||||
if (peer.isAtLeastVersion(minPeerVersion) == false) {
|
||||
LOGGER.debug(String.format("Ignoring peer %s because it is on an old version (%s)", peer, versionString));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return CHALLENGE;
|
||||
}
|
||||
|
||||
@@ -244,8 +252,6 @@ public enum Handshake {
|
||||
/** Maximum allowed difference between peer's reported timestamp and when they connected, in milliseconds. */
|
||||
private static final long MAX_TIMESTAMP_DELTA = 30 * 1000L; // ms
|
||||
|
||||
private static final Pattern VERSION_PATTERN = Pattern.compile(Controller.VERSION_PREFIX + "(\\d{1,3})\\.(\\d{1,5})\\.(\\d{1,5})");
|
||||
|
||||
private static final long PEER_VERSION_131 = 0x0100030001L;
|
||||
|
||||
private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
package org.qortal.network.message;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.block.BlockTransformer;
|
||||
|
||||
import com.google.common.primitives.Ints;
|
||||
|
||||
// This is an OUTGOING-only Message which more readily lends itself to being cached
|
||||
public class CachedBlockMessage extends Message {
|
||||
|
||||
private Block block = null;
|
||||
private byte[] cachedBytes = null;
|
||||
|
||||
public CachedBlockMessage(Block block) {
|
||||
super(MessageType.BLOCK);
|
||||
|
||||
this.block = block;
|
||||
}
|
||||
|
||||
public CachedBlockMessage(byte[] cachedBytes) {
|
||||
super(MessageType.BLOCK);
|
||||
|
||||
this.block = null;
|
||||
this.cachedBytes = cachedBytes;
|
||||
}
|
||||
|
||||
public static Message fromByteBuffer(int id, ByteBuffer byteBuffer) throws UnsupportedEncodingException {
|
||||
throw new UnsupportedOperationException("CachedBlockMessage is for outgoing messages only");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected byte[] toData() {
|
||||
// Already serialized?
|
||||
if (this.cachedBytes != null)
|
||||
return cachedBytes;
|
||||
|
||||
if (this.block == null)
|
||||
return null;
|
||||
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
|
||||
bytes.write(Ints.toByteArray(this.block.getBlockData().getHeight()));
|
||||
|
||||
bytes.write(BlockTransformer.toBytes(this.block));
|
||||
|
||||
this.cachedBytes = bytes.toByteArray();
|
||||
// We no longer need source Block
|
||||
// and Block contains repository handle which is highly likely to be invalid after this call
|
||||
this.block = null;
|
||||
|
||||
return this.cachedBytes;
|
||||
} catch (TransformationException | IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public CachedBlockMessage cloneWithNewId(int newId) {
|
||||
CachedBlockMessage clone = new CachedBlockMessage(this.cachedBytes);
|
||||
clone.setId(newId);
|
||||
return clone;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import java.io.UnsupportedEncodingException;
|
||||
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;
|
||||
@@ -25,7 +26,7 @@ public class GetOnlineAccountsMessage extends Message {
|
||||
private GetOnlineAccountsMessage(int id, List<OnlineAccountData> onlineAccounts) {
|
||||
super(id, MessageType.GET_ONLINE_ACCOUNTS);
|
||||
|
||||
this.onlineAccounts = onlineAccounts;
|
||||
this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<OnlineAccountData> getOnlineAccounts() {
|
||||
@@ -35,12 +36,9 @@ public class GetOnlineAccountsMessage extends Message {
|
||||
public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException {
|
||||
final int accountCount = bytes.getInt();
|
||||
|
||||
if (accountCount > MAX_ACCOUNT_COUNT)
|
||||
return null;
|
||||
|
||||
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
|
||||
|
||||
for (int i = 0; i < accountCount; ++i) {
|
||||
for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) {
|
||||
long timestamp = bytes.getLong();
|
||||
|
||||
byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH];
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.io.UnsupportedEncodingException;
|
||||
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;
|
||||
@@ -25,7 +26,7 @@ public class OnlineAccountsMessage extends Message {
|
||||
private OnlineAccountsMessage(int id, List<OnlineAccountData> onlineAccounts) {
|
||||
super(id, MessageType.ONLINE_ACCOUNTS);
|
||||
|
||||
this.onlineAccounts = onlineAccounts;
|
||||
this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<OnlineAccountData> getOnlineAccounts() {
|
||||
@@ -35,12 +36,9 @@ public class OnlineAccountsMessage extends Message {
|
||||
public static Message fromByteBuffer(int id, ByteBuffer bytes) throws UnsupportedEncodingException {
|
||||
final int accountCount = bytes.getInt();
|
||||
|
||||
if (accountCount > MAX_ACCOUNT_COUNT)
|
||||
return null;
|
||||
|
||||
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
|
||||
|
||||
for (int i = 0; i < accountCount; ++i) {
|
||||
for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) {
|
||||
long timestamp = bytes.getLong();
|
||||
|
||||
byte[] signature = new byte[Transformer.SIGNATURE_LENGTH];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.qortal.repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -98,12 +100,12 @@ public interface ATRepository {
|
||||
*/
|
||||
public List<ATStateData> getMatchingFinalATStatesQuorum(byte[] codeHash, Boolean isFinished,
|
||||
Integer dataByteOffset, Long expectedValue,
|
||||
int minimumCount, long minimumPeriod) throws DataException;
|
||||
int minimumCount, int maximumCount, long minimumPeriod) throws DataException;
|
||||
|
||||
/**
|
||||
* Returns all ATStateData for a given block height.
|
||||
* <p>
|
||||
* Unlike <tt>getATState</tt>, only returns ATStateData saved at the given height.
|
||||
* Unlike <tt>getATState</tt>, only returns <i>partial</i> ATStateData saved at the given height.
|
||||
*
|
||||
* @param height
|
||||
* - block height
|
||||
@@ -112,6 +114,14 @@ public interface ATRepository {
|
||||
*/
|
||||
public List<ATStateData> getBlockATStatesAtHeight(int height) throws DataException;
|
||||
|
||||
|
||||
/** Rebuild the latest AT states cache, necessary for AT state trimming/pruning.
|
||||
* <p>
|
||||
* NOTE: performs implicit <tt>repository.saveChanges()</tt>.
|
||||
*/
|
||||
public void rebuildLatestAtStates() throws DataException;
|
||||
|
||||
|
||||
/** Returns height of first trimmable AT state. */
|
||||
public int getAtTrimHeight() throws DataException;
|
||||
|
||||
@@ -121,12 +131,27 @@ public interface ATRepository {
|
||||
*/
|
||||
public void setAtTrimHeight(int trimHeight) throws DataException;
|
||||
|
||||
/** Hook to allow repository to prepare/cache info for AT state trimming. */
|
||||
public void prepareForAtStateTrimming() throws DataException;
|
||||
|
||||
/** Trims full AT state data between passed heights. Returns number of trimmed rows. */
|
||||
public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException;
|
||||
|
||||
|
||||
/** Returns height of first prunable AT state. */
|
||||
public int getAtPruneHeight() throws DataException;
|
||||
|
||||
/** Sets new base height for AT state pruning.
|
||||
* <p>
|
||||
* NOTE: performs implicit <tt>repository.saveChanges()</tt>.
|
||||
*/
|
||||
public void setAtPruneHeight(int pruneHeight) throws DataException;
|
||||
|
||||
/** Prunes full AT state data between passed heights. Returns number of pruned rows. */
|
||||
public int pruneAtStates(int minHeight, int maxHeight) throws DataException;
|
||||
|
||||
|
||||
/** Checks for the presence of the ATStatesHeightIndex in repository */
|
||||
public boolean hasAtStatesHeightIndex() throws DataException;
|
||||
|
||||
|
||||
/**
|
||||
* Save ATStateData into repository.
|
||||
* <p>
|
||||
|
||||
@@ -191,6 +191,8 @@ public interface AccountRepository {
|
||||
|
||||
public List<MintingAccountData> getMintingAccounts() throws DataException;
|
||||
|
||||
public MintingAccountData getMintingAccount(byte[] mintingAccountKey) throws DataException;
|
||||
|
||||
public void save(MintingAccountData mintingAccountData) throws DataException;
|
||||
|
||||
/** Delete minting account info, used by BlockMinter, from repository using passed public or private key. */
|
||||
|
||||
268
src/main/java/org/qortal/repository/BlockArchiveReader.java
Normal file
268
src/main/java/org/qortal/repository/BlockArchiveReader.java
Normal file
@@ -0,0 +1,268 @@
|
||||
package org.qortal.repository;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
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.transform.TransformationException;
|
||||
import org.qortal.transform.block.BlockTransformer;
|
||||
import org.qortal.utils.Triple;
|
||||
|
||||
import static org.qortal.transform.Transformer.INT_LENGTH;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
|
||||
public class BlockArchiveReader {
|
||||
|
||||
private static BlockArchiveReader instance;
|
||||
private Map<String, Triple<Integer, Integer, Integer>> fileListCache = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(BlockArchiveReader.class);
|
||||
|
||||
public BlockArchiveReader() {
|
||||
|
||||
}
|
||||
|
||||
public static synchronized BlockArchiveReader getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new BlockArchiveReader();
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private void fetchFileList() {
|
||||
Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath();
|
||||
File archiveDirFile = archivePath.toFile();
|
||||
String[] files = archiveDirFile.list();
|
||||
Map<String, Triple<Integer, Integer, Integer>> map = new HashMap<>();
|
||||
|
||||
if (files != null) {
|
||||
for (String file : files) {
|
||||
Path filePath = Paths.get(file);
|
||||
String filename = filePath.getFileName().toString();
|
||||
|
||||
// Parse the filename
|
||||
if (filename == null || !filename.contains("-") || !filename.contains(".")) {
|
||||
// Not a usable file
|
||||
continue;
|
||||
}
|
||||
// Remove the extension and split into two parts
|
||||
String[] parts = filename.substring(0, filename.lastIndexOf('.')).split("-");
|
||||
Integer startHeight = Integer.parseInt(parts[0]);
|
||||
Integer endHeight = Integer.parseInt(parts[1]);
|
||||
Integer range = endHeight - startHeight;
|
||||
map.put(filename, new Triple(startHeight, endHeight, range));
|
||||
}
|
||||
}
|
||||
this.fileListCache = map;
|
||||
}
|
||||
|
||||
public Triple<BlockData, List<TransactionData>, List<ATStateData>> fetchBlockAtHeight(int height) {
|
||||
if (this.fileListCache.isEmpty()) {
|
||||
this.fetchFileList();
|
||||
}
|
||||
|
||||
byte[] serializedBytes = this.fetchSerializedBlockBytesForHeight(height);
|
||||
if (serializedBytes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap(serializedBytes);
|
||||
Triple<BlockData, List<TransactionData>, List<ATStateData>> blockInfo = null;
|
||||
try {
|
||||
blockInfo = BlockTransformer.fromByteBuffer(byteBuffer);
|
||||
if (blockInfo != null && blockInfo.getA() != null) {
|
||||
// Block height is stored outside of the main serialized bytes, so it
|
||||
// won't be set automatically.
|
||||
blockInfo.getA().setHeight(height);
|
||||
}
|
||||
} catch (TransformationException e) {
|
||||
return null;
|
||||
}
|
||||
return blockInfo;
|
||||
}
|
||||
|
||||
public Triple<BlockData, List<TransactionData>, List<ATStateData>> fetchBlockWithSignature(
|
||||
byte[] signature, Repository repository) {
|
||||
|
||||
if (this.fileListCache.isEmpty()) {
|
||||
this.fetchFileList();
|
||||
}
|
||||
|
||||
Integer height = this.fetchHeightForSignature(signature, repository);
|
||||
if (height != null) {
|
||||
return this.fetchBlockAtHeight(height);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<Triple<BlockData, List<TransactionData>, List<ATStateData>>> fetchBlocksFromRange(
|
||||
int startHeight, int endHeight) {
|
||||
|
||||
List<Triple<BlockData, List<TransactionData>, List<ATStateData>>> blockInfoList = new ArrayList<>();
|
||||
|
||||
for (int height = startHeight; height <= endHeight; height++) {
|
||||
Triple<BlockData, List<TransactionData>, List<ATStateData>> blockInfo = this.fetchBlockAtHeight(height);
|
||||
if (blockInfo == null) {
|
||||
return blockInfoList;
|
||||
}
|
||||
blockInfoList.add(blockInfo);
|
||||
}
|
||||
return blockInfoList;
|
||||
}
|
||||
|
||||
public Integer fetchHeightForSignature(byte[] signature, Repository repository) {
|
||||
// Lookup the height for the requested signature
|
||||
try {
|
||||
BlockArchiveData archivedBlock = repository.getBlockArchiveRepository().getBlockArchiveDataForSignature(signature);
|
||||
if (archivedBlock == null) {
|
||||
return null;
|
||||
}
|
||||
return archivedBlock.getHeight();
|
||||
|
||||
} catch (DataException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public int fetchHeightForTimestamp(long timestamp, Repository repository) {
|
||||
// Lookup the height for the requested signature
|
||||
try {
|
||||
return repository.getBlockArchiveRepository().getHeightFromTimestamp(timestamp);
|
||||
|
||||
} catch (DataException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private String getFilenameForHeight(int height) {
|
||||
Iterator it = this.fileListCache.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry pair = (Map.Entry)it.next();
|
||||
if (pair == null && pair.getKey() == null && pair.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
Triple<Integer, Integer, Integer> heightInfo = (Triple<Integer, Integer, Integer>) pair.getValue();
|
||||
Integer startHeight = heightInfo.getA();
|
||||
Integer endHeight = heightInfo.getB();
|
||||
|
||||
if (height >= startHeight && height <= endHeight) {
|
||||
// Found the correct file
|
||||
String filename = (String) pair.getKey();
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public byte[] fetchSerializedBlockBytesForSignature(byte[] signature, Repository repository) {
|
||||
|
||||
if (this.fileListCache.isEmpty()) {
|
||||
this.fetchFileList();
|
||||
}
|
||||
|
||||
Integer height = this.fetchHeightForSignature(signature, repository);
|
||||
if (height != null) {
|
||||
return this.fetchSerializedBlockBytesForHeight(height);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public byte[] fetchSerializedBlockBytesForHeight(int height) {
|
||||
String filename = this.getFilenameForHeight(height);
|
||||
if (filename == null) {
|
||||
// We don't have this block in the archive
|
||||
// Invalidate the file list cache in case it is out of date
|
||||
this.invalidateFileListCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
Path filePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive", filename).toAbsolutePath();
|
||||
RandomAccessFile file = null;
|
||||
try {
|
||||
file = new RandomAccessFile(filePath.toString(), "r");
|
||||
// Get info about this file (the "fixed length header")
|
||||
final int version = file.readInt(); // Do not remove or comment out, as it is moving the file pointer
|
||||
final int startHeight = file.readInt(); // Do not remove or comment out, as it is moving the file pointer
|
||||
final int endHeight = file.readInt(); // Do not remove or comment out, as it is moving the file pointer
|
||||
file.readInt(); // Block count (unused) // Do not remove or comment out, as it is moving the file pointer
|
||||
final int variableHeaderLength = file.readInt(); // Do not remove or comment out, as it is moving the file pointer
|
||||
final int fixedHeaderLength = (int)file.getFilePointer();
|
||||
// End of fixed length header
|
||||
|
||||
// Make sure the version is one we recognize
|
||||
if (version != 1) {
|
||||
LOGGER.info("Error: unknown version in file {}: {}", filename, version);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify that the block is within the reported range
|
||||
if (height < startHeight || height > endHeight) {
|
||||
LOGGER.info("Error: requested height {} but the range of file {} is {}-{}",
|
||||
height, filename, startHeight, endHeight);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Seek to the location of the block index in the variable length header
|
||||
final int locationOfBlockIndexInVariableHeaderSegment = (height - startHeight) * INT_LENGTH;
|
||||
file.seek(fixedHeaderLength + locationOfBlockIndexInVariableHeaderSegment);
|
||||
|
||||
// Read the value to obtain the index of this block in the data segment
|
||||
int locationOfBlockInDataSegment = file.readInt();
|
||||
|
||||
// Now seek to the block data itself
|
||||
int dataSegmentStartIndex = fixedHeaderLength + variableHeaderLength + INT_LENGTH; // Confirmed correct
|
||||
file.seek(dataSegmentStartIndex + locationOfBlockInDataSegment);
|
||||
|
||||
// Read the block metadata
|
||||
int blockHeight = file.readInt(); // Do not remove or comment out, as it is moving the file pointer
|
||||
int blockLength = file.readInt(); // Do not remove or comment out, as it is moving the file pointer
|
||||
|
||||
// Ensure the block height matches the one requested
|
||||
if (blockHeight != height) {
|
||||
LOGGER.info("Error: height {} does not match requested: {}", blockHeight, height);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Now retrieve the block's serialized bytes
|
||||
byte[] blockBytes = new byte[blockLength];
|
||||
file.read(blockBytes);
|
||||
|
||||
return blockBytes;
|
||||
|
||||
} catch (FileNotFoundException e) {
|
||||
LOGGER.info("File {} not found: {}", filename, e.getMessage());
|
||||
return null;
|
||||
} catch (IOException e) {
|
||||
LOGGER.info("Unable to read block {} from archive: {}", height, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
finally {
|
||||
// Close the file
|
||||
if (file != null) {
|
||||
try {
|
||||
file.close();
|
||||
} catch (IOException e) {
|
||||
// Failed to close, but no need to handle this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void invalidateFileListCache() {
|
||||
this.fileListCache.clear();
|
||||
}
|
||||
|
||||
}
|
||||
130
src/main/java/org/qortal/repository/BlockArchiveRepository.java
Normal file
130
src/main/java/org/qortal/repository/BlockArchiveRepository.java
Normal file
@@ -0,0 +1,130 @@
|
||||
package org.qortal.repository;
|
||||
|
||||
import org.qortal.api.model.BlockSignerSummary;
|
||||
import org.qortal.data.block.BlockArchiveData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.block.BlockSummaryData;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface BlockArchiveRepository {
|
||||
|
||||
/**
|
||||
* Returns BlockData from archive using block signature.
|
||||
*
|
||||
* @param signature
|
||||
* @return block data, or null if not found in archive.
|
||||
* @throws DataException
|
||||
*/
|
||||
public BlockData fromSignature(byte[] signature) throws DataException;
|
||||
|
||||
/**
|
||||
* Return height of block in archive using block's signature.
|
||||
*
|
||||
* @param signature
|
||||
* @return height, or 0 if not found in blockchain.
|
||||
* @throws DataException
|
||||
*/
|
||||
public int getHeightFromSignature(byte[] signature) throws DataException;
|
||||
|
||||
/**
|
||||
* Returns BlockData from archive using block height.
|
||||
*
|
||||
* @param height
|
||||
* @return block data, or null if not found in blockchain.
|
||||
* @throws DataException
|
||||
*/
|
||||
public BlockData fromHeight(int height) throws DataException;
|
||||
|
||||
/**
|
||||
* Returns a list of BlockData objects from archive using
|
||||
* block height range.
|
||||
*
|
||||
* @param startHeight
|
||||
* @return a list of BlockData objects, or an empty list if
|
||||
* not found in blockchain. It is not guaranteed that all
|
||||
* requested blocks will be returned.
|
||||
* @throws DataException
|
||||
*/
|
||||
public List<BlockData> fromRange(int startHeight, int endHeight) throws DataException;
|
||||
|
||||
/**
|
||||
* Returns BlockData from archive using block reference.
|
||||
* Currently relies on a child block being the one block
|
||||
* higher than its parent. This limitation can be removed
|
||||
* by storing the reference in the BlockArchive table, but
|
||||
* this has been avoided to reduce space.
|
||||
*
|
||||
* @param reference
|
||||
* @return block data, or null if either parent or child
|
||||
* not found in the archive.
|
||||
* @throws DataException
|
||||
*/
|
||||
public BlockData fromReference(byte[] reference) throws DataException;
|
||||
|
||||
/**
|
||||
* Return height of block with timestamp just before passed timestamp.
|
||||
*
|
||||
* @param timestamp
|
||||
* @return height, or 0 if not found in blockchain.
|
||||
* @throws DataException
|
||||
*/
|
||||
public int getHeightFromTimestamp(long timestamp) throws DataException;
|
||||
|
||||
/**
|
||||
* Returns block summaries for blocks signed by passed public key, or reward-share with minter with passed public key.
|
||||
*/
|
||||
public List<BlockSummaryData> getBlockSummariesBySigner(byte[] signerPublicKey, Integer limit, Integer offset, Boolean reverse) throws DataException;
|
||||
|
||||
/**
|
||||
* Returns summaries of block signers, optionally limited to passed addresses.
|
||||
* This combines both the BlockArchive and the Blocks data into a single result set.
|
||||
*/
|
||||
public List<BlockSignerSummary> getBlockSigners(List<String> addresses, Integer limit, Integer offset, Boolean reverse) throws DataException;
|
||||
|
||||
|
||||
/** Returns height of first unarchived block. */
|
||||
public int getBlockArchiveHeight() throws DataException;
|
||||
|
||||
/** Sets new height for block archiving.
|
||||
* <p>
|
||||
* NOTE: performs implicit <tt>repository.saveChanges()</tt>.
|
||||
*/
|
||||
public void setBlockArchiveHeight(int archiveHeight) throws DataException;
|
||||
|
||||
|
||||
/**
|
||||
* Returns the block archive data for a given signature, from the block archive.
|
||||
* <p>
|
||||
* This method will return null if no block archive has been built for the
|
||||
* requested signature. In those cases, the height (and other data) can be
|
||||
* looked up using the Blocks table. This allows a block to be located in
|
||||
* the archive when we only know its signature.
|
||||
* <p>
|
||||
*
|
||||
* @param signature
|
||||
* @throws DataException
|
||||
*/
|
||||
public BlockArchiveData getBlockArchiveDataForSignature(byte[] signature) throws DataException;
|
||||
|
||||
/**
|
||||
* Saves a block archive entry into the repository.
|
||||
* <p>
|
||||
* This can be used to find the height of a block by its signature, without
|
||||
* having access to the block data itself.
|
||||
* <p>
|
||||
*
|
||||
* @param blockArchiveData
|
||||
* @throws DataException
|
||||
*/
|
||||
public void save(BlockArchiveData blockArchiveData) throws DataException;
|
||||
|
||||
/**
|
||||
* Deletes a block archive entry from the repository.
|
||||
*
|
||||
* @param blockArchiveData
|
||||
* @throws DataException
|
||||
*/
|
||||
public void delete(BlockArchiveData blockArchiveData) throws DataException;
|
||||
|
||||
}
|
||||
201
src/main/java/org/qortal/repository/BlockArchiveWriter.java
Normal file
201
src/main/java/org/qortal/repository/BlockArchiveWriter.java
Normal file
@@ -0,0 +1,201 @@
|
||||
package org.qortal.repository;
|
||||
|
||||
import com.google.common.primitives.Ints;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockArchiveData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.block.BlockTransformer;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
public class BlockArchiveWriter {
|
||||
|
||||
public enum BlockArchiveWriteResult {
|
||||
OK,
|
||||
STOPPING,
|
||||
NOT_ENOUGH_BLOCKS,
|
||||
BLOCK_NOT_FOUND
|
||||
}
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(BlockArchiveWriter.class);
|
||||
|
||||
public static final long DEFAULT_FILE_SIZE_TARGET = 100 * 1024 * 1024; // 100MiB
|
||||
|
||||
private int startHeight;
|
||||
private final int endHeight;
|
||||
private final Repository repository;
|
||||
|
||||
private long fileSizeTarget = DEFAULT_FILE_SIZE_TARGET;
|
||||
private boolean shouldEnforceFileSizeTarget = true;
|
||||
|
||||
private int writtenCount;
|
||||
private int lastWrittenHeight;
|
||||
private Path outputPath;
|
||||
|
||||
public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) {
|
||||
this.startHeight = startHeight;
|
||||
this.endHeight = endHeight;
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public static int getMaxArchiveHeight(Repository repository) throws DataException {
|
||||
// We must only archive trimmed blocks, or the archive will grow far too large
|
||||
final int accountSignaturesTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight();
|
||||
final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight();
|
||||
final int trimStartHeight = Math.min(accountSignaturesTrimStartHeight, atTrimStartHeight);
|
||||
return trimStartHeight - 1; // subtract 1 because these values represent the first _untrimmed_ block
|
||||
}
|
||||
|
||||
public static boolean isArchiverUpToDate(Repository repository) throws DataException {
|
||||
final int maxArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository);
|
||||
final int actualArchiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight();
|
||||
final float progress = (float)actualArchiveHeight / (float) maxArchiveHeight;
|
||||
LOGGER.debug(String.format("maxArchiveHeight: %d, actualArchiveHeight: %d, progress: %f",
|
||||
maxArchiveHeight, actualArchiveHeight, progress));
|
||||
|
||||
// If archiver is within 95% of the maximum, treat it as up to date
|
||||
// We need several percent as an allowance because the archiver will only
|
||||
// save files when they reach the target size
|
||||
return (progress >= 0.95);
|
||||
}
|
||||
|
||||
public BlockArchiveWriteResult write() throws DataException, IOException, TransformationException, InterruptedException {
|
||||
// Create the archive folder if it doesn't exist
|
||||
// This is a subfolder of the db directory, to make bootstrapping easier
|
||||
Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath();
|
||||
try {
|
||||
Files.createDirectories(archivePath);
|
||||
} catch (IOException e) {
|
||||
LOGGER.info("Unable to create archive folder");
|
||||
throw new DataException("Unable to create archive folder");
|
||||
}
|
||||
|
||||
// Determine start height of blocks to fetch
|
||||
if (startHeight <= 2) {
|
||||
// Skip genesis block, as it's not designed to be transmitted, and we can build that from blockchain.json
|
||||
// TODO: include genesis block if we can
|
||||
startHeight = 2;
|
||||
}
|
||||
|
||||
// Header bytes will store the block indexes
|
||||
ByteArrayOutputStream headerBytes = new ByteArrayOutputStream();
|
||||
// Bytes will store the actual block data
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
|
||||
LOGGER.info(String.format("Fetching blocks from height %d...", startHeight));
|
||||
int i = 0;
|
||||
while (headerBytes.size() + bytes.size() < this.fileSizeTarget
|
||||
|| this.shouldEnforceFileSizeTarget == false) {
|
||||
|
||||
if (Controller.isStopping()) {
|
||||
return BlockArchiveWriteResult.STOPPING;
|
||||
}
|
||||
if (Controller.getInstance().isSynchronizing()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int currentHeight = startHeight + i;
|
||||
if (currentHeight > endHeight) {
|
||||
break;
|
||||
}
|
||||
|
||||
//LOGGER.info("Fetching block {}...", currentHeight);
|
||||
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(currentHeight);
|
||||
if (blockData == null) {
|
||||
return BlockArchiveWriteResult.BLOCK_NOT_FOUND;
|
||||
}
|
||||
|
||||
// Write the signature and height into the BlockArchive table
|
||||
BlockArchiveData blockArchiveData = new BlockArchiveData(blockData);
|
||||
repository.getBlockArchiveRepository().save(blockArchiveData);
|
||||
repository.saveChanges();
|
||||
|
||||
// Write the block data to some byte buffers
|
||||
Block block = new Block(repository, blockData);
|
||||
int blockIndex = bytes.size();
|
||||
// Write block index to header
|
||||
headerBytes.write(Ints.toByteArray(blockIndex));
|
||||
// Write block height
|
||||
bytes.write(Ints.toByteArray(block.getBlockData().getHeight()));
|
||||
byte[] blockBytes = BlockTransformer.toBytes(block);
|
||||
// Write block length
|
||||
bytes.write(Ints.toByteArray(blockBytes.length));
|
||||
// Write block bytes
|
||||
bytes.write(blockBytes);
|
||||
i++;
|
||||
|
||||
}
|
||||
int totalLength = headerBytes.size() + bytes.size();
|
||||
LOGGER.info(String.format("Total length of %d blocks is %d bytes", i, totalLength));
|
||||
|
||||
// Validate file size, in case something went wrong
|
||||
if (totalLength < fileSizeTarget && this.shouldEnforceFileSizeTarget) {
|
||||
return BlockArchiveWriteResult.NOT_ENOUGH_BLOCKS;
|
||||
}
|
||||
|
||||
// We have enough blocks to create a new file
|
||||
int endHeight = startHeight + i - 1;
|
||||
int version = 1;
|
||||
String filePath = String.format("%s/%d-%d.dat", archivePath.toString(), startHeight, endHeight);
|
||||
FileOutputStream fileOutputStream = new FileOutputStream(filePath);
|
||||
// Write version number
|
||||
fileOutputStream.write(Ints.toByteArray(version));
|
||||
// Write start height
|
||||
fileOutputStream.write(Ints.toByteArray(startHeight));
|
||||
// Write end height
|
||||
fileOutputStream.write(Ints.toByteArray(endHeight));
|
||||
// Write total count
|
||||
fileOutputStream.write(Ints.toByteArray(i));
|
||||
// Write dynamic header (block indexes) segment length
|
||||
fileOutputStream.write(Ints.toByteArray(headerBytes.size()));
|
||||
// Write dynamic header (block indexes) data
|
||||
headerBytes.writeTo(fileOutputStream);
|
||||
// Write data segment (block data) length
|
||||
fileOutputStream.write(Ints.toByteArray(bytes.size()));
|
||||
// Write data
|
||||
bytes.writeTo(fileOutputStream);
|
||||
// Close the file
|
||||
fileOutputStream.close();
|
||||
|
||||
// Invalidate cache so that the rest of the app picks up the new file
|
||||
BlockArchiveReader.getInstance().invalidateFileListCache();
|
||||
|
||||
this.writtenCount = i;
|
||||
this.lastWrittenHeight = endHeight;
|
||||
this.outputPath = Paths.get(filePath);
|
||||
return BlockArchiveWriteResult.OK;
|
||||
}
|
||||
|
||||
public int getWrittenCount() {
|
||||
return this.writtenCount;
|
||||
}
|
||||
|
||||
public int getLastWrittenHeight() {
|
||||
return this.lastWrittenHeight;
|
||||
}
|
||||
|
||||
public Path getOutputPath() {
|
||||
return this.outputPath;
|
||||
}
|
||||
|
||||
public void setFileSizeTarget(long fileSizeTarget) {
|
||||
this.fileSizeTarget = fileSizeTarget;
|
||||
}
|
||||
|
||||
// For testing, to avoid having to pre-calculate file sizes
|
||||
public void setShouldEnforceFileSizeTarget(boolean shouldEnforceFileSizeTarget) {
|
||||
this.shouldEnforceFileSizeTarget = shouldEnforceFileSizeTarget;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -137,11 +137,6 @@ public interface BlockRepository {
|
||||
*/
|
||||
public List<BlockSummaryData> getBlockSummaries(int firstBlockHeight, int lastBlockHeight) throws DataException;
|
||||
|
||||
/**
|
||||
* Returns block summaries for the passed height range, for API use.
|
||||
*/
|
||||
public List<BlockSummaryData> getBlockSummaries(Integer startHeight, Integer endHeight, Integer count) throws DataException;
|
||||
|
||||
/** Returns height of first trimmable online accounts signatures. */
|
||||
public int getOnlineAccountsSignaturesTrimHeight() throws DataException;
|
||||
|
||||
@@ -166,6 +161,20 @@ public interface BlockRepository {
|
||||
*/
|
||||
public BlockData getDetachedBlockSignature(int startHeight) throws DataException;
|
||||
|
||||
|
||||
/** Returns height of first prunable block. */
|
||||
public int getBlockPruneHeight() throws DataException;
|
||||
|
||||
/** Sets new base height for block pruning.
|
||||
* <p>
|
||||
* NOTE: performs implicit <tt>repository.saveChanges()</tt>.
|
||||
*/
|
||||
public void setBlockPruneHeight(int pruneHeight) throws DataException;
|
||||
|
||||
/** Prunes full block data between passed heights. Returns number of pruned rows. */
|
||||
public int pruneBlocks(int minHeight, int maxHeight) throws DataException;
|
||||
|
||||
|
||||
/**
|
||||
* Saves block into repository.
|
||||
*
|
||||
|
||||
422
src/main/java/org/qortal/repository/Bootstrap.java
Normal file
422
src/main/java/org/qortal/repository/Bootstrap.java
Normal file
@@ -0,0 +1,422 @@
|
||||
package org.qortal.repository;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.account.MintingAccountData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.crosschain.TradeBotData;
|
||||
import org.qortal.gui.SplashFrame;
|
||||
import org.qortal.repository.hsqldb.HSQLDBImportExport;
|
||||
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.NTP;
|
||||
import org.qortal.utils.SevenZ;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.nio.file.*;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
|
||||
|
||||
|
||||
public class Bootstrap {
|
||||
|
||||
private Repository repository;
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(Bootstrap.class);
|
||||
|
||||
/** The maximum number of untrimmed blocks allowed to be included in a bootstrap, beyond the trim threshold */
|
||||
private static final int MAXIMUM_UNTRIMMED_BLOCKS = 100;
|
||||
|
||||
/** The maximum number of unpruned blocks allowed to be included in a bootstrap, beyond the prune threshold */
|
||||
private static final int MAXIMUM_UNPRUNED_BLOCKS = 100;
|
||||
|
||||
|
||||
public Bootstrap() {
|
||||
}
|
||||
|
||||
public Bootstrap(Repository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* canBootstrap()
|
||||
* Performs basic initial checks to ensure everything is in order
|
||||
* @return true if ready for bootstrap creation, or false if not
|
||||
* All failure reasons are logged
|
||||
*/
|
||||
public boolean canCreateBootstrap() {
|
||||
try {
|
||||
LOGGER.info("Checking repository state...");
|
||||
|
||||
final boolean isTopOnly = Settings.getInstance().isTopOnly();
|
||||
final boolean archiveEnabled = Settings.getInstance().isArchiveEnabled();
|
||||
|
||||
// Make sure we have a repository instance
|
||||
if (repository == null) {
|
||||
LOGGER.info("Error: repository instance required to check if we can create a bootstrap.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Require that a block archive has been built
|
||||
if (!isTopOnly && !archiveEnabled) {
|
||||
LOGGER.info("Unable to create bootstrap because the block archive isn't enabled. " +
|
||||
"Set {\"archivedEnabled\": true} in settings.json to fix.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure that the block archiver is up to date
|
||||
boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository);
|
||||
if (!upToDate) {
|
||||
LOGGER.info("Unable to create bootstrap because the block archive isn't fully built yet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure that this database contains the ATStatesHeightIndex which was missing in some cases
|
||||
boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex();
|
||||
if (!hasAtStatesHeightIndex) {
|
||||
LOGGER.info("Unable to create bootstrap due to missing ATStatesHeightIndex. A re-sync from genesis is needed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure we have synced NTP time
|
||||
if (NTP.getTime() == null) {
|
||||
LOGGER.info("Unable to create bootstrap because the node hasn't synced its time yet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure the chain is synced
|
||||
final BlockData chainTip = Controller.getInstance().getChainTip();
|
||||
final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp();
|
||||
if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) {
|
||||
LOGGER.info("Unable to create bootstrap because the blockchain isn't fully synced.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// FUTURE: ensure trim and prune settings are using default values
|
||||
|
||||
if (!isTopOnly) {
|
||||
// We don't trim in top-only mode because we prune the blocks instead
|
||||
// If we're not in top-only mode we should make sure that trimming is up to date
|
||||
|
||||
// Ensure that the online account signatures have been fully trimmed
|
||||
final int accountsTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight();
|
||||
final long accountsUpperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime();
|
||||
final int accountsUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(accountsUpperTrimmableTimestamp);
|
||||
final int accountsBlocksRemaining = accountsUpperTrimmableHeight - accountsTrimStartHeight;
|
||||
if (accountsBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) {
|
||||
LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " +
|
||||
"then try again. Blocks remaining (online accounts signatures): {}", accountsBlocksRemaining);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure that the AT states data has been fully trimmed
|
||||
final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight();
|
||||
final long atUpperTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime();
|
||||
final int atUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(atUpperTrimmableTimestamp);
|
||||
final int atBlocksRemaining = atUpperTrimmableHeight - atTrimStartHeight;
|
||||
if (atBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) {
|
||||
LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " +
|
||||
"then try again. Blocks remaining (AT states): {}", atBlocksRemaining);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that blocks have been fully pruned
|
||||
final int blockPruneStartHeight = repository.getBlockRepository().getBlockPruneHeight();
|
||||
int blockUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit();
|
||||
if (archiveEnabled) {
|
||||
blockUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1;
|
||||
}
|
||||
final int blocksPruneRemaining = blockUpperPrunableHeight - blockPruneStartHeight;
|
||||
if (blocksPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) {
|
||||
LOGGER.info("Blockchain is not fully pruned. Please allow the node to run for longer, " +
|
||||
"then try again. Blocks remaining: {}", blocksPruneRemaining);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure that AT states have been fully pruned
|
||||
final int atPruneStartHeight = repository.getATRepository().getAtPruneHeight();
|
||||
int atUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit();
|
||||
if (archiveEnabled) {
|
||||
atUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1;
|
||||
}
|
||||
final int atPruneRemaining = atUpperPrunableHeight - atPruneStartHeight;
|
||||
if (atPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) {
|
||||
LOGGER.info("Blockchain is not fully pruned. Please allow the node to run for longer, " +
|
||||
"then try again. Blocks remaining (AT states): {}", atPruneRemaining);
|
||||
return false;
|
||||
}
|
||||
|
||||
LOGGER.info("Repository state checks passed");
|
||||
return true;
|
||||
}
|
||||
catch (DataException e) {
|
||||
LOGGER.info("Unable to create bootstrap: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* validateBlockchain
|
||||
* Performs quick validation of recent blocks in blockchain, prior to creating a bootstrap
|
||||
* @return true if valid, false if not
|
||||
*/
|
||||
public boolean validateBlockchain() {
|
||||
LOGGER.info("Validating blockchain...");
|
||||
|
||||
try {
|
||||
BlockChain.validate();
|
||||
|
||||
LOGGER.info("Blockchain is valid");
|
||||
|
||||
return true;
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("Blockchain validation failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* validateCompleteBlockchain
|
||||
* Performs intensive validation of all blocks in blockchain
|
||||
* @return true if valid, false if not
|
||||
*/
|
||||
public boolean validateCompleteBlockchain() {
|
||||
LOGGER.info("Validating blockchain...");
|
||||
|
||||
try {
|
||||
// Perform basic startup validation
|
||||
BlockChain.validate();
|
||||
|
||||
// Perform more intensive full-chain validation
|
||||
BlockChain.validateAllBlocks();
|
||||
|
||||
LOGGER.info("Blockchain is valid");
|
||||
|
||||
return true;
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("Blockchain validation failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public String create() throws DataException, InterruptedException, IOException {
|
||||
|
||||
// Make sure we have a repository instance
|
||||
if (repository == null) {
|
||||
throw new DataException("Repository instance required in order to create a boostrap");
|
||||
}
|
||||
|
||||
LOGGER.info("Acquiring blockchain lock...");
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
Path inputPath = null;
|
||||
Path outputPath = null;
|
||||
|
||||
try {
|
||||
|
||||
LOGGER.info("Exporting local data...");
|
||||
repository.exportNodeLocalData();
|
||||
|
||||
LOGGER.info("Deleting trade bot states...");
|
||||
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
for (TradeBotData tradeBotData : allTradeBotData) {
|
||||
repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
|
||||
}
|
||||
|
||||
LOGGER.info("Deleting minting accounts...");
|
||||
List<MintingAccountData> mintingAccounts = repository.getAccountRepository().getMintingAccounts();
|
||||
for (MintingAccountData mintingAccount : mintingAccounts) {
|
||||
repository.getAccountRepository().delete(mintingAccount.getPrivateKey());
|
||||
}
|
||||
|
||||
repository.saveChanges();
|
||||
|
||||
LOGGER.info("Creating bootstrap...");
|
||||
// Timeout if the database isn't ready for backing up after 10 seconds
|
||||
long timeout = 10 * 1000L;
|
||||
repository.backup(false, "bootstrap", timeout);
|
||||
|
||||
LOGGER.info("Moving files to output directory...");
|
||||
inputPath = Paths.get(Settings.getInstance().getRepositoryPath(), "bootstrap");
|
||||
outputPath = Paths.get(Files.createTempDirectory("qortal-bootstrap").toString(), "bootstrap");
|
||||
|
||||
|
||||
// Move the db backup to a "bootstrap" folder in the root directory
|
||||
Files.move(inputPath, outputPath, REPLACE_EXISTING);
|
||||
|
||||
// If in archive mode, copy the archive folder to inside the bootstrap folder
|
||||
if (!Settings.getInstance().isTopOnly() && Settings.getInstance().isArchiveEnabled()) {
|
||||
FileUtils.copyDirectory(
|
||||
Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toFile(),
|
||||
Paths.get(outputPath.toString(), "archive").toFile()
|
||||
);
|
||||
}
|
||||
|
||||
LOGGER.info("Compressing...");
|
||||
String compressedOutputPath = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), this.getFilename());
|
||||
try {
|
||||
Files.delete(Paths.get(compressedOutputPath));
|
||||
} catch (NoSuchFileException e) {
|
||||
// Doesn't exist, so no need to delete
|
||||
}
|
||||
SevenZ.compress(compressedOutputPath, outputPath.toFile());
|
||||
|
||||
// Return the path to the compressed bootstrap file
|
||||
Path finalPath = Paths.get(compressedOutputPath);
|
||||
return finalPath.toAbsolutePath().toString();
|
||||
|
||||
}
|
||||
catch (TimeoutException e) {
|
||||
throw new DataException(String.format("Unable to create bootstrap due to timeout: %s", e.getMessage()));
|
||||
}
|
||||
finally {
|
||||
LOGGER.info("Re-importing local data...");
|
||||
Path exportPath = HSQLDBImportExport.getExportDirectory(false);
|
||||
repository.importDataFromFile(Paths.get(exportPath.toString(), "TradeBotStates.json").toString());
|
||||
repository.importDataFromFile(Paths.get(exportPath.toString(), "MintingAccounts.json").toString());
|
||||
|
||||
blockchainLock.unlock();
|
||||
|
||||
// Cleanup
|
||||
if (inputPath != null) {
|
||||
FileUtils.deleteDirectory(inputPath.toFile());
|
||||
}
|
||||
if (outputPath != null) {
|
||||
FileUtils.deleteDirectory(outputPath.toFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void startImport() throws InterruptedException {
|
||||
while (!Controller.isStopping()) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
this.repository = repository;
|
||||
|
||||
this.updateStatus("Starting import of bootstrap...");
|
||||
|
||||
this.doImport();
|
||||
break;
|
||||
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("Bootstrap import failed: {}", e.getMessage());
|
||||
this.updateStatus("Bootstrapping failed. Retrying in 5 minutes");
|
||||
Thread.sleep(5 * 60 * 1000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void doImport() throws DataException {
|
||||
Path path = null;
|
||||
try {
|
||||
Path tempDir = Files.createTempDirectory("qortal-bootstrap");
|
||||
String filename = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), this.getFilename());
|
||||
path = Paths.get(tempDir.toString(), filename);
|
||||
|
||||
this.downloadToPath(path);
|
||||
this.importFromPath(path);
|
||||
|
||||
} catch (InterruptedException | DataException | IOException e) {
|
||||
throw new DataException(String.format("Unable to import bootstrap: %s", e.getMessage()));
|
||||
}
|
||||
finally {
|
||||
if (path != null) {
|
||||
try {
|
||||
Files.delete(path);
|
||||
|
||||
} catch (IOException e) {
|
||||
// Temp folder will be cleaned up by system anyway, so ignore this failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String getFilename() {
|
||||
boolean isTopOnly = Settings.getInstance().isTopOnly();
|
||||
boolean archiveEnabled = Settings.getInstance().isArchiveEnabled();
|
||||
|
||||
if (isTopOnly) {
|
||||
return "bootstrap-toponly.7z";
|
||||
}
|
||||
else if (archiveEnabled) {
|
||||
return "bootstrap-archive.7z";
|
||||
}
|
||||
else {
|
||||
return "bootstrap-full.7z";
|
||||
}
|
||||
}
|
||||
|
||||
private void downloadToPath(Path path) throws DataException {
|
||||
// Select a random host from bootstrapHosts
|
||||
String[] hosts = Settings.getInstance().getBootstrapHosts();
|
||||
int index = new SecureRandom().nextInt(hosts.length);
|
||||
String bootstrapHost = hosts[index];
|
||||
String bootstrapFilename = this.getFilename();
|
||||
|
||||
try {
|
||||
this.updateStatus("Downloading bootstrap...");
|
||||
String bootstrapUrl = String.format("%s/%s", bootstrapHost, bootstrapFilename);
|
||||
InputStream in = new URL(bootstrapUrl).openStream();
|
||||
Files.copy(in, path, REPLACE_EXISTING);
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new DataException(String.format("Unable to download bootstrap: {}", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
public void importFromPath(Path path) throws InterruptedException, DataException, IOException {
|
||||
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
this.updateStatus("Extracting bootstrap...");
|
||||
Path input = path.toAbsolutePath();
|
||||
Path output = path.toAbsolutePath().getParent().toAbsolutePath();
|
||||
SevenZ.decompress(input.toString(), output.toFile());
|
||||
|
||||
this.updateStatus("Stopping repository...");
|
||||
// Close the repository while we are still able to
|
||||
// Otherwise, the caller will run into difficulties when it tries to close it
|
||||
repository.discardChanges();
|
||||
repository.close();
|
||||
// Now close the repository factory so that we can swap out the database files
|
||||
RepositoryManager.closeRepositoryFactory();
|
||||
|
||||
Path inputPath = Paths.get(output.toString(), "bootstrap");
|
||||
Path outputPath = Paths.get(Settings.getInstance().getRepositoryPath());
|
||||
if (!inputPath.toFile().exists()) {
|
||||
throw new DataException("Extracted bootstrap doesn't exist");
|
||||
}
|
||||
|
||||
// Move the "bootstrap" folder in place of the "db" folder
|
||||
this.updateStatus("Moving files to output directory...");
|
||||
FileUtils.deleteDirectory(outputPath.toFile());
|
||||
Files.move(inputPath, outputPath);
|
||||
|
||||
this.updateStatus("Starting repository from bootstrap...");
|
||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||
|
||||
}
|
||||
finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(String text) {
|
||||
LOGGER.info(text);
|
||||
SplashFrame.getInstance().updateStatus(text);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user