mirror of
https://github.com/Qortal/qortal.git
synced 2025-11-03 14:57:03 +00:00
Compare commits
378 Commits
v1.3.0
...
networking
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ff9e49e8 | ||
|
|
d52875aa8f | ||
|
|
9027cd290c | ||
|
|
58a7203ede | ||
|
|
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 | ||
|
|
5fc495eb6a | ||
|
|
847e81e95c | ||
|
|
7918622e2e | ||
|
|
427fa1816d | ||
|
|
0c7e388463 | ||
|
|
be3af53011 | ||
|
|
414399b2a0 | ||
|
|
c592051a80 | ||
|
|
33a8f311e5 | ||
|
|
018c3cdcd4 | ||
|
|
384dffbf9a | ||
|
|
0306ecb03d | ||
|
|
e5ce732557 | ||
|
|
f19e0498bf | ||
|
|
32ec02225a | ||
|
|
3920933fc7 | ||
|
|
1fdd7f156c | ||
|
|
91925cf931 | ||
|
|
30e58f1c19 | ||
|
|
8d5c6db39f | ||
|
|
3453f0efaf | ||
|
|
eb23940996 | ||
|
|
6cd86d86a6 | ||
|
|
c3fa34f5b9 | ||
|
|
0af0aaaa21 | ||
|
|
02100c502b | ||
|
|
b55154cd3c | ||
|
|
1e6e5e66da | ||
|
|
9b0e88ca87 | ||
|
|
3acc0babb7 | ||
|
|
dc6eda1355 | ||
|
|
6224bc3bca | ||
|
|
9ceac8c991 | ||
|
|
834fcd80d7 | ||
|
|
20e4a79130 | ||
|
|
d336200d75 | ||
|
|
e5bb3e2f0a | ||
|
|
5b2b2bab46 | ||
|
|
c17eea3ed9 | ||
|
|
83f4e2f5bf | ||
|
|
c8e7a00c08 | ||
|
|
190014cf96 | ||
|
|
385064e324 | ||
|
|
f3e1f088f8 | ||
|
|
6eb9447bb9 | ||
|
|
0ee8d7da0f | ||
|
|
918a331609 | ||
|
|
9bc395d36f | ||
|
|
41453f5bd1 | ||
|
|
78f62751e5 | ||
|
|
d59c30757c | ||
|
|
30d2e4fdac | ||
|
|
d43a074cc1 | ||
|
|
27783dc6de | ||
|
|
2a789a9a9b | ||
|
|
a66dba767e | ||
|
|
e00579e1a2 | ||
|
|
99f1a55de2 | ||
|
|
3ec307a2a1 | ||
|
|
3fdef9ea6d | ||
|
|
332c917c94 | ||
|
|
35b0ac78b8 | ||
|
|
047627a6e5 | ||
|
|
e4e775a107 | ||
|
|
5d6811bd50 | ||
|
|
688f215dfd | ||
|
|
7cbdbbcc8d | ||
|
|
4e89b8fbac | ||
|
|
70ec8cb11f | ||
|
|
0f0266609f | ||
|
|
ecfa6e994e | ||
|
|
ed4a45f214 | ||
|
|
e953be6e4a | ||
|
|
bd51806a0d | ||
|
|
625dbfbbd7 | ||
|
|
1c6ea0a860 | ||
|
|
3706cd5ff7 | ||
|
|
934cd1d511 | ||
|
|
68e3d3b989 | ||
|
|
31fa916156 | ||
|
|
456bb3ca63 | ||
|
|
b07ad094c1 | ||
|
|
d766cfaa67 | ||
|
|
acc616c204 | ||
|
|
8707f154ee | ||
|
|
992427f0e0 | ||
|
|
2c84add935 | ||
|
|
e8fc91fd34 | ||
|
|
8c9cf4a02d | ||
|
|
23f0969b2d | ||
|
|
cf82813280 | ||
|
|
753fa4dfa9 | ||
|
|
58ff338ab3 | ||
|
|
064e12a57b | ||
|
|
54bb8ed817 | ||
|
|
b651eae258 | ||
|
|
7562d9bbf8 | ||
|
|
1b50dd5adf | ||
|
|
c10a5db280 | ||
|
|
500690be49 | ||
|
|
778ac35ee6 | ||
|
|
c16a664a78 | ||
|
|
75a265f89a | ||
|
|
ddb55210b4 | ||
|
|
e093520696 | ||
|
|
cfacddcb36 | ||
|
|
d2dea3ff35 | ||
|
|
865fcb95bf | ||
|
|
a52c089728 | ||
|
|
15d25649b2 | ||
|
|
e0210635e3 | ||
|
|
90b993e234 | ||
|
|
9b7c2c50fb | ||
|
|
fc7a7a1549 | ||
|
|
a12045c19e | ||
|
|
fccb3a3f0c | ||
|
|
62ae49b639 | ||
|
|
2e8f58bb2f | ||
|
|
9e98ce220f | ||
|
|
10c3a0c056 | ||
|
|
69ec654e4a | ||
|
|
a310e751bb | ||
|
|
3ef8b81e51 | ||
|
|
1f409235e4 | ||
|
|
806baa6ae4 | ||
|
|
58ed72058f | ||
|
|
253a994438 | ||
|
|
5549eded38 | ||
|
|
20777363cf | ||
|
|
b3f859f290 | ||
|
|
8c9f68a9c3 | ||
|
|
41f178bf59 | ||
|
|
ad5050f92e | ||
|
|
16397852ae | ||
|
|
c125a53655 | ||
|
|
7b056a832f | ||
|
|
6c40727027 | ||
|
|
8f06765caf | ||
|
|
de2fc78ad1 | ||
|
|
ee08410260 | ||
|
|
88da8d949f | ||
|
|
d2a92db921 | ||
|
|
9c18a33d7f | ||
|
|
f3b8258067 | ||
|
|
da78c73485 | ||
|
|
cec25ce279 | ||
|
|
0389007491 | ||
|
|
38a64bdd9e | ||
|
|
6a24f787c4 | ||
|
|
98564aa8bf | ||
|
|
9ceff90f42 | ||
|
|
6a4388fecc | ||
|
|
1958444bc4 | ||
|
|
a2038274e1 | ||
|
|
532c697026 | ||
|
|
5cf5c1e1f7 | ||
|
|
60621e8b81 | ||
|
|
a6a1f65d3e | ||
|
|
a681f741dd | ||
|
|
bed9837967 | ||
|
|
855cb2226a | ||
|
|
d85a3d17c8 | ||
|
|
81a5b154c2 | ||
|
|
a6f42df9d6 | ||
|
|
17ae7acc6d | ||
|
|
3d5fec3c30 | ||
|
|
21f48fba5f | ||
|
|
d0da5d7c48 | ||
|
|
4209cc6ee4 | ||
|
|
f3e1092dd5 | ||
|
|
43055b666f | ||
|
|
7cd8ed6e23 | ||
|
|
4bc0edeeca | ||
|
|
7a06df6ccd | ||
|
|
d9164a32e5 | ||
|
|
a8fbf32a88 | ||
|
|
514689d2f4 | ||
|
|
76a15bb026 | ||
|
|
b061f188f9 | ||
|
|
af7d7d0966 | ||
|
|
2ffd0770c6 | ||
|
|
e3abeafc6b | ||
|
|
1720582f33 | ||
|
|
d93e9d570f | ||
|
|
5ea90f2fdd | ||
|
|
c628f97d8c | ||
|
|
8a1e2f4111 | ||
|
|
41f244d549 | ||
|
|
79641efa87 | ||
|
|
ca3fcc3c67 | ||
|
|
de8e5ec920 | ||
|
|
f833e44bd5 | ||
|
|
8b0b1db5a4 | ||
|
|
5b95f3af02 | ||
|
|
3cc66609e8 | ||
|
|
ce468d22dd | ||
|
|
3e19516f62 | ||
|
|
84dba739d9 | ||
|
|
99315c7378 | ||
|
|
1ca5b864a9 | ||
|
|
96eb60dca3 | ||
|
|
c67fcb0034 | ||
|
|
273dfe2365 | ||
|
|
5952ea4b54 | ||
|
|
1708ba077c | ||
|
|
b4301f125d | ||
|
|
9e52f20f71 | ||
|
|
31bf388cab | ||
|
|
276c479a5f | ||
|
|
9393689037 | ||
|
|
76485010ad | ||
|
|
b8ac128d5c | ||
|
|
06c75310a1 | ||
|
|
b9d819220d | ||
|
|
7a569f342f | ||
|
|
f1efae79c8 | ||
|
|
1cd4bbc078 | ||
|
|
0b5e5832c4 | ||
|
|
7db96c672f | ||
|
|
f8725d6313 | ||
|
|
2165c87b9d | ||
|
|
f61e320230 | ||
|
|
6c1b21da22 | ||
|
|
f6216b9745 | ||
|
|
91e82d1e3c | ||
|
|
50e2bda020 | ||
|
|
ab1de1aafa | ||
|
|
d4ac87f91d | ||
|
|
52f4008725 | ||
|
|
d8dd71ff50 | ||
|
|
02966bf39a | ||
|
|
a83d8bf1d5 | ||
|
|
1e4432b1f3 | ||
|
|
d50c979d9f | ||
|
|
4e60ec5192 | ||
|
|
31c4e3b1be | ||
|
|
b97fbd3171 | ||
|
|
43fb5d9332 | ||
|
|
ea3f1a8eff |
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
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -16,3 +16,11 @@
|
||||
/settings*.json
|
||||
/testchain.json
|
||||
/run-testnet.sh
|
||||
/.idea
|
||||
/qortal.iml
|
||||
.DS_Store
|
||||
/src/main/resources/resources
|
||||
/src/main/resources/log*.properties
|
||||
/*.jar
|
||||
/run.pid
|
||||
/run.log
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Auto Updates
|
||||
|
||||
## TL;DR: how-to
|
||||
|
||||
* Prepare new release version (see way below for details)
|
||||
* Assuming you are in git 'master' branch, at HEAD
|
||||
* Shutdown local node if running
|
||||
* Build auto-update download: `tools/build-auto-update.sh` - uploads auto-update file into new git branch
|
||||
* Restart local node
|
||||
* Publish auto-update transaction using *private key* for **non-admin** member of "dev" group:
|
||||
`tools/publish-auto-update.sh non-admin-dev-member-private-key-in-base58`
|
||||
* Wait for auto-update `ARBITRARY` transaction to be confirmed into a block
|
||||
* Have "dev" group admins 'approve' auto-update using `tools/approve-auto-update.sh`
|
||||
This tool will prompt for *private key* of **admin** of "dev" group
|
||||
* A minimum number of admins are required for approval, and a minimum number of blocks must pass also.
|
||||
* Nodes will start to download, and apply, the update over the next 20 minutes or so (see CHECK_INTERVAL in AutoUpdate.java)
|
||||
|
||||
## Theory
|
||||
* Using a specific git commit (e.g. abcdef123) we produce a determinstic JAR with consistent hash.
|
||||
* To avoid issues with over-eager anti-virus / firewalls we obfuscate JAR using very simplistic XOR-based method.
|
||||
@@ -25,8 +40,8 @@ The same method is used to obfuscate and de-obfuscate:
|
||||
|
||||
## Typical download locations
|
||||
The git SHA1 commit hash is used to replace `%s` in various download locations, e.g.:
|
||||
* https://github.com/QORT/qortal/raw/%s/qortal.update
|
||||
* https://raw.githubusercontent.com@151.101.16.133/QORT/qortal/%s/qortal.update
|
||||
* https://github.com/Qortal/qortal/raw/%s/qortal.update
|
||||
* https://raw.githubusercontent.com@151.101.16.133/Qortal/qortal/%s/qortal.update
|
||||
|
||||
These locations are part of the org.qortal.settings.Settings class and can be overriden in settings.json like:
|
||||
```
|
||||
@@ -45,4 +60,12 @@ $ java -cp qortal.jar org.qortal.XorUpdate
|
||||
usage: XorUpdate <input-file> <output-file>
|
||||
$ java -cp qortal.jar org.qortal.XorUpdate qortal.jar qortal.update
|
||||
$
|
||||
```
|
||||
```
|
||||
|
||||
## Preparing new release version
|
||||
|
||||
* Shutdown local node
|
||||
* Modify `pom.xml` and increase version inside `<version>` tag
|
||||
* Commit new `pom.xml` and push to github, e.g. `git commit -m 'Bumped to v1.4.2' -- pom.xml; git push`
|
||||
* Tag this new commit with same version: `git tag v1.4.2`
|
||||
* Push tag up to github: `git push origin v1.4.2`
|
||||
|
||||
12
DATABASE.md
12
DATABASE.md
@@ -4,10 +4,10 @@ You can examine your node's database using [HSQLDB's "sqltool"](http://www.hsqld
|
||||
It's a good idea to install "rlwrap" (ReadLine wrapper) too as sqltool doesn't support command history/editing.
|
||||
|
||||
Typical command line for sqltool would be:
|
||||
`rlwrap java -cp ${HSQLDB_JAR}:${SQLTOOL_JAR} org.hsqldb.cmdline.SqlTool --rcFile=${SQLTOOL_RC} qora`
|
||||
`rlwrap java -cp ${HSQLDB_JAR}:${SQLTOOL_JAR} org.hsqldb.cmdline.SqlTool --rcFile=${SQLTOOL_RC} qortal`
|
||||
|
||||
`${HSQLDB_JAR}` should be set with pathname where Maven downloaded hsqldb,
|
||||
typically `${HOME}/.m2/repository/org/hsqldb/hsqldb/2.5.0/hsqldb-2.5.0.jar`
|
||||
typically `${HOME}/.m2/repository/org/hsqldb/hsqldb/2.5.1/hsqldb-2.5.1.jar`
|
||||
|
||||
`${SQLTOOL_JAR}` should be set with pathname where Maven downloaded sqltool,
|
||||
typically `${HOME}/.m2/repository/org/hsqldb/sqltool/2.5.0/sqltool-2.5.0.jar`
|
||||
@@ -25,10 +25,16 @@ Above `url` component `file:db/blockchain` assumes you will call `sqltool` from
|
||||
|
||||
Another idea is to assign a shell alias in your `.bashrc` like:
|
||||
```
|
||||
export HSQLDB_JAR=${HOME}/.m2/repository/org/hsqldb/hsqldb/2.5.0/hsqldb-2.5.0.jar
|
||||
export HSQLDB_JAR=${HOME}/.m2/repository/org/hsqldb/hsqldb/2.5.1/hsqldb-2.5.1.jar
|
||||
export SQLTOOL_JAR=${HOME}/.m2/repository/org/hsqldb/sqltool/2.5.0/sqltool-2.5.0.jar
|
||||
alias sqltool='rlwrap java -cp ${HSQLDB_JAR}:${SQLTOOL_JAR} org.hsqldb.cmdline.SqlTool --rcFile=${SQLTOOL_RC}'
|
||||
```
|
||||
So you can simply type: `sqltool qortal`
|
||||
|
||||
Don't forget to use `SHUTDOWN;` before exiting sqltool so that database files are closed cleanly.
|
||||
|
||||
## Quick and dirty version
|
||||
|
||||
With `sqltool-2.5.0.jar` and `qortal.jar` in current directory, and database in `db/`
|
||||
|
||||
`java -cp qortal.jar:sqltool-2.5.0.jar org.hsqldb.cmdline.SqlTool --inlineRc=url=jdbc:hsqldb:file:db/blockchain`
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
- Create basic *settings.json* file: `echo '{}' > settings.json`
|
||||
- Run JAR in same working directory as *settings.json*: `java -jar target/qortal-1.0.jar`
|
||||
- Wrap in shell script, add JVM flags, redirection, backgrounding, etc. as necessary.
|
||||
- Or use supplied example shell script: *run.sh*
|
||||
- Or use supplied example shell script: *start.sh*
|
||||
|
||||
69
TestNets.md
Normal file
69
TestNets.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# How to build a testnet
|
||||
|
||||
## Create testnet blockchain config
|
||||
|
||||
- You can begin by copying the mainnet blockchain config `src/main/resources/blockchain.json`
|
||||
- Insert `"isTestChain": true,` after the opening `{`
|
||||
- Modify testnet genesis block
|
||||
|
||||
### Testnet genesis block
|
||||
|
||||
- Set `timestamp` to a nearby future value, e.g. 15 mins from 'now'
|
||||
This is to give yourself enough time to set up other testnet nodes
|
||||
- Retain the initial `ISSUE_ASSET` transactions!
|
||||
- Add `ACCOUNT_FLAGS` transactions with `"andMask": -1, "orMask": 1, "xorMask": 0` to create founders
|
||||
- Add at least one `REWARD_SHARE` transaction otherwise no-one can mint initial blocks!
|
||||
You will need to calculate `rewardSharePublicKey` (and private key),
|
||||
or make a new account on mainnet and use self-share key values
|
||||
- Add `ACCOUNT_LEVEL` transactions to set initial level of accounts as needed
|
||||
- Add `GENESIS` transactions to add QORT/LEGACY_QORA funds to accounts as needed
|
||||
|
||||
## Testnet `settings.json`
|
||||
|
||||
- Create a new `settings-test.json`
|
||||
- Make sure to add `"isTestNet": true,`
|
||||
- Make sure to reference testnet blockchain config file: `"blockchainConfig": "testchain.json",`
|
||||
- It is a good idea to use a separate database: `"repositoryPath": "db-testnet",`
|
||||
- You might also need to add `"bitcoinNet": "TEST3",` and `"litecoinNet": "TEST3",`
|
||||
|
||||
## Other nodes
|
||||
|
||||
- Copy `testchain.json` and `settings-test.json` to other nodes
|
||||
- Alternatively, you can run multiple nodes on the same machine by:
|
||||
* Copying `settings-test.json` to `settings-test-1.json`
|
||||
* Configure different `repositoryPath`
|
||||
* Configure use of different ports:
|
||||
+ `"apiPort": 22391,`
|
||||
+ `"listenPort": 22392,`
|
||||
|
||||
## Starting-up
|
||||
|
||||
- Start up at least as many nodes as `minBlockchainPeers` (or adjust this value instead)
|
||||
- Probably best to perform API call `DELETE /peers/known`
|
||||
- Add other nodes via API call `POST /peers <peer-hostname-or-IP>`
|
||||
- Add minting private key to node(s) via API call `POST /admin/mintingaccounts <minting-private-key>`
|
||||
This key must have corresponding `REWARD_SHARE` transaction in testnet genesis block
|
||||
- Wait for genesis block timestamp to pass
|
||||
- A node should mint block 2 approximately 60 seconds after genesis block timestamp
|
||||
- Other testnet nodes will sync *as long as there is at least `minBlockchainPeers` peers with an "up-to-date" chain`
|
||||
- You can also use API call `POST /admin/forcesync <connected-peer-IP-and-port>` on stuck nodes
|
||||
|
||||
## Dealing with stuck chain
|
||||
|
||||
Maybe your nodes have been offline and no-one has minted a recent testnet block.
|
||||
Your options are:
|
||||
|
||||
- Start a new testnet from scratch
|
||||
- Fire up your testnet node(s)
|
||||
- Force one of your nodes to mint by:
|
||||
+ Set a debugger breakpoint on Settings.getMinBlockchainPeers()
|
||||
+ When breakpoint is hit, change `this.minBlockchainPeers` to zero, then continue
|
||||
- Once one of your nodes has minted blocks up to 'now', you can use "forcesync" on the other nodes
|
||||
|
||||
## Tools
|
||||
|
||||
- `qort` tool, but use `-t` option for default testnet API port (62391)
|
||||
- `qort` tool, but first set shell variable: `export BASE_URL=some-node-hostname-or-ip:port`
|
||||
- `qort` tool, but prepend with one-time shell variable: `BASE_URL=some-node-hostname-or-ip:port qort ......`
|
||||
- `peer-heights`, but use `-t` option, or `BASE_URL` shell variable as above
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
20
WindowsInstaller/dictionary.ail
Executable file → Normal file
20
WindowsInstaller/dictionary.ail
Executable file → Normal file
@@ -3,95 +3,115 @@
|
||||
<!-- Control table -->
|
||||
<ENTRY id="Control.Text.CustomizeDataPathDlg#Description">
|
||||
<STRING lang="en" value="Do you want to store the blockchain, and other data, in a specific folder?"/>
|
||||
<STRING lang="ru" value="Вы можете выбрать место хранения блокчейна и других данных."/>
|
||||
<STRING lang="zh" value="你想把区块链数据存放在一个特定的文件夹吗?"/>
|
||||
<STRING lang="zh_TW" value="你想把区块链数据存放在一个特定的文件夹吗?"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.CustomizeDataPathDlg#Text">
|
||||
<STRING lang="en" value="Select one of the options below, then click "Next"."/>
|
||||
<STRING lang="ru" value="Выберите один из вариантов ниже, затем нажмите Далее"/>
|
||||
<STRING lang="zh" value="请选择,然后“下一步”"/>
|
||||
<STRING lang="zh_TW" value="请选择,然后“下一步”"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.CustomizeDataPathDlg#Title">
|
||||
<STRING lang="en" value="Choose Custom Data Storage Folder?"/>
|
||||
<STRING lang="ru" value="Выберите место хранения данных."/>
|
||||
<STRING lang="zh" value="选择数据保存的文件夹?"/>
|
||||
<STRING lang="zh_TW" value="选择数据保存的文件夹?"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.CustomizeDbDlg#Description">
|
||||
<STRING lang="en" value="Do you want to store the blockchain, and other data, in a specific folder?"/>
|
||||
<STRING lang="ru" value="Вы можете выбрать место хранения блокчейна и других данных."/>
|
||||
<STRING lang="zh" value="Do you want to store the blockchain, and other data, in a specific folder?"/>
|
||||
<STRING lang="zh_TW" value="Do you want to store the blockchain, and other data, in a specific folder?"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.CustomizeDbDlg#Title">
|
||||
<STRING lang="en" value="Choose Custom Data Storage Folder?"/>
|
||||
<STRING lang="ru" value="Выберите место хранения данных."/>
|
||||
<STRING lang="zh" value="Choose Custom Data Storage Folder?"/>
|
||||
<STRING lang="zh_TW" value="Choose Custom Data Storage Folder?"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.DataFolderDlg#Description">
|
||||
<STRING lang="en" value="This is the folder where the blockchain, and other data, will be stored."/>
|
||||
<STRING lang="ru" value="Это папка, в которой будет храниться блокчейн и другие данные."/>
|
||||
<STRING lang="zh" value="这里是区块链及其它数据存放的文件夹"/>
|
||||
<STRING lang="zh_TW" value="这里是区块链及其它数据存放的文件夹"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.DataFolderDlg#Text">
|
||||
<STRING lang="en" value="To store data in this folder, click "[Text_Next]". To store data in a different folder, enter it below or click "Browse"."/>
|
||||
<STRING lang="ru" value="Чтобы сохранить данные в этой папке, нажмите "[Text_Next]". Чтобы сохранить данные в другой папке, введите ее ниже или нажмите "Обзор"."/>
|
||||
<STRING lang="zh" value="如果存放在这个文件夹,点 “下一步”。如果存放在其它位置,请选择“浏览”。"/>
|
||||
<STRING lang="zh_TW" value="如果存放在这个文件夹,点 “下一步”。如果存放在其它位置,请选择“浏览”。"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.DataFolderDlg#Title">
|
||||
<STRING lang="en" value="Select Data Storage Folder"/>
|
||||
<STRING lang="ru" value="Выберите папку для хранения данных"/>
|
||||
<STRING lang="zh" value="请选择文件存储地方"/>
|
||||
<STRING lang="zh_TW" value="请选择文件存储地方"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.DbFolderDlg#Description">
|
||||
<STRING lang="en" value="This is the folder where the blockchain, and other data, will be stored."/>
|
||||
<STRING lang="ru" value="Это папка, в которой будет храниться блокчейн и другие данные."/>
|
||||
<STRING lang="zh" value="This is the folder where the blockchain, and other data, will be stored."/>
|
||||
<STRING lang="zh_TW" value="This is the folder where the blockchain, and other data, will be stored."/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.DbFolderDlg#Title">
|
||||
<STRING lang="en" value="Select Data Storage Folder"/>
|
||||
<STRING lang="ru" value="Выберите папку для хранения данных"/>
|
||||
<STRING lang="zh" value="请选择文件存储地方"/>
|
||||
<STRING lang="zh_TW" value="请选择文件存储地方"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.NTPDialog#Description">
|
||||
<STRING lang="en" value="Reconfigure Windows for more accurate time?"/>
|
||||
<STRING lang="ru" value="Настроить синхронизацию времени системы Windows?"/>
|
||||
<STRING lang="zh" value="重新配置Windows以获得更准确的时间?"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.NTPDialog#Text_1">
|
||||
<STRING lang="en" value="An accurate Windows clock is required to connect to the [ProductName] network and make transactions."/>
|
||||
<STRING lang="ru" value="Для подключения к сети Qortal и совершения транзакций требуется точная настройка времени Windows"/>
|
||||
<STRING lang="zh" value="需要准确的Windows时钟才能连接到[ProductName]网络并进行交易。"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.NTPDialog#Text_2">
|
||||
<STRING lang="en" value="Select one of the options below, then click "Next"."/>
|
||||
<STRING lang="ru" value="Выберите один из вариантов ниже, затем нажмите"/>
|
||||
<STRING lang="zh" value="请选择,然后“下一步”"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.NTPDialog#Text_3">
|
||||
<STRING lang="en" value="Your computer's clock needs to be accurate to within 0.5 seconds."/>
|
||||
<STRING lang="ru" value="Точность времени вашего компьютера должна составлять 0.5 секунд."/>
|
||||
<STRING lang="zh" value="您的计算机时钟需要准确到0.5秒内。"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.NTPDialog#Title">
|
||||
<STRING lang="en" value="Windows clock accuracy"/>
|
||||
<STRING lang="ru" value="Настройка времени системы Windows"/>
|
||||
<STRING lang="zh" value="Windows 时钟精度"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="Control.Text.VerifyRemoveDlg#RemoveBlockchainCheckbox">
|
||||
<STRING lang="en" value="Remove downloaded blockchain and other data"/>
|
||||
<STRING lang="ru" value="Удалить загруженный блокчейн и другие данные"/>
|
||||
<STRING lang="zh" value="删除您下载的区块链"/>
|
||||
</ENTRY>
|
||||
<!-- RadioButton table -->
|
||||
<ENTRY id="RadioButton.Text.CUSTOM_DB_BOOL#choose">
|
||||
<STRING lang="en" value="Choose custom data storage folder..."/>
|
||||
<STRING lang="ru" value="Выбрать папку для хранения данных..."/>
|
||||
<STRING lang="zh" value="选择特定的文件夹存储"/>
|
||||
<STRING lang="zh_TW" value="选择特定的文件夹存储"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="RadioButton.Text.CUSTOM_DB_BOOL#default">
|
||||
<STRING lang="en" value="Use default location "/>
|
||||
<STRING lang="ru" value="Использовать папку по умолчанию"/>
|
||||
<STRING lang="zh" value="使用默认存储地点"/>
|
||||
<STRING lang="zh_TW" value="使用默认存储地点"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="RadioButton.Text.RECONFIG_NTP#1">
|
||||
<STRING lang="en" value="Yes, configure Windows to use internet time servers (Recommended)"/>
|
||||
<STRING lang="ru" value="Да, настроить синхронизацию времени Windows (Рекомендуется)"/>
|
||||
<STRING lang="zh" value="是,将Windows配置为使用多个Internet时间服务器 (推荐的)"/>
|
||||
</ENTRY>
|
||||
<ENTRY id="RadioButton.Text.RECONFIG_NTP#2">
|
||||
<STRING lang="en" value="No, I will manage clock accuracy myself"/>
|
||||
<STRING lang="ru" value="Нет, я сам буду управлять настройками часов"/>
|
||||
<STRING lang="zh" value="不,我会自己管理时钟精度。"/>
|
||||
</ENTRY>
|
||||
</DICTIONARY>
|
||||
|
||||
Binary file not shown.
@@ -4,6 +4,6 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.ciyam</groupId>
|
||||
<artifactId>AT</artifactId>
|
||||
<version>1.3.5</version>
|
||||
<version>1.3.7</version>
|
||||
<description>POM was created from install:install-file</description>
|
||||
</project>
|
||||
BIN
lib/org/ciyam/AT/1.3.8/AT-1.3.8.jar
Normal file
BIN
lib/org/ciyam/AT/1.3.8/AT-1.3.8.jar
Normal file
Binary file not shown.
9
lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom
Normal file
9
lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.ciyam</groupId>
|
||||
<artifactId>AT</artifactId>
|
||||
<version>1.3.8</version>
|
||||
<description>POM was created from install:install-file</description>
|
||||
</project>
|
||||
@@ -3,11 +3,14 @@
|
||||
<groupId>org.ciyam</groupId>
|
||||
<artifactId>AT</artifactId>
|
||||
<versioning>
|
||||
<release>1.3.5</release>
|
||||
<release>1.3.8</release>
|
||||
<versions>
|
||||
<version>1.3.4</version>
|
||||
<version>1.3.5</version>
|
||||
<version>1.3.6</version>
|
||||
<version>1.3.7</version>
|
||||
<version>1.3.8</version>
|
||||
</versions>
|
||||
<lastUpdated>20200717104214</lastUpdated>
|
||||
<lastUpdated>20200925114415</lastUpdated>
|
||||
</versioning>
|
||||
</metadata>
|
||||
|
||||
45
pom.xml
45
pom.xml
@@ -3,19 +3,20 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.qortal</groupId>
|
||||
<artifactId>qortal</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<version>1.5.2</version>
|
||||
<packaging>jar</packaging>
|
||||
<properties>
|
||||
<bitcoinj.version>0.15.5</bitcoinj.version>
|
||||
<skipTests>true</skipTests>
|
||||
<altcoinj.version>bf9fb80</altcoinj.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.5</ciyam-at.version>
|
||||
<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>
|
||||
<dagger.version>1.2.2</dagger.version>
|
||||
<guava.version>28.1-jre</guava.version>
|
||||
<hsqldb.version>2.5.0-fixed</hsqldb.version>
|
||||
<hsqldb-sqltool.version>2.5.0</hsqldb-sqltool.version>
|
||||
<hsqldb.version>2.5.1</hsqldb.version>
|
||||
<jersey.version>2.29.1</jersey.version>
|
||||
<jetty.version>9.4.29.v20200521</jetty.version>
|
||||
<log4j.version>2.12.1</log4j.version>
|
||||
@@ -199,6 +200,10 @@
|
||||
<pattern>org.qortal.api.model**</pattern>
|
||||
<template>${project.build.sourceDirectory}/org/qortal/data/package-info.java</template>
|
||||
</package>
|
||||
<package>
|
||||
<pattern>org.qortal.api.model.**</pattern>
|
||||
<template>${project.build.sourceDirectory}/org/qortal/data/package-info.java</template>
|
||||
</package>
|
||||
</packages>
|
||||
<outputDirectory>${project.build.directory}/generated-sources/package-info</outputDirectory>
|
||||
</configuration>
|
||||
@@ -313,6 +318,14 @@
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.22.2</version>
|
||||
<configuration>
|
||||
<skipTests>${skipTests}</skipTests>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
@@ -375,6 +388,11 @@
|
||||
<name>project</name>
|
||||
<url>file:${project.basedir}/lib</url>
|
||||
</repository>
|
||||
<!-- jitpack for build-on-demand of altcoinj -->
|
||||
<repository>
|
||||
<id>jitpack.io</id>
|
||||
<url>https://jitpack.io</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
<dependencies>
|
||||
<!-- https://mvnrepository.com/artifact/org.codehaus.mojo/build-helper-maven-plugin -->
|
||||
@@ -397,12 +415,6 @@
|
||||
<artifactId>hsqldb</artifactId>
|
||||
<version>${hsqldb.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hsqldb</groupId>
|
||||
<artifactId>sqltool</artifactId>
|
||||
<version>${hsqldb-sqltool.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- CIYAM AT (automated transactions) -->
|
||||
<dependency>
|
||||
<groupId>org.ciyam</groupId>
|
||||
@@ -415,12 +427,23 @@
|
||||
<artifactId>bitcoinj-core</artifactId>
|
||||
<version>${bitcoinj.version}</version>
|
||||
</dependency>
|
||||
<!-- For Litecoin, etc. support, requires bitcoinj -->
|
||||
<dependency>
|
||||
<groupId>com.github.jjos2372</groupId>
|
||||
<artifactId>altcoinj</artifactId>
|
||||
<version>${altcoinj.version}</version>
|
||||
</dependency>
|
||||
<!-- Utilities -->
|
||||
<dependency>
|
||||
<groupId>com.googlecode.json-simple</groupId>
|
||||
<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>
|
||||
|
||||
51
run.sh
51
run.sh
@@ -1,51 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# There's no need to run as root, so don't allow it, for security reasons
|
||||
if [ "$USER" = "root" ]; then
|
||||
echo "Please su to non-root user before running"
|
||||
exit
|
||||
fi
|
||||
|
||||
# Validate Java is installed and the minimum version is available
|
||||
MIN_JAVA_VER='11'
|
||||
|
||||
if command -v java > /dev/null 2>&1; then
|
||||
version=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}')
|
||||
version=$(echo $version | cut -d'.' -f1,2)
|
||||
if [ `echo "${version}>=${MIN_JAVA_VER}" | bc` -eq 1 ]; then
|
||||
echo 'Passed Java version check'
|
||||
else
|
||||
echo 'Please upgrade your Java to version 11 or greater'
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo 'Java is not available, please install Java 11 or greater'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No qortal.jar but we have a Maven built one?
|
||||
# Be helpful and copy across to correct location
|
||||
if [ ! -e qortal.jar -a -f target/qortal*.jar ]; then
|
||||
echo "Copying Maven-built Qortal JAR to correct pathname"
|
||||
cp target/qortal*.jar qortal.jar
|
||||
fi
|
||||
|
||||
# Limits Java JVM stack size and maximum heap usage.
|
||||
# Comment out for bigger systems, e.g. non-routers
|
||||
# or when API documentation is enabled
|
||||
# JVM_MEMORY_ARGS="-Xss256k -Xmx128m"
|
||||
|
||||
# Although java.net.preferIPv4Stack is supposed to be false
|
||||
# by default in Java 11, on some platforms (e.g. FreeBSD 12),
|
||||
# it is overriden to be true by default. Hence we explicitly
|
||||
# set it to true to obtain desired behaviour.
|
||||
nohup nice -n 20 java \
|
||||
-Djava.net.preferIPv4Stack=false \
|
||||
-XX:NativeMemoryTracking=summary \
|
||||
${JVM_MEMORY_ARGS} \
|
||||
-jar qortal.jar \
|
||||
1>run.log 2>&1 &
|
||||
|
||||
# Save backgrounded process's PID
|
||||
echo $! > run.pid
|
||||
echo qortal running as pid $!
|
||||
@@ -21,18 +21,28 @@ public class HSQLDBPool extends JDBCPool {
|
||||
public Connection tryConnection() throws SQLException {
|
||||
for (int i = 0; i < states.length(); i++) {
|
||||
if (states.compareAndSet(i, RefState.available, RefState.allocated)) {
|
||||
return connections[i].getConnection();
|
||||
JDBCPooledConnection pooledConnection = connections[i];
|
||||
|
||||
if (pooledConnection == null)
|
||||
// Probably shutdown situation
|
||||
return null;
|
||||
|
||||
return pooledConnection.getConnection();
|
||||
}
|
||||
|
||||
if (states.compareAndSet(i, RefState.empty, RefState.allocated)) {
|
||||
try {
|
||||
JDBCPooledConnection connection = (JDBCPooledConnection) source.getPooledConnection();
|
||||
JDBCPooledConnection pooledConnection = (JDBCPooledConnection) source.getPooledConnection();
|
||||
|
||||
connection.addConnectionEventListener(this);
|
||||
connection.addStatementEventListener(this);
|
||||
connections[i] = connection;
|
||||
if (pooledConnection == null)
|
||||
// Probably shutdown situation
|
||||
return null;
|
||||
|
||||
return connections[i].getConnection();
|
||||
pooledConnection.addConnectionEventListener(this);
|
||||
pooledConnection.addStatementEventListener(this);
|
||||
connections[i] = pooledConnection;
|
||||
|
||||
return pooledConnection.getConnection();
|
||||
} catch (SQLException e) {
|
||||
states.set(i, RefState.empty);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ public class ApplyUpdate {
|
||||
private static final String JAR_FILENAME = AutoUpdate.JAR_FILENAME;
|
||||
private static final String NEW_JAR_FILENAME = AutoUpdate.NEW_JAR_FILENAME;
|
||||
private static final String WINDOWS_EXE_LAUNCHER = "qortal.exe";
|
||||
private static final String JAVA_TOOL_OPTIONS_NAME = "JAVA_TOOL_OPTIONS";
|
||||
private static final String JAVA_TOOL_OPTIONS_VALUE = "-XX:MaxRAMFraction=4";
|
||||
|
||||
private static final long CHECK_INTERVAL = 10 * 1000L; // ms
|
||||
private static final int MAX_ATTEMPTS = 12;
|
||||
@@ -65,17 +67,19 @@ public class ApplyUpdate {
|
||||
}
|
||||
|
||||
private static boolean shutdownNode() {
|
||||
String BASE_URI = "http://localhost:" + Settings.getInstance().getApiPort() + "/";
|
||||
LOGGER.info(String.format("Shutting down node using API via %s", BASE_URI));
|
||||
String baseUri = "http://localhost:" + Settings.getInstance().getApiPort() + "/";
|
||||
LOGGER.info(() -> String.format("Shutting down node using API via %s", baseUri));
|
||||
|
||||
int attempt;
|
||||
for (attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) {
|
||||
LOGGER.info(String.format("Attempt #%d out of %d to shutdown node", attempt + 1, MAX_ATTEMPTS));
|
||||
String response = ApiRequest.perform(BASE_URI + "admin/stop", null);
|
||||
final int attemptForLogging = attempt;
|
||||
LOGGER.info(() -> String.format("Attempt #%d out of %d to shutdown node", attemptForLogging + 1, MAX_ATTEMPTS));
|
||||
String response = ApiRequest.perform(baseUri + "admin/stop", null);
|
||||
if (response == null)
|
||||
break;
|
||||
// No response - consider node shut down
|
||||
return true;
|
||||
|
||||
LOGGER.info(String.format("Response from API: %s", response));
|
||||
LOGGER.info(() -> String.format("Response from API: %s", response));
|
||||
|
||||
try {
|
||||
Thread.sleep(CHECK_INTERVAL);
|
||||
@@ -99,19 +103,20 @@ public class ApplyUpdate {
|
||||
Path newJar = Paths.get(NEW_JAR_FILENAME);
|
||||
|
||||
if (!Files.exists(newJar)) {
|
||||
LOGGER.warn(String.format("Replacement JAR '%s' not found?", newJar));
|
||||
LOGGER.warn(() -> String.format("Replacement JAR '%s' not found?", newJar));
|
||||
return;
|
||||
}
|
||||
|
||||
int attempt;
|
||||
for (attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) {
|
||||
LOGGER.info(String.format("Attempt #%d out of %d to replace JAR", attempt + 1, MAX_ATTEMPTS));
|
||||
final int attemptForLogging = attempt;
|
||||
LOGGER.info(() -> String.format("Attempt #%d out of %d to replace JAR", attemptForLogging + 1, MAX_ATTEMPTS));
|
||||
|
||||
try {
|
||||
Files.copy(newJar, realJar, StandardCopyOption.REPLACE_EXISTING);
|
||||
break;
|
||||
} catch (IOException e) {
|
||||
LOGGER.info(String.format("Unable to replace JAR: %s", e.getMessage()));
|
||||
LOGGER.info(() -> String.format("Unable to replace JAR: %s", e.getMessage()));
|
||||
|
||||
// Try again
|
||||
}
|
||||
@@ -119,6 +124,7 @@ public class ApplyUpdate {
|
||||
try {
|
||||
Thread.sleep(CHECK_INTERVAL);
|
||||
} catch (InterruptedException e) {
|
||||
LOGGER.warn("Ignoring interrupt...");
|
||||
// Doggedly retry
|
||||
}
|
||||
}
|
||||
@@ -129,13 +135,13 @@ public class ApplyUpdate {
|
||||
|
||||
private static void restartNode(String[] args) {
|
||||
String javaHome = System.getProperty("java.home");
|
||||
LOGGER.info(String.format("Java home: %s", javaHome));
|
||||
LOGGER.info(() -> String.format("Java home: %s", javaHome));
|
||||
|
||||
Path javaBinary = Paths.get(javaHome, "bin", "java");
|
||||
LOGGER.info(String.format("Java binary: %s", javaBinary));
|
||||
LOGGER.info(() -> String.format("Java binary: %s", javaBinary));
|
||||
|
||||
Path exeLauncher = Paths.get(WINDOWS_EXE_LAUNCHER);
|
||||
LOGGER.info(String.format("Windows EXE launcher: %s", exeLauncher));
|
||||
LOGGER.info(() -> String.format("Windows EXE launcher: %s", exeLauncher));
|
||||
|
||||
List<String> javaCmd;
|
||||
if (Files.exists(exeLauncher)) {
|
||||
@@ -156,9 +162,16 @@ public class ApplyUpdate {
|
||||
}
|
||||
|
||||
try {
|
||||
LOGGER.info(String.format("Restarting node with: %s", String.join(" ", javaCmd)));
|
||||
LOGGER.info(() -> String.format("Restarting node with: %s", String.join(" ", javaCmd)));
|
||||
|
||||
new ProcessBuilder(javaCmd).start();
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(javaCmd);
|
||||
|
||||
if (Files.exists(exeLauncher)) {
|
||||
LOGGER.info(() -> String.format("Setting env %s to %s", JAVA_TOOL_OPTIONS_NAME, JAVA_TOOL_OPTIONS_VALUE));
|
||||
processBuilder.environment().put(JAVA_TOOL_OPTIONS_NAME, JAVA_TOOL_OPTIONS_VALUE);
|
||||
}
|
||||
|
||||
processBuilder.start();
|
||||
} catch (IOException e) {
|
||||
LOGGER.error(String.format("Failed to restart node (BAD): %s", e.getMessage()));
|
||||
}
|
||||
|
||||
75
src/main/java/org/qortal/RepositoryMaintenance.java
Normal file
75
src/main/java/org/qortal/RepositoryMaintenance.java
Normal file
@@ -0,0 +1,75 @@
|
||||
package org.qortal;
|
||||
|
||||
import java.security.Security;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryFactory;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
public class RepositoryMaintenance {
|
||||
|
||||
static {
|
||||
// This must go before any calls to LogManager/Logger
|
||||
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
|
||||
}
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(RepositoryMaintenance.class);
|
||||
|
||||
public static void main(String[] args) {
|
||||
LOGGER.info("Repository maintenance starting up...");
|
||||
|
||||
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
||||
Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
|
||||
|
||||
// Load/check settings, which potentially sets up blockchain config, etc.
|
||||
try {
|
||||
if (args.length > 0)
|
||||
Settings.fileInstance(args[0]);
|
||||
else
|
||||
Settings.getInstance();
|
||||
} catch (Throwable t) {
|
||||
LOGGER.error("Settings file error: " + t.getMessage());
|
||||
System.exit(2);
|
||||
}
|
||||
|
||||
LOGGER.info("Opening repository");
|
||||
try {
|
||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||
} catch (DataException e) {
|
||||
// If exception has no cause then repository is in use by some other process.
|
||||
if (e.getCause() == null) {
|
||||
LOGGER.info("Repository in use by another process?");
|
||||
} else {
|
||||
LOGGER.error("Unable to start repository", e);
|
||||
}
|
||||
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
LOGGER.info("Starting repository periodic maintenance. This can take a while...");
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
repository.performPeriodicMaintenance();
|
||||
|
||||
LOGGER.info("Repository periodic maintenance completed");
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Repository periodic maintenance failed", e);
|
||||
}
|
||||
|
||||
try {
|
||||
LOGGER.info("Shutting down repository");
|
||||
RepositoryManager.closeRepositoryFactory();
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Error occurred while shutting down repository", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public enum ApiError {
|
||||
// COMMON
|
||||
// UNKNOWN(0, 500),
|
||||
JSON(1, 400),
|
||||
// NO_BALANCE(2, 422),
|
||||
INSUFFICIENT_BALANCE(2, 402),
|
||||
// NOT_YET_RELEASED(3, 422),
|
||||
UNAUTHORIZED(4, 403),
|
||||
REPOSITORY_ISSUE(5, 500),
|
||||
@@ -126,10 +126,10 @@ public enum ApiError {
|
||||
// Groups
|
||||
GROUP_UNKNOWN(1101, 404),
|
||||
|
||||
// Bitcoin
|
||||
BTC_NETWORK_ISSUE(1201, 500),
|
||||
BTC_BALANCE_ISSUE(1202, 402),
|
||||
BTC_TOO_SOON(1203, 408);
|
||||
// Foreign blockchain
|
||||
FOREIGN_BLOCKCHAIN_NETWORK_ISSUE(1201, 500),
|
||||
FOREIGN_BLOCKCHAIN_BALANCE_ISSUE(1202, 402),
|
||||
FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408);
|
||||
|
||||
private static final Map<Integer, ApiError> map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError));
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import org.qortal.api.websocket.ActiveChatsWebSocket;
|
||||
import org.qortal.api.websocket.AdminStatusWebSocket;
|
||||
import org.qortal.api.websocket.BlocksWebSocket;
|
||||
import org.qortal.api.websocket.ChatMessagesWebSocket;
|
||||
import org.qortal.api.websocket.PresenceWebSocket;
|
||||
import org.qortal.api.websocket.TradeBotWebSocket;
|
||||
import org.qortal.api.websocket.TradeOffersWebSocket;
|
||||
import org.qortal.settings.Settings;
|
||||
@@ -200,6 +201,7 @@ public class ApiService {
|
||||
context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages");
|
||||
context.addServlet(TradeOffersWebSocket.class, "/websockets/crosschain/tradeoffers");
|
||||
context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot");
|
||||
context.addServlet(PresenceWebSocket.class, "/websockets/presence");
|
||||
|
||||
// Start server
|
||||
this.server.start();
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
|
||||
@@ -5,10 +5,20 @@ import java.net.UnknownHostException;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
public class Security {
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
public abstract class Security {
|
||||
|
||||
public static final String API_KEY_HEADER = "X-API-KEY";
|
||||
|
||||
// TODO: replace with proper authentication
|
||||
public static void checkApiCallAllowed(HttpServletRequest request) {
|
||||
String expectedApiKey = Settings.getInstance().getApiKey();
|
||||
String passedApiKey = request.getHeader(API_KEY_HEADER);
|
||||
|
||||
if ((expectedApiKey != null && !expectedApiKey.equals(passedApiKey)) ||
|
||||
(passedApiKey != null && !passedApiKey.equals(expectedApiKey)))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED);
|
||||
|
||||
InetAddress remoteAddr;
|
||||
try {
|
||||
remoteAddr = InetAddress.getByName(request.getRemoteAddr());
|
||||
@@ -19,4 +29,5 @@ public class Security {
|
||||
if (!remoteAddr.isLoopbackAddress())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -13,17 +14,61 @@ import org.qortal.transaction.Transaction.TransactionType;
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class ActivitySummary {
|
||||
|
||||
public int blockCount;
|
||||
public int transactionCount;
|
||||
public int assetsIssued;
|
||||
public int namesRegistered;
|
||||
private int blockCount;
|
||||
private int assetsIssued;
|
||||
private int namesRegistered;
|
||||
|
||||
// Assuming TransactionType values are contiguous so 'length' equals count
|
||||
@XmlJavaTypeAdapter(TransactionCountMapXmlAdapter.class)
|
||||
public Map<TransactionType, Integer> transactionCountByType = new EnumMap<>(TransactionType.class);
|
||||
private Map<TransactionType, Integer> transactionCountByType = new EnumMap<>(TransactionType.class);
|
||||
private int totalTransactionCount = 0;
|
||||
|
||||
public ActivitySummary() {
|
||||
// Needed for JAXB
|
||||
}
|
||||
|
||||
public int getBlockCount() {
|
||||
return this.blockCount;
|
||||
}
|
||||
|
||||
public void setBlockCount(int blockCount) {
|
||||
this.blockCount = blockCount;
|
||||
}
|
||||
|
||||
public int getTotalTransactionCount() {
|
||||
return this.totalTransactionCount;
|
||||
}
|
||||
|
||||
public int getAssetsIssued() {
|
||||
return this.assetsIssued;
|
||||
}
|
||||
|
||||
public void setAssetsIssued(int assetsIssued) {
|
||||
this.assetsIssued = assetsIssued;
|
||||
}
|
||||
|
||||
public int getNamesRegistered() {
|
||||
return this.namesRegistered;
|
||||
}
|
||||
|
||||
public void setNamesRegistered(int namesRegistered) {
|
||||
this.namesRegistered = namesRegistered;
|
||||
}
|
||||
|
||||
public Map<TransactionType, Integer> getTransactionCountByType() {
|
||||
return Collections.unmodifiableMap(this.transactionCountByType);
|
||||
}
|
||||
|
||||
public void setTransactionCountByType(TransactionType transactionType, int transactionCount) {
|
||||
this.transactionCountByType.put(transactionType, transactionCount);
|
||||
|
||||
this.totalTransactionCount = this.transactionCountByType.values().stream().mapToInt(Integer::intValue).sum();
|
||||
}
|
||||
|
||||
public void setTransactionCountByType(Map<TransactionType, Integer> transactionCountByType) {
|
||||
this.transactionCountByType = new EnumMap<>(transactionCountByType);
|
||||
|
||||
this.totalTransactionCount = this.transactionCountByType.values().stream().mapToInt(Integer::intValue).sum();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import org.qortal.data.account.RewardShareData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class BlockInfo {
|
||||
|
||||
private byte[] signature;
|
||||
private int height;
|
||||
private long timestamp;
|
||||
private int transactionCount;
|
||||
private String minterAddress;
|
||||
|
||||
protected BlockInfo() {
|
||||
/* For JAXB */
|
||||
}
|
||||
|
||||
public BlockInfo(byte[] signature, int height, long timestamp, int transactionCount, String minterAddress) {
|
||||
this.signature = signature;
|
||||
this.height = height;
|
||||
this.timestamp = timestamp;
|
||||
this.transactionCount = transactionCount;
|
||||
this.minterAddress = minterAddress;
|
||||
}
|
||||
|
||||
public BlockInfo(BlockData blockData) {
|
||||
// Convert BlockData to BlockInfo, using additional data
|
||||
this.minterAddress = "unknown?";
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(blockData.getMinterPublicKey());
|
||||
if (rewardShareData != null)
|
||||
this.minterAddress = rewardShareData.getMintingAccount();
|
||||
} catch (DataException e) {
|
||||
// We'll carry on with placeholder minterAddress then...
|
||||
}
|
||||
|
||||
this.signature = blockData.getSignature();
|
||||
this.height = blockData.getHeight();
|
||||
this.timestamp = blockData.getTimestamp();
|
||||
this.transactionCount = blockData.getTransactionCount();
|
||||
}
|
||||
|
||||
public byte[] getSignature() {
|
||||
return this.signature;
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return this.height;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return this.timestamp;
|
||||
}
|
||||
|
||||
public int getTransactionCount() {
|
||||
return this.transactionCount;
|
||||
}
|
||||
|
||||
public String getMinterAddress() {
|
||||
return this.minterAddress;
|
||||
}
|
||||
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainBitcoinP2SHStatus {
|
||||
|
||||
@Schema(description = "Bitcoin P2SH address", example = "3CdH27kTpV8dcFHVRYjQ8EEV5FJg9X8pSJ (mainnet), 2fMiRRXVsxhZeyfum9ifybZvaMHbQTmwdZw (testnet)")
|
||||
public String bitcoinP2shAddress;
|
||||
|
||||
@Schema(description = "Bitcoin P2SH balance")
|
||||
public BigDecimal bitcoinP2shBalance;
|
||||
|
||||
@Schema(description = "Can P2SH redeem yet?")
|
||||
public boolean canRedeem;
|
||||
|
||||
@Schema(description = "Can P2SH refund yet?")
|
||||
public boolean canRefund;
|
||||
|
||||
@Schema(description = "Secret extracted by P2SH redeemer")
|
||||
public byte[] secret;
|
||||
|
||||
public CrossChainBitcoinP2SHStatus() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,6 +22,9 @@ public class CrossChainBitcoinRefundRequest {
|
||||
@Schema(description = "Bitcoin miner fee", example = "0.00001000")
|
||||
public BigDecimal bitcoinMinerFee;
|
||||
|
||||
@Schema(description = "Bitcoin HASH160(public key) for receiving funds, or omit to derive from private key", example = "u17kBVKkKSp12oUzaxFwNnq1JZf")
|
||||
public byte[] receivingAccountInfo;
|
||||
|
||||
public CrossChainBitcoinRefundRequest() {
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainBitcoinyHTLCStatus {
|
||||
|
||||
@Schema(description = "P2SH address", example = "3CdH27kTpV8dcFHVRYjQ8EEV5FJg9X8pSJ (mainnet), 2fMiRRXVsxhZeyfum9ifybZvaMHbQTmwdZw (testnet)")
|
||||
public String bitcoinP2shAddress;
|
||||
|
||||
@Schema(description = "P2SH balance")
|
||||
public BigDecimal bitcoinP2shBalance;
|
||||
|
||||
@Schema(description = "Can HTLC redeem yet?")
|
||||
public boolean canRedeem;
|
||||
|
||||
@Schema(description = "Can HTLC refund yet?")
|
||||
public boolean canRefund;
|
||||
|
||||
@Schema(description = "Secret used by HTLC redeemer")
|
||||
public byte[] secret;
|
||||
|
||||
public CrossChainBitcoinyHTLCStatus() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
import org.qortal.crosschain.BTCACCT;
|
||||
import org.qortal.crosschain.AcctMode;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@@ -16,26 +16,41 @@ public class CrossChainOfferSummary {
|
||||
// Properties
|
||||
|
||||
@Schema(description = "AT's Qortal address")
|
||||
public String qortalAtAddress;
|
||||
private String qortalAtAddress;
|
||||
|
||||
@Schema(description = "AT creator's Qortal address")
|
||||
public String qortalCreator;
|
||||
private String qortalCreator;
|
||||
|
||||
@Schema(description = "AT creator's ephemeral trading key-pair represented as Qortal address")
|
||||
private String qortalCreatorTradeAddress;
|
||||
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
private long qortAmount;
|
||||
|
||||
@Schema(description = "Bitcoin amount - DEPRECATED: use foreignAmount")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
@Deprecated
|
||||
private long btcAmount;
|
||||
|
||||
@Schema(description = "Foreign blockchain amount")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
private long foreignAmount;
|
||||
|
||||
@Schema(description = "Suggested trade timeout (minutes)", example = "10080")
|
||||
private int tradeTimeout;
|
||||
|
||||
private BTCACCT.Mode mode;
|
||||
@Schema(description = "Current AT execution mode")
|
||||
private AcctMode mode;
|
||||
|
||||
private long timestamp;
|
||||
|
||||
@Schema(description = "Trade partner's Qortal receiving address")
|
||||
private String partnerQortalReceivingAddress;
|
||||
|
||||
private String foreignBlockchain;
|
||||
|
||||
private String acctName;
|
||||
|
||||
protected CrossChainOfferSummary() {
|
||||
/* For JAXB */
|
||||
}
|
||||
@@ -43,12 +58,16 @@ public class CrossChainOfferSummary {
|
||||
public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
|
||||
this.qortalAtAddress = crossChainTradeData.qortalAtAddress;
|
||||
this.qortalCreator = crossChainTradeData.qortalCreator;
|
||||
this.qortalCreatorTradeAddress = crossChainTradeData.qortalCreatorTradeAddress;
|
||||
this.qortAmount = crossChainTradeData.qortAmount;
|
||||
this.btcAmount = crossChainTradeData.expectedBitcoin;
|
||||
this.foreignAmount = crossChainTradeData.expectedForeignAmount;
|
||||
this.btcAmount = this.foreignAmount; // Duplicate for deprecated field
|
||||
this.tradeTimeout = crossChainTradeData.tradeTimeout;
|
||||
this.mode = crossChainTradeData.mode;
|
||||
this.timestamp = timestamp;
|
||||
this.partnerQortalReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress;
|
||||
this.foreignBlockchain = crossChainTradeData.foreignBlockchain;
|
||||
this.acctName = crossChainTradeData.acctName;
|
||||
}
|
||||
|
||||
public String getQortalAtAddress() {
|
||||
@@ -59,6 +78,10 @@ public class CrossChainOfferSummary {
|
||||
return this.qortalCreator;
|
||||
}
|
||||
|
||||
public String getQortalCreatorTradeAddress() {
|
||||
return this.qortalCreatorTradeAddress;
|
||||
}
|
||||
|
||||
public long getQortAmount() {
|
||||
return this.qortAmount;
|
||||
}
|
||||
@@ -67,11 +90,15 @@ public class CrossChainOfferSummary {
|
||||
return this.btcAmount;
|
||||
}
|
||||
|
||||
public long getForeignAmount() {
|
||||
return this.foreignAmount;
|
||||
}
|
||||
|
||||
public int getTradeTimeout() {
|
||||
return this.tradeTimeout;
|
||||
}
|
||||
|
||||
public BTCACCT.Mode getMode() {
|
||||
public AcctMode getMode() {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
@@ -83,4 +110,18 @@ public class CrossChainOfferSummary {
|
||||
return this.partnerQortalReceivingAddress;
|
||||
}
|
||||
|
||||
public String getForeignBlockchain() {
|
||||
return this.foreignBlockchain;
|
||||
}
|
||||
|
||||
public String getAcctName() {
|
||||
return this.acctName;
|
||||
}
|
||||
|
||||
// For debugging mostly
|
||||
|
||||
public String toString() {
|
||||
return String.format("%s: %s", this.qortalAtAddress, this.mode);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,8 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
// All properties to be converted to JSON via JAXB
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainTradeSummary {
|
||||
@@ -15,9 +17,14 @@ public class CrossChainTradeSummary {
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
private long qortAmount;
|
||||
|
||||
@Deprecated
|
||||
@Schema(description = "DEPRECATED: use foreignAmount instead")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
private long btcAmount;
|
||||
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
private long foreignAmount;
|
||||
|
||||
protected CrossChainTradeSummary() {
|
||||
/* For JAXB */
|
||||
}
|
||||
@@ -25,7 +32,8 @@ public class CrossChainTradeSummary {
|
||||
public CrossChainTradeSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
|
||||
this.tradeTimestamp = timestamp;
|
||||
this.qortAmount = crossChainTradeData.qortAmount;
|
||||
this.btcAmount = crossChainTradeData.expectedBitcoin;
|
||||
this.foreignAmount = crossChainTradeData.expectedForeignAmount;
|
||||
this.btcAmount = this.foreignAmount;
|
||||
}
|
||||
|
||||
public long getTradeTimestamp() {
|
||||
@@ -40,4 +48,7 @@ public class CrossChainTradeSummary {
|
||||
return this.btcAmount;
|
||||
}
|
||||
|
||||
public long getForeignAmount() {
|
||||
return this.foreignAmount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public class NodeInfo {
|
||||
public String buildVersion;
|
||||
public long buildTimestamp;
|
||||
public String nodeId;
|
||||
public boolean isTestNet;
|
||||
|
||||
public NodeInfo() {
|
||||
}
|
||||
|
||||
@@ -20,17 +20,14 @@ public class NodeStatus {
|
||||
public final int height;
|
||||
|
||||
public NodeStatus() {
|
||||
isMintingPossible = Controller.getInstance().isMintingPossible();
|
||||
isSynchronizing = Controller.getInstance().isSynchronizing();
|
||||
this.isMintingPossible = Controller.getInstance().isMintingPossible();
|
||||
|
||||
if (isSynchronizing)
|
||||
syncPercent = Controller.getInstance().getSyncPercent();
|
||||
else
|
||||
syncPercent = null;
|
||||
this.syncPercent = Controller.getInstance().getSyncPercent();
|
||||
this.isSynchronizing = this.syncPercent != null;
|
||||
|
||||
numberOfConnections = Network.getInstance().getHandshakedPeers().size();
|
||||
this.numberOfConnections = Network.getInstance().getHandshakedPeers().size();
|
||||
|
||||
height = Controller.getInstance().getChainHeight();
|
||||
this.height = Controller.getInstance().getChainHeight();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
157
src/main/java/org/qortal/api/model/SimpleForeignTransaction.java
Normal file
157
src/main/java/org/qortal/api/model/SimpleForeignTransaction.java
Normal file
@@ -0,0 +1,157 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class SimpleForeignTransaction {
|
||||
|
||||
public static class AddressAmount {
|
||||
public final String address;
|
||||
public final long amount;
|
||||
|
||||
protected AddressAmount() {
|
||||
/* For JAXB */
|
||||
this.address = null;
|
||||
this.amount = 0;
|
||||
}
|
||||
|
||||
public AddressAmount(String address, long amount) {
|
||||
this.address = address;
|
||||
this.amount = amount;
|
||||
}
|
||||
}
|
||||
|
||||
private String txHash;
|
||||
private long timestamp;
|
||||
|
||||
private List<AddressAmount> inputs;
|
||||
|
||||
public static class Output {
|
||||
public final List<String> addresses;
|
||||
public final long amount;
|
||||
|
||||
protected Output() {
|
||||
/* For JAXB */
|
||||
this.addresses = null;
|
||||
this.amount = 0;
|
||||
}
|
||||
|
||||
public Output(List<String> addresses, long amount) {
|
||||
this.addresses = addresses;
|
||||
this.amount = amount;
|
||||
}
|
||||
}
|
||||
private List<Output> outputs;
|
||||
|
||||
private long totalAmount;
|
||||
private long fees;
|
||||
|
||||
private Boolean isSentNotReceived;
|
||||
|
||||
protected SimpleForeignTransaction() {
|
||||
/* For JAXB */
|
||||
}
|
||||
|
||||
private SimpleForeignTransaction(Builder builder) {
|
||||
this.txHash = builder.txHash;
|
||||
this.timestamp = builder.timestamp;
|
||||
this.inputs = Collections.unmodifiableList(builder.inputs);
|
||||
this.outputs = Collections.unmodifiableList(builder.outputs);
|
||||
|
||||
Objects.requireNonNull(this.txHash);
|
||||
if (timestamp <= 0)
|
||||
throw new IllegalArgumentException("timestamp must be positive");
|
||||
|
||||
long totalGrossAmount = this.inputs.stream().map(addressAmount -> addressAmount.amount).reduce(0L, Long::sum);
|
||||
this.totalAmount = this.outputs.stream().map(addressAmount -> addressAmount.amount).reduce(0L, Long::sum);
|
||||
|
||||
this.fees = totalGrossAmount - this.totalAmount;
|
||||
|
||||
this.isSentNotReceived = builder.isSentNotReceived;
|
||||
}
|
||||
|
||||
public String getTxHash() {
|
||||
return this.txHash;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return this.timestamp;
|
||||
}
|
||||
|
||||
public List<AddressAmount> getInputs() {
|
||||
return this.inputs;
|
||||
}
|
||||
|
||||
public List<Output> getOutputs() {
|
||||
return this.outputs;
|
||||
}
|
||||
|
||||
public long getTotalAmount() {
|
||||
return this.totalAmount;
|
||||
}
|
||||
|
||||
public long getFees() {
|
||||
return this.fees;
|
||||
}
|
||||
|
||||
public Boolean isSentNotReceived() {
|
||||
return this.isSentNotReceived;
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private String txHash;
|
||||
private long timestamp;
|
||||
private List<AddressAmount> inputs = new ArrayList<>();
|
||||
private List<Output> outputs = new ArrayList<>();
|
||||
private Boolean isSentNotReceived;
|
||||
|
||||
public Builder txHash(String txHash) {
|
||||
this.txHash = Objects.requireNonNull(txHash);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder timestamp(long timestamp) {
|
||||
if (timestamp <= 0)
|
||||
throw new IllegalArgumentException("timestamp must be positive");
|
||||
|
||||
this.timestamp = timestamp;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder input(String address, long amount) {
|
||||
Objects.requireNonNull(address);
|
||||
if (amount < 0)
|
||||
throw new IllegalArgumentException("amount must be zero or positive");
|
||||
|
||||
AddressAmount input = new AddressAmount(address, amount);
|
||||
inputs.add(input);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder output(List<String> addresses, long amount) {
|
||||
Objects.requireNonNull(addresses);
|
||||
if (amount < 0)
|
||||
throw new IllegalArgumentException("amount must be zero or positive");
|
||||
|
||||
Output output = new Output(addresses, amount);
|
||||
outputs.add(output);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder isSentNotReceived(Boolean isSentNotReceived) {
|
||||
this.isSentNotReceived = isSentNotReceived;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SimpleForeignTransaction build() {
|
||||
return new SimpleForeignTransaction(this);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class TradeBotRespondRequest {
|
||||
|
||||
@Schema(description = "Qortal AT address", example = "AH3e3jHEsGHPVQPDiJx4pYqgVi72auxgVy")
|
||||
public String atAddress;
|
||||
|
||||
@Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ")
|
||||
public String xprv58;
|
||||
|
||||
@Schema(description = "Qortal address for receiving QORT from AT")
|
||||
public String receivingAddress;
|
||||
|
||||
public TradeBotRespondRequest() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.qortal.api.model;
|
||||
package org.qortal.api.model.crosschain;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
@@ -9,16 +9,20 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class BitcoinSendRequest {
|
||||
|
||||
@Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ")
|
||||
@Schema(description = "Bitcoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________")
|
||||
public String xprv58;
|
||||
|
||||
@Schema(description = "Recipient's Bitcoin address ('legacy' P2PKH only)", example = "1BitcoinEaterAddressDontSendf59kuE")
|
||||
public String receivingAddress;
|
||||
|
||||
@Schema(description = "Amount of BTC to send")
|
||||
@Schema(description = "Amount of BTC to send", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long bitcoinAmount;
|
||||
|
||||
@Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 BTC (100 sats) per byte", example = "0.00000100", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public Long feePerByte;
|
||||
|
||||
public BitcoinSendRequest() {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.qortal.api.model.crosschain;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class LitecoinSendRequest {
|
||||
|
||||
@Schema(description = "Litecoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________")
|
||||
public String xprv58;
|
||||
|
||||
@Schema(description = "Recipient's Litecoin address ('legacy' P2PKH only)", example = "LiTecoinEaterAddressDontSendhLfzKD")
|
||||
public String receivingAddress;
|
||||
|
||||
@Schema(description = "Amount of LTC to send", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long litecoinAmount;
|
||||
|
||||
@Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 LTC (100 sats) per byte", example = "0.00000100", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public Long feePerByte;
|
||||
|
||||
public LitecoinSendRequest() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.qortal.api.model;
|
||||
package org.qortal.api.model.crosschain;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
@@ -12,22 +14,30 @@ public class TradeBotCreateRequest {
|
||||
@Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB")
|
||||
public byte[] creatorPublicKey;
|
||||
|
||||
@Schema(description = "QORT amount paid out on successful trade", example = "80.40200000")
|
||||
@Schema(description = "QORT amount paid out on successful trade", example = "80.40000000", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long qortAmount;
|
||||
|
||||
@Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "81")
|
||||
@Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "80.50000000", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long fundingQortAmount;
|
||||
|
||||
@Schema(description = "Bitcoin amount wanted in return", example = "0.00864200")
|
||||
@Deprecated
|
||||
@Schema(description = "Bitcoin amount wanted in return. DEPRECATED: use foreignAmount instead", example = "0.00864200", type = "number", hidden = true)
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long bitcoinAmount;
|
||||
public Long bitcoinAmount;
|
||||
|
||||
@Schema(description = "Foreign blockchain. Note: default (BITCOIN) to be removed in the future", example = "BITCOIN", implementation = SupportedBlockchain.class)
|
||||
public SupportedBlockchain foreignBlockchain;
|
||||
|
||||
@Schema(description = "Foreign blockchain amount wanted in return", example = "0.00864200", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public Long foreignAmount;
|
||||
|
||||
@Schema(description = "Suggested trade timeout (minutes)", example = "10080")
|
||||
public int tradeTimeout;
|
||||
|
||||
@Schema(description = "Bitcoin address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE")
|
||||
@Schema(description = "Foreign blockchain address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE")
|
||||
public String receivingAddress;
|
||||
|
||||
public TradeBotCreateRequest() {
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.qortal.api.model.crosschain;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class TradeBotRespondRequest {
|
||||
|
||||
@Schema(description = "Qortal AT address", example = "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
public String atAddress;
|
||||
|
||||
@Deprecated
|
||||
@Schema(description = "Bitcoin BIP32 extended private key. DEPRECATED: use foreignKey instead", hidden = true,
|
||||
example = "xprv___________________________________________________________________________________________________________")
|
||||
public String xprv58;
|
||||
|
||||
@Schema(description = "Foreign blockchain private key, e.g. BIP32 'm' key for Bitcoin/Litecoin starting with 'xprv'",
|
||||
example = "xprv___________________________________________________________________________________________________________")
|
||||
public String foreignKey;
|
||||
|
||||
@Schema(description = "Qortal address for receiving QORT from AT", example = "Qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq")
|
||||
public String receivingAddress;
|
||||
|
||||
public TradeBotRespondRequest() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -473,6 +474,7 @@ public class AddressesResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String computePublicize(String rawBytes58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -40,7 +41,6 @@ import org.qortal.account.Account;
|
||||
import org.qortal.account.PrivateKeyAccount;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiException;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.ActivitySummary;
|
||||
@@ -57,6 +57,7 @@ import org.qortal.network.PeerAddress;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
@@ -118,6 +119,7 @@ public class AdminResource {
|
||||
nodeInfo.buildVersion = Controller.getInstance().getVersionString();
|
||||
nodeInfo.buildTimestamp = Controller.getInstance().getBuildTimestamp();
|
||||
nodeInfo.nodeId = Network.getInstance().getOurNodeId();
|
||||
nodeInfo.isTestNet = Settings.getInstance().isTestNet();
|
||||
|
||||
return nodeInfo;
|
||||
}
|
||||
@@ -132,6 +134,7 @@ public class AdminResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public NodeStatus status() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -152,6 +155,7 @@ public class AdminResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String shutdown() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -180,7 +184,10 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public ActivitySummary summary() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
ActivitySummary summary = new ActivitySummary();
|
||||
|
||||
LocalDate date = LocalDate.now();
|
||||
@@ -192,16 +199,13 @@ public class AdminResource {
|
||||
int startHeight = repository.getBlockRepository().getHeightFromTimestamp(start);
|
||||
int endHeight = repository.getBlockRepository().getBlockchainHeight();
|
||||
|
||||
summary.blockCount = endHeight - startHeight;
|
||||
summary.setBlockCount(endHeight - startHeight);
|
||||
|
||||
summary.transactionCountByType = repository.getTransactionRepository().getTransactionSummary(startHeight + 1, endHeight);
|
||||
summary.setTransactionCountByType(repository.getTransactionRepository().getTransactionSummary(startHeight + 1, endHeight));
|
||||
|
||||
for (Integer count : summary.transactionCountByType.values())
|
||||
summary.transactionCount += count;
|
||||
summary.setAssetsIssued(repository.getAssetRepository().getRecentAssetIds(start).size());
|
||||
|
||||
summary.assetsIssued = repository.getAssetRepository().getRecentAssetIds(start).size();
|
||||
|
||||
summary.namesRegistered = repository.getNameRepository().getRecentNames(start).size();
|
||||
summary.setNamesRegistered (repository.getNameRepository().getRecentNames(start).size());
|
||||
|
||||
return summary;
|
||||
} catch (DataException e) {
|
||||
@@ -209,6 +213,30 @@ public class AdminResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/enginestats")
|
||||
@Operation(
|
||||
summary = "Fetch statistics snapshot for core engine",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = Controller.StatsSnapshot.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public Controller.StatsSnapshot getEngineStats() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
return Controller.getInstance().getStatsSnapshot();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/mintingaccounts")
|
||||
@Operation(
|
||||
@@ -221,6 +249,7 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public List<MintingAccountData> getMintingAccounts() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -267,6 +296,7 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE, ApiError.CANNOT_MINT})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String addMintingAccount(String seed58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -319,6 +349,7 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String deleteMintingAccount(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -418,6 +449,7 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_HEIGHT, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String orphan(String targetHeightString) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -435,8 +467,6 @@ public class AdminResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +491,7 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String forceSync(String targetPeerAddress) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -492,8 +523,6 @@ public class AdminResource {
|
||||
return syncResult.name();
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (UnknownHostException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
} catch (InterruptedException e) {
|
||||
@@ -501,4 +530,161 @@ public class AdminResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/repository/data")
|
||||
@Operation(
|
||||
summary = "Export sensitive/node-local data from repository.",
|
||||
description = "Exports data to .script files on local machine"
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String exportRepository() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
repository.exportNodeLocalData();
|
||||
return "true";
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/repository/data")
|
||||
@Operation(
|
||||
summary = "Import data into repository.",
|
||||
description = "Imports data from file on local machine. Filename is forced to 'import.json' if apiKey is not set.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string", example = "MintingAccounts.script"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "\"true\"",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String importRepository(String filename) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
// Hard-coded because it's too dangerous to allow user-supplied filenames in weaker security contexts
|
||||
if (Settings.getInstance().getApiKey() == null)
|
||||
filename = "import.json";
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
repository.importDataFromFile(filename);
|
||||
repository.saveChanges();
|
||||
|
||||
return "true";
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// We couldn't lock blockchain to perform import
|
||||
return "false";
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/repository/checkpoint")
|
||||
@Operation(
|
||||
summary = "Checkpoint data in repository.",
|
||||
description = "Forces repository to checkpoint uncommitted writes.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "\"true\"",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String checkpointRepository() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
RepositoryManager.setRequestedCheckpoint(Boolean.TRUE);
|
||||
|
||||
return "true";
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/repository/backup")
|
||||
@Operation(
|
||||
summary = "Perform online backup of repository.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "\"true\"",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String backupRepository() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
repository.backup(true);
|
||||
repository.saveChanges();
|
||||
|
||||
return "true";
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// We couldn't lock blockchain to perform backup
|
||||
return "false";
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/repository")
|
||||
@Operation(
|
||||
summary = "Perform maintenance on repository.",
|
||||
description = "Requires enough free space to rebuild repository. This will pause your node for a while."
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public void performRepositoryMaintenance() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
repository.performPeriodicMaintenance();
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// No big deal
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||
import io.swagger.v3.oas.annotations.extensions.Extension;
|
||||
import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
|
||||
import io.swagger.v3.oas.annotations.info.Info;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.annotations.security.SecuritySchemes;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import org.qortal.api.Security;
|
||||
|
||||
@OpenAPIDefinition(
|
||||
info = @Info( title = "Qortal API", description = "NOTE: byte-arrays are encoded in Base58" ),
|
||||
tags = {
|
||||
@@ -30,5 +36,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
})
|
||||
}
|
||||
)
|
||||
@SecuritySchemes({
|
||||
@SecurityScheme(name = "basicAuth", type = SecuritySchemeType.HTTP, scheme = "basic"),
|
||||
@SecurityScheme(name = "apiKey", type = SecuritySchemeType.APIKEY, in = SecuritySchemeIn.HEADER, paramName = Security.API_KEY_HEADER)
|
||||
})
|
||||
public class ApiDefinition {
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiException;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.at.QortalAtLoggerFactory;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
import org.qortal.data.transaction.DeployAtTransactionData;
|
||||
@@ -147,8 +146,7 @@ public class AtResource {
|
||||
ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress);
|
||||
byte[] stateData = atStateData.getStateData();
|
||||
|
||||
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
|
||||
byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData);
|
||||
byte[] dataBytes = MachineState.extractDataBytes(stateData);
|
||||
|
||||
return dataBytes;
|
||||
} catch (ApiException 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,6 +9,11 @@ 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.List;
|
||||
|
||||
@@ -20,11 +26,13 @@ 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.BlockInfo;
|
||||
import org.qortal.api.model.BlockMintingInfo;
|
||||
import org.qortal.api.model.BlockSignerSummary;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.account.AccountData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
@@ -33,6 +41,8 @@ import org.qortal.data.transaction.TransactionData;
|
||||
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")
|
||||
@@ -81,6 +91,48 @@ public class BlocksResource {
|
||||
}
|
||||
}
|
||||
|
||||
@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()) {
|
||||
BlockData blockData = repository.getBlockRepository().fromSignature(signature);
|
||||
if (blockData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
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());
|
||||
|
||||
} 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(
|
||||
@@ -329,6 +381,59 @@ public class BlocksResource {
|
||||
}
|
||||
}
|
||||
|
||||
@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()) {
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
if (blockData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
Block block = new Block(repository, blockData);
|
||||
BlockData parentBlockData = repository.getBlockRepository().fromSignature(blockData.getReference());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/timestamp/{timestamp}")
|
||||
@Operation(
|
||||
@@ -492,7 +597,7 @@ public class BlocksResource {
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = BlockInfo.class
|
||||
implementation = BlockSummaryData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -502,7 +607,7 @@ public class BlocksResource {
|
||||
@ApiErrors({
|
||||
ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public List<BlockInfo> getBlockRange(
|
||||
public List<BlockSummaryData> getBlockSummaries(
|
||||
@QueryParam("start") Integer startHeight,
|
||||
@QueryParam("end") Integer endHeight,
|
||||
@Parameter(ref = "count") @QueryParam("count") Integer count) {
|
||||
@@ -515,7 +620,7 @@ public class BlocksResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getBlockRepository().getBlockInfos(startHeight, endHeight, count);
|
||||
return repository.getBlockRepository().getBlockSummaries(startHeight, endHeight, count);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import java.util.List;
|
||||
@@ -156,6 +157,7 @@ public class ChatResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String buildChat(ChatTransactionData transactionData) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -203,6 +205,7 @@ public class ChatResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String buildChat(String rawBytes58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
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 java.util.Arrays;
|
||||
import java.util.Random;
|
||||
|
||||
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 org.qortal.account.PublicKeyAccount;
|
||||
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.CrossChainBuildRequest;
|
||||
import org.qortal.api.model.CrossChainDualSecretRequest;
|
||||
import org.qortal.api.model.CrossChainTradeRequest;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.crosschain.BitcoinACCTv1;
|
||||
import org.qortal.crosschain.Bitcoiny;
|
||||
import org.qortal.crosschain.AcctMode;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.data.transaction.BaseTransactionData;
|
||||
import org.qortal.data.transaction.DeployAtTransactionData;
|
||||
import org.qortal.data.transaction.MessageTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.group.Group;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transaction.DeployAtTransaction;
|
||||
import org.qortal.transaction.MessageTransaction;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.Transformer;
|
||||
import org.qortal.transform.transaction.DeployAtTransactionTransformer;
|
||||
import org.qortal.transform.transaction.MessageTransactionTransformer;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
@Path("/crosschain/BitcoinACCTv1")
|
||||
@Tag(name = "Cross-Chain (BitcoinACCTv1)")
|
||||
public class CrossChainBitcoinACCTv1Resource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@POST
|
||||
@Path("/build")
|
||||
@Operation(
|
||||
summary = "Build Bitcoin cross-chain trading AT",
|
||||
description = "Returns raw, unsigned DEPLOY_AT transaction",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainBuildRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_DATA, ApiError.INVALID_REFERENCE, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
public String buildTrade(CrossChainBuildRequest tradeRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
byte[] creatorPublicKey = tradeRequest.creatorPublicKey;
|
||||
|
||||
if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (tradeRequest.hashOfSecretB == null || tradeRequest.hashOfSecretB.length != Bitcoiny.HASH160_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (tradeRequest.tradeTimeout == null)
|
||||
tradeRequest.tradeTimeout = 7 * 24 * 60; // 7 days
|
||||
else
|
||||
if (tradeRequest.tradeTimeout < 10 || tradeRequest.tradeTimeout > 50000)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (tradeRequest.qortAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (tradeRequest.fundingQortAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
// funding amount must exceed initial + final
|
||||
if (tradeRequest.fundingQortAmount <= tradeRequest.qortAmount)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (tradeRequest.bitcoinAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey);
|
||||
|
||||
byte[] creationBytes = BitcoinACCTv1.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.hashOfSecretB,
|
||||
tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout);
|
||||
|
||||
long txTimestamp = NTP.getTime();
|
||||
byte[] lastReference = creatorAccount.getLastReference();
|
||||
if (lastReference == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE);
|
||||
|
||||
long fee = 0;
|
||||
String name = "QORT-BTC cross-chain trade";
|
||||
String description = "Qortal-Bitcoin cross-chain trade";
|
||||
String atType = "ACCT";
|
||||
String tags = "QORT-BTC ACCT";
|
||||
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, creatorAccount.getPublicKey(), fee, null);
|
||||
TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, tradeRequest.fundingQortAmount, Asset.QORT);
|
||||
|
||||
Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
|
||||
|
||||
fee = deployAtTransaction.calcRecommendedFee();
|
||||
deployAtTransactionData.setFee(fee);
|
||||
|
||||
ValidationResult result = deployAtTransaction.isValidUnconfirmed();
|
||||
if (result != ValidationResult.OK)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
byte[] bytes = DeployAtTransactionTransformer.toBytes(deployAtTransactionData);
|
||||
return Base58.encode(bytes);
|
||||
} catch (TransformationException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/trademessage")
|
||||
@Operation(
|
||||
summary = "Builds raw, unsigned 'trade' MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode",
|
||||
description = "Specify address of cross-chain AT that needs to be messaged, and signature of 'offer' MESSAGE from trade partner.<br>"
|
||||
+ "AT needs to be in 'offer' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!<br>"
|
||||
+ "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainTradeRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public String buildTradeMessage(CrossChainTradeRequest tradeRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
byte[] tradePublicKey = tradeRequest.tradePublicKey;
|
||||
|
||||
if (tradePublicKey == null || tradePublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (tradeRequest.atAddress == null || !Crypto.isValidAtAddress(tradeRequest.atAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (tradeRequest.messageTransactionSignature == null || !Crypto.isValidAddress(tradeRequest.messageTransactionSignature))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress);
|
||||
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||
|
||||
if (crossChainTradeData.mode != AcctMode.OFFERING)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Does supplied public key match trade public key?
|
||||
if (!Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
TransactionData transactionData = repository.getTransactionRepository().fromSignature(tradeRequest.messageTransactionSignature);
|
||||
if (transactionData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_UNKNOWN);
|
||||
|
||||
if (transactionData.getType() != TransactionType.MESSAGE)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID);
|
||||
|
||||
MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData;
|
||||
byte[] messageData = messageTransactionData.getData();
|
||||
BitcoinACCTv1.OfferMessageData offerMessageData = BitcoinACCTv1.extractOfferMessageData(messageData);
|
||||
if (offerMessageData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID);
|
||||
|
||||
// Good to make MESSAGE
|
||||
|
||||
byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH;
|
||||
byte[] hashOfSecretA = offerMessageData.hashOfSecretA;
|
||||
int lockTimeA = (int) offerMessageData.lockTimeA;
|
||||
|
||||
String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey());
|
||||
int lockTimeB = BitcoinACCTv1.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA);
|
||||
|
||||
byte[] outgoingMessageData = BitcoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
||||
byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData);
|
||||
|
||||
return Base58.encode(messageTransactionBytes);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/redeemmessage")
|
||||
@Operation(
|
||||
summary = "Builds raw, unsigned 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner",
|
||||
description = "Specify address of cross-chain AT that needs to be messaged, both 32-byte secrets and an address for receiving QORT from AT.<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 sign output with account 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 = CrossChainDualSecretRequest.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 String buildRedeemMessage(CrossChainDualSecretRequest secretRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
byte[] partnerPublicKey = secretRequest.partnerPublicKey;
|
||||
|
||||
if (partnerPublicKey == null || partnerPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (secretRequest.secretA == null || secretRequest.secretA.length != BitcoinACCTv1.SECRET_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (secretRequest.secretB == null || secretRequest.secretB.length != BitcoinACCTv1.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 = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||
|
||||
if (crossChainTradeData.mode != AcctMode.TRADING)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
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 = BitcoinACCTv1.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB, secretRequest.receivingAddress);
|
||||
byte[] messageTransactionBytes = buildAtMessage(repository, partnerPublicKey, secretRequest.atAddress, messageData);
|
||||
|
||||
return Base58.encode(messageTransactionBytes);
|
||||
} 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(), BitcoinACCTv1.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;
|
||||
}
|
||||
|
||||
private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException {
|
||||
long txTimestamp = NTP.getTime();
|
||||
|
||||
// senderPublicKey could be ephemeral trade public key where there is no corresponding account and hence no reference
|
||||
String senderAddress = Crypto.toAddress(senderPublicKey);
|
||||
byte[] lastReference = repository.getAccountRepository().getLastReference(senderAddress);
|
||||
final boolean requiresPoW = lastReference == null;
|
||||
|
||||
if (requiresPoW) {
|
||||
Random random = new Random();
|
||||
lastReference = new byte[Transformer.SIGNATURE_LENGTH];
|
||||
random.nextBytes(lastReference);
|
||||
}
|
||||
|
||||
int version = 4;
|
||||
int nonce = 0;
|
||||
long amount = 0L;
|
||||
Long assetId = null; // no assetId as amount is zero
|
||||
Long fee = 0L;
|
||||
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, senderPublicKey, fee, null);
|
||||
TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, atAddress, amount, assetId, messageData, false, false);
|
||||
|
||||
MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData);
|
||||
|
||||
if (requiresPoW) {
|
||||
messageTransaction.computeNonce();
|
||||
} else {
|
||||
fee = messageTransaction.calcRecommendedFee();
|
||||
messageTransactionData.setFee(fee);
|
||||
}
|
||||
|
||||
ValidationResult result = messageTransaction.isValidUnconfirmed();
|
||||
if (result != ValidationResult.OK)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
try {
|
||||
return MessageTransactionTransformer.toBytes(messageTransactionData);
|
||||
} catch (TransformationException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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 java.util.List;
|
||||
|
||||
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 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.BitcoinSendRequest;
|
||||
import org.qortal.crosschain.Bitcoin;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.SimpleTransaction;
|
||||
|
||||
@Path("/crosschain/btc")
|
||||
@Tag(name = "Cross-Chain (Bitcoin)")
|
||||
public class CrossChainBitcoinResource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@POST
|
||||
@Path("/walletbalance")
|
||||
@Operation(
|
||||
summary = "Returns BTC 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 getBitcoinWalletBalance(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Bitcoin bitcoin = Bitcoin.getInstance();
|
||||
|
||||
if (!bitcoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
Long balance = bitcoin.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> getBitcoinWalletTransactions(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Bitcoin bitcoin = Bitcoin.getInstance();
|
||||
|
||||
if (!bitcoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
return bitcoin.getWalletTransactions(key58);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/send")
|
||||
@Operation(
|
||||
summary = "Sends BTC from hierarchical, deterministic BIP32 wallet to specific address",
|
||||
description = "Currently only supports 'legacy' P2PKH Bitcoin 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 = BitcoinSendRequest.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(BitcoinSendRequest bitcoinSendRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
if (bitcoinSendRequest.bitcoinAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (bitcoinSendRequest.feePerByte != null && bitcoinSendRequest.feePerByte <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
Bitcoin bitcoin = Bitcoin.getInstance();
|
||||
|
||||
if (!bitcoin.isValidAddress(bitcoinSendRequest.receivingAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (!bitcoin.isValidDeterministicKey(bitcoinSendRequest.xprv58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
Transaction spendTransaction = bitcoin.buildSpend(bitcoinSendRequest.xprv58,
|
||||
bitcoinSendRequest.receivingAddress,
|
||||
bitcoinSendRequest.bitcoinAmount,
|
||||
bitcoinSendRequest.feePerByte);
|
||||
|
||||
if (spendTransaction == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE);
|
||||
|
||||
try {
|
||||
bitcoin.broadcastTransaction(spendTransaction);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
|
||||
return spendTransaction.getTxId().toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
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 java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
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.bitcoinj.core.*;
|
||||
import org.bitcoinj.script.Script;
|
||||
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.CrossChainBitcoinyHTLCStatus;
|
||||
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;
|
||||
|
||||
@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;
|
||||
|
||||
@GET
|
||||
@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 (base58 encoded). Locktime is seconds since epoch.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_CRITERIA})
|
||||
public String deriveHtlcAddress(@PathParam("blockchain") String blockchainName,
|
||||
@PathParam("refundPKH") String refundPKH,
|
||||
@PathParam("locktime") int lockTime,
|
||||
@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[] decodedHashOfSecret;
|
||||
|
||||
try {
|
||||
refunderPubKeyHash = Base58.decode(refundPKH);
|
||||
redeemerPubKeyHash = Base58.decode(redeemPKH);
|
||||
|
||||
if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
}
|
||||
|
||||
try {
|
||||
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, decodedHashOfSecret);
|
||||
|
||||
Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance();
|
||||
|
||||
return bitcoiny.deriveP2shAddress(redeemScript);
|
||||
}
|
||||
|
||||
@GET
|
||||
@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 (base58 encoded). Locktime is seconds since epoch.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN})
|
||||
public CrossChainBitcoinyHTLCStatus checkHtlcStatus(@PathParam("blockchain") String blockchainName,
|
||||
@PathParam("refundPKH") String refundPKH,
|
||||
@PathParam("locktime") int lockTime,
|
||||
@PathParam("redeemPKH") String redeemPKH,
|
||||
@PathParam("hashOfSecret") String hashOfSecret) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName);
|
||||
if (blockchain == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] refunderPubKeyHash;
|
||||
byte[] redeemerPubKeyHash;
|
||||
byte[] decodedHashOfSecret;
|
||||
|
||||
try {
|
||||
refunderPubKeyHash = Base58.decode(refundPKH);
|
||||
redeemerPubKeyHash = Base58.decode(redeemPKH);
|
||||
|
||||
if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
}
|
||||
|
||||
try {
|
||||
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, decodedHashOfSecret);
|
||||
|
||||
Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance();
|
||||
|
||||
String p2shAddress = bitcoiny.deriveP2shAddress(redeemScript);
|
||||
|
||||
long now = NTP.getTime();
|
||||
|
||||
try {
|
||||
int medianBlockTime = bitcoiny.getMedianBlockTime();
|
||||
|
||||
// Check P2SH is funded
|
||||
long p2shBalance = bitcoiny.getConfirmedBalance(p2shAddress.toString());
|
||||
|
||||
CrossChainBitcoinyHTLCStatus htlcStatus = new CrossChainBitcoinyHTLCStatus();
|
||||
htlcStatus.bitcoinP2shAddress = p2shAddress;
|
||||
htlcStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8);
|
||||
|
||||
List<TransactionOutput> fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddress.toString());
|
||||
|
||||
if (p2shBalance > 0L && !fundingOutputs.isEmpty()) {
|
||||
htlcStatus.canRedeem = now >= medianBlockTime * 1000L;
|
||||
htlcStatus.canRefund = now >= lockTime * 1000L;
|
||||
}
|
||||
|
||||
if (now >= medianBlockTime * 1000L) {
|
||||
// See if we can extract secret
|
||||
htlcStatus.secret = BitcoinyHTLC.findHtlcSecret(bitcoiny, htlcStatus.bitcoinP2shAddress);
|
||||
}
|
||||
|
||||
return htlcStatus;
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/redeem/LITECOIN/{ataddress}/{tradePrivateKey}/{secret}/{receivingAddress}")
|
||||
@Operation(
|
||||
summary = "Redeems HTLC associated with supplied AT, using private key, secret, and receiving address",
|
||||
description = "Secret and private key should be 32 bytes (base58 encoded). Receiving address must be a valid LTC P2PKH address.<br>" +
|
||||
"The secret can be found in Alice's trade bot data or in the message to Bob's AT.<br>" +
|
||||
"The trade private key and receiving address can be found in Bob's trade bot data.",
|
||||
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,
|
||||
@PathParam("tradePrivateKey") String tradePrivateKey,
|
||||
@PathParam("secret") String secret,
|
||||
@PathParam("receivingAddress") String receivingAddress) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
// base58 decode the trade private key
|
||||
byte[] decodedTradePrivateKey = null;
|
||||
if (tradePrivateKey != null)
|
||||
decodedTradePrivateKey = Base58.decode(tradePrivateKey);
|
||||
|
||||
// base58 decode the secret
|
||||
byte[] decodedSecret = null;
|
||||
if (secret != null)
|
||||
decodedSecret = Base58.decode(secret);
|
||||
|
||||
// Convert supplied Litecoin receiving address into public key hash (we only support P2PKH at this time)
|
||||
Address litecoinReceivingAddress;
|
||||
try {
|
||||
litecoinReceivingAddress = Address.fromString(Litecoin.getInstance().getNetworkParameters(), receivingAddress);
|
||||
} catch (AddressFormatException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
if (litecoinReceivingAddress.getOutputScriptType() != Script.ScriptType.P2PKH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] litecoinReceivingAccountInfo = litecoinReceivingAddress.getHash();
|
||||
|
||||
return this.doRedeemHtlc(atAddress, decodedTradePrivateKey, decodedSecret, litecoinReceivingAccountInfo);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/redeem/LITECOIN/{ataddress}")
|
||||
@Operation(
|
||||
summary = "Redeems HTLC associated with supplied AT",
|
||||
description = "To be used by a QORT seller (Bob) who needs to redeem LTC 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);
|
||||
|
||||
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 = LitecoinACCTv1.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 litecoin receiving address in the tradebot data
|
||||
byte[] litecoinReceivingAccountInfo = null;
|
||||
if (tradeBotData != null)
|
||||
// Use receiving address PKH from tradebot data
|
||||
litecoinReceivingAccountInfo = tradeBotData.getReceivingAccountInfo();
|
||||
|
||||
return this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, litecoinReceivingAccountInfo);
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean doRedeemHtlc(String atAddress, byte[] decodedTradePrivateKey, byte[] decodedSecret, byte[] litecoinReceivingAccountInfo) {
|
||||
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 (litecoinReceivingAccountInfo == null || litecoinReceivingAccountInfo.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 LTC
|
||||
if (Crypto.isValidAddress(litecoinReceivingAccountInfo))
|
||||
if (Base58.encode(litecoinReceivingAccountInfo).startsWith("Q"))
|
||||
// This is likely a QORT address, not an LTC
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
|
||||
// Use secret-A to redeem P2SH-A
|
||||
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
int lockTime = crossChainTradeData.lockTimeA;
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTime, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA);
|
||||
String p2shAddressA = litecoin.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 = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.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 = litecoin.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey,
|
||||
fundingOutputs, redeemScriptA, decodedSecret, litecoinReceivingAccountInfo);
|
||||
|
||||
litecoin.broadcastTransaction(p2shRedeemTransaction);
|
||||
return true; // TODO: validate?
|
||||
}
|
||||
}
|
||||
|
||||
} 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;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/refund/LITECOIN/{ataddress}")
|
||||
@Operation(
|
||||
summary = "Refunds HTLC associated with supplied AT",
|
||||
description = "To be used by a QORT buyer (Alice) who needs to refund their LTC 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);
|
||||
|
||||
// Determine LTC receive address for refund
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
String receiveAddress = litecoin.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);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/refund/LITECOIN/{ataddress}/{receivingAddress}")
|
||||
@Operation(
|
||||
summary = "Refunds HTLC associated with supplied AT, to the specified LTC receiving address",
|
||||
description = "To be used by a QORT buyer (Alice) who needs to refund their LTC 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,
|
||||
@PathParam("receivingAddress") String receivingAddress) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
return this.doRefundHtlc(atAddress, receivingAddress);
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
|
||||
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);
|
||||
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
// We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
|
||||
int medianBlockTime = litecoin.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 = litecoin.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 = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.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 = litecoin.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
// Validate the destination LTC address
|
||||
Address receiving = Address.fromString(litecoin.getNetworkParameters(), receiveAddress);
|
||||
if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(litecoin.getNetworkParameters(), refundAmount, refundKey,
|
||||
fundingOutputs, redeemScriptA, lockTime, receiving.getHash());
|
||||
|
||||
litecoin.broadcastTransaction(p2shRefundTransaction);
|
||||
return true; // TODO: validate?
|
||||
}
|
||||
}
|
||||
|
||||
} 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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 java.util.List;
|
||||
|
||||
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 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.LitecoinSendRequest;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.Litecoin;
|
||||
import org.qortal.crosschain.SimpleTransaction;
|
||||
|
||||
@Path("/crosschain/ltc")
|
||||
@Tag(name = "Cross-Chain (Litecoin)")
|
||||
public class CrossChainLitecoinResource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@POST
|
||||
@Path("/walletbalance")
|
||||
@Operation(
|
||||
summary = "Returns LTC 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 getLitecoinWalletBalance(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
if (!litecoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
Long balance = litecoin.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> getLitecoinWalletTransactions(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
if (!litecoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
return litecoin.getWalletTransactions(key58);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/send")
|
||||
@Operation(
|
||||
summary = "Sends LTC from hierarchical, deterministic BIP32 wallet to specific address",
|
||||
description = "Currently only supports 'legacy' P2PKH Litecoin 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 = LitecoinSendRequest.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(LitecoinSendRequest litecoinSendRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
if (litecoinSendRequest.litecoinAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (litecoinSendRequest.feePerByte != null && litecoinSendRequest.feePerByte <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
if (!litecoin.isValidAddress(litecoinSendRequest.receivingAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (!litecoin.isValidDeterministicKey(litecoinSendRequest.xprv58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
Transaction spendTransaction = litecoin.buildSpend(litecoinSendRequest.xprv58,
|
||||
litecoinSendRequest.receivingAddress,
|
||||
litecoinSendRequest.litecoinAmount,
|
||||
litecoinSendRequest.feePerByte);
|
||||
|
||||
if (spendTransaction == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE);
|
||||
|
||||
try {
|
||||
litecoin.broadcastTransaction(spendTransaction);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
|
||||
return spendTransaction.getTxId().toString();
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
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 java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
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.account.PublicKeyAccount;
|
||||
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.TradeBotCreateRequest;
|
||||
import org.qortal.api.model.crosschain.TradeBotRespondRequest;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.controller.tradebot.AcctTradeBot;
|
||||
import org.qortal.controller.tradebot.TradeBot;
|
||||
import org.qortal.crosschain.ForeignBlockchain;
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
import org.qortal.crosschain.ACCT;
|
||||
import org.qortal.crosschain.AcctMode;
|
||||
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;
|
||||
|
||||
@Path("/crosschain/tradebot")
|
||||
@Tag(name = "Cross-Chain (Trade-Bot)")
|
||||
public class CrossChainTradeBotResource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Operation(
|
||||
summary = "List current trade-bot states",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = TradeBotData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
public List<TradeBotData> getTradeBotStates(
|
||||
@Parameter(
|
||||
description = "Limit to specific blockchain",
|
||||
example = "LITECOIN",
|
||||
schema = @Schema(implementation = SupportedBlockchain.class)
|
||||
) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
|
||||
if (foreignBlockchain == null)
|
||||
return allTradeBotData;
|
||||
|
||||
return allTradeBotData.stream().filter(tradeBotData -> tradeBotData.getForeignBlockchain().equals(foreignBlockchain.name())).collect(Collectors.toList());
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/create")
|
||||
@Operation(
|
||||
summary = "Create a trade offer (trade-bot entry)",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = TradeBotCreateRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE})
|
||||
@SuppressWarnings("deprecation")
|
||||
public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
if (tradeBotCreateRequest.foreignBlockchain == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
ForeignBlockchain foreignBlockchain = tradeBotCreateRequest.foreignBlockchain.getInstance();
|
||||
|
||||
// We prefer foreignAmount to deprecated bitcoinAmount
|
||||
if (tradeBotCreateRequest.foreignAmount == null)
|
||||
tradeBotCreateRequest.foreignAmount = tradeBotCreateRequest.bitcoinAmount;
|
||||
|
||||
if (!foreignBlockchain.isValidAddress(tradeBotCreateRequest.receivingAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (tradeBotCreateRequest.tradeTimeout < 60)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (tradeBotCreateRequest.foreignAmount == null || tradeBotCreateRequest.foreignAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (tradeBotCreateRequest.qortAmount <= 0 || tradeBotCreateRequest.fundingQortAmount <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Do some simple checking first
|
||||
Account creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey);
|
||||
|
||||
if (creator.getConfirmedBalance(Asset.QORT) < tradeBotCreateRequest.fundingQortAmount)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INSUFFICIENT_BALANCE);
|
||||
|
||||
byte[] unsignedBytes = TradeBot.getInstance().createTrade(repository, tradeBotCreateRequest);
|
||||
if (unsignedBytes == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
return Base58.encode(unsignedBytes);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/respond")
|
||||
@Operation(
|
||||
summary = "Respond to a trade offer. NOTE: WILL SPEND FUNDS!)",
|
||||
description = "Start a new trade-bot entry to respond to chosen trade offer.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = TradeBotRespondRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
|
||||
@SuppressWarnings("deprecation")
|
||||
public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
final String atAddress = tradeBotRespondRequest.atAddress;
|
||||
|
||||
// We prefer foreignKey to deprecated xprv58
|
||||
if (tradeBotRespondRequest.foreignKey == null)
|
||||
tradeBotRespondRequest.foreignKey = tradeBotRespondRequest.xprv58;
|
||||
|
||||
if (tradeBotRespondRequest.foreignKey == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
if (atAddress == null || !Crypto.isValidAtAddress(atAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (tradeBotRespondRequest.receivingAddress == null || !Crypto.isValidAddress(tradeBotRespondRequest.receivingAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
// Extract data from cross-chain trading AT
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = fetchAtDataWithChecking(repository, atAddress);
|
||||
|
||||
// TradeBot uses AT's code hash to map to ACCT
|
||||
ACCT acct = TradeBot.getInstance().getAcctUsingAtData(atData);
|
||||
if (acct == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (!acct.getBlockchain().isValidWalletKey(tradeBotRespondRequest.foreignKey))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
if (crossChainTradeData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (crossChainTradeData.mode != AcctMode.OFFERING)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
AcctTradeBot.ResponseResult result = TradeBot.getInstance().startResponse(repository, atData, acct, crossChainTradeData,
|
||||
tradeBotRespondRequest.foreignKey, tradeBotRespondRequest.receivingAddress);
|
||||
|
||||
switch (result) {
|
||||
case OK:
|
||||
return "true";
|
||||
|
||||
case BALANCE_ISSUE:
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE);
|
||||
|
||||
case NETWORK_ISSUE:
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
default:
|
||||
return "false";
|
||||
}
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Operation(
|
||||
summary = "Delete completed trade",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
example = "93MB2qRDNVLxbmmPuYpLdAqn3u2x9ZhaVZK5wELHueP8"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
public String tradeBotDelete(String tradePrivateKey58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
final byte[] tradePrivateKey;
|
||||
try {
|
||||
tradePrivateKey = Base58.decode(tradePrivateKey58);
|
||||
|
||||
if (tradePrivateKey.length != 32)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Handed off to TradeBot
|
||||
return TradeBot.getInstance().deleteEntry(repository, tradePrivateKey) ? "true" : "false";
|
||||
} 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);
|
||||
|
||||
// No point sending message to AT that's finished
|
||||
if (atData.getIsFinished())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
return atData;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,8 +6,11 @@ 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.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -26,10 +29,17 @@ import org.qortal.api.ApiException;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.ConnectedPeer;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.controller.Synchronizer;
|
||||
import org.qortal.controller.Synchronizer.SynchronizationResult;
|
||||
import org.qortal.data.block.BlockSummaryData;
|
||||
import org.qortal.data.network.PeerData;
|
||||
import org.qortal.network.Network;
|
||||
import org.qortal.network.Peer;
|
||||
import org.qortal.network.PeerAddress;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.utils.ExecuteProduceConsume;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
@@ -122,6 +132,7 @@ public class PeersResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public ExecuteProduceConsume.StatsSnapshot getEngineStats() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -159,6 +170,7 @@ public class PeersResource {
|
||||
@ApiErrors({
|
||||
ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String addPeer(String address) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -213,6 +225,7 @@ public class PeersResource {
|
||||
@ApiErrors({
|
||||
ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String removePeer(String address) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -248,6 +261,7 @@ public class PeersResource {
|
||||
@ApiErrors({
|
||||
ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String removeKnownPeers(String address) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -260,4 +274,68 @@ public class PeersResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/commonblock")
|
||||
@Operation(
|
||||
summary = "Report common block with given peer.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string", example = "node2.qortal.org"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "the block",
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = BlockSummaryData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public List<BlockSummaryData> commonBlock(String targetPeerAddress) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try {
|
||||
// Try to resolve passed address to make things easier
|
||||
PeerAddress peerAddress = PeerAddress.fromString(targetPeerAddress);
|
||||
InetSocketAddress resolvedAddress = peerAddress.toSocketAddress();
|
||||
|
||||
List<Peer> peers = Network.getInstance().getHandshakedPeers();
|
||||
Peer targetPeer = peers.stream().filter(peer -> peer.getResolvedAddress().equals(resolvedAddress)).findFirst().orElse(null);
|
||||
|
||||
if (targetPeer == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int ourInitialHeight = Controller.getInstance().getChainHeight();
|
||||
boolean force = true;
|
||||
List<BlockSummaryData> peerBlockSummaries = new ArrayList<>();
|
||||
|
||||
SynchronizationResult findCommonBlockResult = Synchronizer.getInstance().fetchSummariesFromCommonBlock(repository, targetPeer, ourInitialHeight, force, peerBlockSummaries, true);
|
||||
if (findCommonBlockResult != SynchronizationResult.OK)
|
||||
return null;
|
||||
|
||||
return peerBlockSummaries;
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
} catch (UnknownHostException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
} catch (InterruptedException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -510,14 +510,19 @@ public class TransactionsResource {
|
||||
if (!Controller.getInstance().isUpToDate())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC);
|
||||
|
||||
byte[] rawBytes = Base58.decode(rawBytes58);
|
||||
|
||||
TransactionData transactionData;
|
||||
try {
|
||||
transactionData = TransactionTransformer.fromBytes(rawBytes);
|
||||
} catch (TransformationException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||
}
|
||||
|
||||
if (transactionData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
byte[] rawBytes = Base58.decode(rawBytes58);
|
||||
|
||||
TransactionData transactionData = TransactionTransformer.fromBytes(rawBytes);
|
||||
|
||||
if (transactionData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
Transaction transaction = Transaction.fromData(repository, transactionData);
|
||||
|
||||
if (!transaction.isSignatureValid())
|
||||
@@ -535,16 +540,9 @@ public class TransactionsResource {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
|
||||
// Notify controller of new transaction
|
||||
Controller.getInstance().onNewTransaction(transactionData, null);
|
||||
|
||||
return "true";
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e);
|
||||
} catch (TransformationException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
} catch (InterruptedException e) {
|
||||
|
||||
@@ -31,6 +31,7 @@ public class ActiveChatsWebSocket extends ApiWebSocket {
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, String> pathParams = getPathParams(session, "/{address}");
|
||||
|
||||
@@ -49,16 +50,19 @@ public class ActiveChatsWebSocket extends ApiWebSocket {
|
||||
}
|
||||
|
||||
@OnWebSocketClose
|
||||
@Override
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
ChatNotifier.getInstance().deregister(session);
|
||||
}
|
||||
|
||||
@OnWebSocketError
|
||||
public void onWebSocketError(Session session, Throwable throwable) {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
@OnWebSocketMessage
|
||||
public void onWebSocketMessage(Session session, String message) {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
private void onNotify(Session session, ChatTransactionData chatTransactionData, String ourAddress, AtomicReference<String> previousOutput) {
|
||||
|
||||
@@ -13,59 +13,87 @@ import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
|
||||
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||
import org.qortal.api.model.NodeStatus;
|
||||
import org.qortal.controller.StatusNotifier;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.event.Event;
|
||||
import org.qortal.event.EventBus;
|
||||
import org.qortal.event.Listener;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class AdminStatusWebSocket extends ApiWebSocket {
|
||||
public class AdminStatusWebSocket extends ApiWebSocket implements Listener {
|
||||
|
||||
private static final AtomicReference<String> previousOutput = new AtomicReference<>(null);
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
factory.register(AdminStatusWebSocket.class);
|
||||
|
||||
try {
|
||||
previousOutput.set(buildStatusString());
|
||||
} catch (IOException e) {
|
||||
// How to fail properly?
|
||||
return;
|
||||
}
|
||||
|
||||
EventBus.INSTANCE.addListener(this::listen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listen(Event event) {
|
||||
if (!(event instanceof Controller.StatusChangeEvent))
|
||||
return;
|
||||
|
||||
String newOutput;
|
||||
try {
|
||||
newOutput = buildStatusString();
|
||||
} catch (IOException e) {
|
||||
// Ignore this time?
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousOutput.getAndUpdate(currentValue -> newOutput).equals(newOutput))
|
||||
// Output hasn't changed, so don't send anything
|
||||
return;
|
||||
|
||||
for (Session session : getSessions())
|
||||
this.sendStatus(session, newOutput);
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
AtomicReference<String> previousOutput = new AtomicReference<>(null);
|
||||
this.sendStatus(session, previousOutput.get());
|
||||
|
||||
StatusNotifier.Listener listener = timestamp -> onNotify(session, previousOutput);
|
||||
StatusNotifier.getInstance().register(session, listener);
|
||||
|
||||
this.onNotify(session, previousOutput);
|
||||
super.onWebSocketConnect(session);
|
||||
}
|
||||
|
||||
@OnWebSocketClose
|
||||
@Override
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
StatusNotifier.getInstance().deregister(session);
|
||||
super.onWebSocketClose(session, statusCode, reason);
|
||||
}
|
||||
|
||||
@OnWebSocketError
|
||||
public void onWebSocketError(Session session, Throwable throwable) {
|
||||
/* We ignore errors for now, but method here to silence log spam */
|
||||
}
|
||||
|
||||
@OnWebSocketMessage
|
||||
public void onWebSocketMessage(Session session, String message) {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
private void onNotify(Session session,AtomicReference<String> previousOutput) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
NodeStatus nodeStatus = new NodeStatus();
|
||||
private static String buildStatusString() throws IOException {
|
||||
NodeStatus nodeStatus = new NodeStatus();
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
marshall(stringWriter, nodeStatus);
|
||||
return stringWriter.toString();
|
||||
}
|
||||
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
|
||||
marshall(stringWriter, nodeStatus);
|
||||
|
||||
// Only output if something has changed
|
||||
String output = stringWriter.toString();
|
||||
if (output.equals(previousOutput.get()))
|
||||
return;
|
||||
|
||||
previousOutput.set(output);
|
||||
session.getRemote().sendStringByFuture(output);
|
||||
} catch (DataException | IOException | WebSocketException e) {
|
||||
private void sendStatus(Session session, String status) {
|
||||
try {
|
||||
session.getRemote().sendStringByFuture(status);
|
||||
} catch (WebSocketException e) {
|
||||
// No output this time?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,9 @@ abstract class ApiWebSocket extends WebSocketServlet {
|
||||
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
synchronized (SESSIONS_BY_CLASS) {
|
||||
SESSIONS_BY_CLASS.get(this.getClass()).remove(session);
|
||||
List<Session> sessions = SESSIONS_BY_CLASS.get(this.getClass());
|
||||
if (sessions != null)
|
||||
sessions.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,12 @@ import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
|
||||
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.model.BlockInfo;
|
||||
import org.qortal.controller.BlockNotifier;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.block.BlockSummaryData;
|
||||
import org.qortal.event.Event;
|
||||
import org.qortal.event.EventBus;
|
||||
import org.qortal.event.Listener;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
@@ -22,26 +26,42 @@ import org.qortal.utils.Base58;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class BlocksWebSocket extends ApiWebSocket {
|
||||
public class BlocksWebSocket extends ApiWebSocket implements Listener {
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
factory.register(BlocksWebSocket.class);
|
||||
|
||||
EventBus.INSTANCE.addListener(this::listen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listen(Event event) {
|
||||
if (!(event instanceof Controller.NewBlockEvent))
|
||||
return;
|
||||
|
||||
BlockData blockData = ((Controller.NewBlockEvent) event).getBlockData();
|
||||
BlockSummaryData blockSummary = new BlockSummaryData(blockData);
|
||||
|
||||
for (Session session : getSessions())
|
||||
sendBlockSummary(session, blockSummary);
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
BlockNotifier.Listener listener = blockInfo -> onNotify(session, blockInfo);
|
||||
BlockNotifier.getInstance().register(session, listener);
|
||||
super.onWebSocketConnect(session);
|
||||
}
|
||||
|
||||
@OnWebSocketClose
|
||||
@Override
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
BlockNotifier.getInstance().deregister(session);
|
||||
super.onWebSocketClose(session, statusCode, reason);
|
||||
}
|
||||
|
||||
@OnWebSocketError
|
||||
public void onWebSocketError(Session session, Throwable throwable) {
|
||||
/* We ignore errors for now, but method here to silence log spam */
|
||||
}
|
||||
|
||||
@OnWebSocketMessage
|
||||
@@ -65,13 +85,13 @@ public class BlocksWebSocket extends ApiWebSocket {
|
||||
return;
|
||||
}
|
||||
|
||||
List<BlockInfo> blockInfos = repository.getBlockRepository().getBlockInfos(height, null, 1);
|
||||
if (blockInfos == null || blockInfos.isEmpty()) {
|
||||
List<BlockSummaryData> blockSummaries = repository.getBlockRepository().getBlockSummaries(height, height);
|
||||
if (blockSummaries == null || blockSummaries.isEmpty()) {
|
||||
sendError(session, ApiError.BLOCK_UNKNOWN);
|
||||
return;
|
||||
}
|
||||
|
||||
onNotify(session, blockInfos.get(0));
|
||||
sendBlockSummary(session, blockSummaries.get(0));
|
||||
} catch (DataException e) {
|
||||
sendError(session, ApiError.REPOSITORY_ISSUE);
|
||||
}
|
||||
@@ -94,23 +114,23 @@ public class BlocksWebSocket extends ApiWebSocket {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<BlockInfo> blockInfos = repository.getBlockRepository().getBlockInfos(height, null, 1);
|
||||
if (blockInfos == null || blockInfos.isEmpty()) {
|
||||
List<BlockSummaryData> blockSummaries = repository.getBlockRepository().getBlockSummaries(height, height);
|
||||
if (blockSummaries == null || blockSummaries.isEmpty()) {
|
||||
sendError(session, ApiError.BLOCK_UNKNOWN);
|
||||
return;
|
||||
}
|
||||
|
||||
onNotify(session, blockInfos.get(0));
|
||||
sendBlockSummary(session, blockSummaries.get(0));
|
||||
} catch (DataException e) {
|
||||
sendError(session, ApiError.REPOSITORY_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
private void onNotify(Session session, BlockInfo blockInfo) {
|
||||
private void sendBlockSummary(Session session, BlockSummaryData blockSummary) {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
|
||||
try {
|
||||
marshall(stringWriter, blockInfo);
|
||||
marshall(stringWriter, blockSummary);
|
||||
|
||||
session.getRemote().sendStringByFuture(stringWriter.toString());
|
||||
} catch (IOException | WebSocketException e) {
|
||||
|
||||
@@ -32,6 +32,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
|
||||
@@ -86,16 +87,19 @@ public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
}
|
||||
|
||||
@OnWebSocketClose
|
||||
@Override
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
ChatNotifier.getInstance().deregister(session);
|
||||
}
|
||||
|
||||
@OnWebSocketError
|
||||
public void onWebSocketError(Session session, Throwable throwable) {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
@OnWebSocketMessage
|
||||
public void onWebSocketMessage(Session session, String message) {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
private void onNotify(Session session, ChatTransactionData chatTransactionData, int txGroupId) {
|
||||
|
||||
244
src/main/java/org/qortal/api/websocket/PresenceWebSocket.java
Normal file
244
src/main/java/org/qortal/api/websocket/PresenceWebSocket.java
Normal file
@@ -0,0 +1,244 @@
|
||||
package org.qortal.api.websocket;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
|
||||
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.transaction.PresenceTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.event.Event;
|
||||
import org.qortal.event.EventBus;
|
||||
import org.qortal.event.Listener;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transaction.PresenceTransaction.PresenceType;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class PresenceWebSocket extends ApiWebSocket implements Listener {
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
@SuppressWarnings("unused")
|
||||
private static class PresenceInfo {
|
||||
private final PresenceType presenceType;
|
||||
private final String publicKey;
|
||||
private final long timestamp;
|
||||
private final String address;
|
||||
|
||||
protected PresenceInfo() {
|
||||
this.presenceType = null;
|
||||
this.publicKey = null;
|
||||
this.timestamp = 0L;
|
||||
this.address = null;
|
||||
}
|
||||
|
||||
public PresenceInfo(PresenceType presenceType, String pubKey58, long timestamp) {
|
||||
this.presenceType = presenceType;
|
||||
this.publicKey = pubKey58;
|
||||
this.timestamp = timestamp;
|
||||
this.address = Crypto.toAddress(Base58.decode(this.publicKey));
|
||||
}
|
||||
|
||||
public PresenceType getPresenceType() {
|
||||
return this.presenceType;
|
||||
}
|
||||
|
||||
public String getPublicKey() {
|
||||
return this.publicKey;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return this.timestamp;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return this.address;
|
||||
}
|
||||
}
|
||||
|
||||
/** Outer map key is PresenceType (enum), inner map key is public key in base58, inner map value is timestamp */
|
||||
private static final Map<PresenceType, Map<String, Long>> currentEntries = Collections.synchronizedMap(new EnumMap<>(PresenceType.class));
|
||||
|
||||
/** (Optional) PresenceType used for filtering by that Session. */
|
||||
private static final Map<Session, PresenceType> sessionPresenceTypes = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
factory.register(PresenceWebSocket.class);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
populateCurrentInfo(repository);
|
||||
} catch (DataException e) {
|
||||
// How to fail properly?
|
||||
return;
|
||||
}
|
||||
|
||||
EventBus.INSTANCE.addListener(this::listen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listen(Event event) {
|
||||
// We use NewBlockEvent as a proxy for 1-minute timer
|
||||
if (!(event instanceof Controller.NewTransactionEvent) && !(event instanceof Controller.NewBlockEvent))
|
||||
return;
|
||||
|
||||
removeOldEntries();
|
||||
|
||||
if (event instanceof Controller.NewBlockEvent)
|
||||
// We only wanted a chance to cull old entries
|
||||
return;
|
||||
|
||||
TransactionData transactionData = ((Controller.NewTransactionEvent) event).getTransactionData();
|
||||
|
||||
if (transactionData.getType() != TransactionType.PRESENCE)
|
||||
return;
|
||||
|
||||
PresenceTransactionData presenceData = (PresenceTransactionData) transactionData;
|
||||
PresenceType presenceType = presenceData.getPresenceType();
|
||||
|
||||
// Put/replace for this publickey making sure we keep newest timestamp
|
||||
String pubKey58 = Base58.encode(presenceData.getCreatorPublicKey());
|
||||
long ourTimestamp = presenceData.getTimestamp();
|
||||
long computedTimestamp = mergePresence(presenceType, pubKey58, ourTimestamp);
|
||||
|
||||
if (computedTimestamp != ourTimestamp)
|
||||
// nothing changed
|
||||
return;
|
||||
|
||||
List<PresenceInfo> presenceInfo = Collections.singletonList(new PresenceInfo(presenceType, pubKey58, computedTimestamp));
|
||||
|
||||
// Notify sessions
|
||||
for (Session session : getSessions()) {
|
||||
PresenceType sessionPresenceType = sessionPresenceTypes.get(session);
|
||||
|
||||
if (sessionPresenceType == null || sessionPresenceType == presenceType)
|
||||
sendPresenceInfo(session, presenceInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
List<String> presenceTypes = queryParams.get("presenceType");
|
||||
|
||||
// We only support ONE presenceType
|
||||
String presenceTypeName = presenceTypes == null || presenceTypes.isEmpty() ? null : presenceTypes.get(0);
|
||||
|
||||
PresenceType presenceType = presenceTypeName == null ? null : PresenceType.fromString(presenceTypeName);
|
||||
|
||||
// Make sure that if caller does give a presenceType, that it is a valid/known one.
|
||||
if (presenceTypeName != null && presenceType == null) {
|
||||
session.close(4003, "unknown presenceType: " + presenceTypeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save session's requested PresenceType, if given
|
||||
if (presenceType != null)
|
||||
sessionPresenceTypes.put(session, presenceType);
|
||||
|
||||
List<PresenceInfo> presenceInfo;
|
||||
|
||||
synchronized (currentEntries) {
|
||||
presenceInfo = currentEntries.entrySet().stream()
|
||||
.filter(entry -> presenceType == null ? true : entry.getKey() == presenceType)
|
||||
.flatMap(entry -> entry.getValue().entrySet().stream().map(innerEntry -> new PresenceInfo(entry.getKey(), innerEntry.getKey(), innerEntry.getValue())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
if (!sendPresenceInfo(session, presenceInfo)) {
|
||||
session.close(4002, "websocket issue");
|
||||
return;
|
||||
}
|
||||
|
||||
super.onWebSocketConnect(session);
|
||||
}
|
||||
|
||||
@OnWebSocketClose
|
||||
@Override
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
// clean up
|
||||
sessionPresenceTypes.remove(session);
|
||||
|
||||
super.onWebSocketClose(session, statusCode, reason);
|
||||
}
|
||||
|
||||
@OnWebSocketError
|
||||
public void onWebSocketError(Session session, Throwable throwable) {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
@OnWebSocketMessage
|
||||
public void onWebSocketMessage(Session session, String message) {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
private boolean sendPresenceInfo(Session session, List<PresenceInfo> presenceInfo) {
|
||||
try {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
marshall(stringWriter, presenceInfo);
|
||||
|
||||
String output = stringWriter.toString();
|
||||
session.getRemote().sendStringByFuture(output);
|
||||
} catch (IOException e) {
|
||||
// No output this time?
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void populateCurrentInfo(Repository repository) throws DataException {
|
||||
// We want ALL PRESENCE transactions
|
||||
|
||||
List<TransactionData> presenceTransactionsData = repository.getTransactionRepository().getUnconfirmedTransactions(TransactionType.PRESENCE, null);
|
||||
|
||||
for (TransactionData transactionData : presenceTransactionsData) {
|
||||
PresenceTransactionData presenceData = (PresenceTransactionData) transactionData;
|
||||
|
||||
PresenceType presenceType = presenceData.getPresenceType();
|
||||
|
||||
// Put/replace for this publickey making sure we keep newest timestamp
|
||||
String pubKey58 = Base58.encode(presenceData.getCreatorPublicKey());
|
||||
long ourTimestamp = presenceData.getTimestamp();
|
||||
|
||||
mergePresence(presenceType, pubKey58, ourTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
private static long mergePresence(PresenceType presenceType, String pubKey58, long ourTimestamp) {
|
||||
Map<String, Long> typedPubkeyTimestamps = currentEntries.computeIfAbsent(presenceType, someType -> Collections.synchronizedMap(new HashMap<>()));
|
||||
return typedPubkeyTimestamps.compute(pubKey58, (somePubKey58, currentTimestamp) -> (currentTimestamp == null || currentTimestamp < ourTimestamp) ? ourTimestamp : currentTimestamp);
|
||||
}
|
||||
|
||||
private static void removeOldEntries() {
|
||||
long now = NTP.getTime();
|
||||
|
||||
currentEntries.entrySet().forEach(entry -> {
|
||||
long expiryThreshold = now - entry.getKey().getLifetime();
|
||||
entry.getValue().entrySet().removeIf(pubkeyTimestamp -> pubkeyTimestamp.getValue() < expiryThreshold);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import java.util.stream.Collectors;
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
|
||||
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||
import org.qortal.controller.TradeBot;
|
||||
import org.qortal.controller.tradebot.TradeBot;
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
import org.qortal.data.crosschain.TradeBotData;
|
||||
import org.qortal.event.Event;
|
||||
import org.qortal.event.EventBus;
|
||||
@@ -29,7 +31,9 @@ import org.qortal.utils.Base58;
|
||||
public class TradeBotWebSocket extends ApiWebSocket implements Listener {
|
||||
|
||||
/** Cache of trade-bot entry states, keyed by trade-bot entry's "trade private key" (base58) */
|
||||
private static final Map<String, TradeBotData.State> PREVIOUS_STATES = new HashMap<>();
|
||||
private static final Map<String, Integer> PREVIOUS_STATES = new HashMap<>();
|
||||
|
||||
private static final Map<Session, String> sessionBlockchain = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
@@ -41,7 +45,7 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener {
|
||||
// How do we properly fail here?
|
||||
return;
|
||||
|
||||
PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getState)));
|
||||
PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getStateValue)));
|
||||
} catch (DataException e) {
|
||||
// No output this time
|
||||
}
|
||||
@@ -58,44 +62,78 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener {
|
||||
String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey());
|
||||
|
||||
synchronized (PREVIOUS_STATES) {
|
||||
if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getState())
|
||||
Integer previousStateValue = PREVIOUS_STATES.get(tradePrivateKey58);
|
||||
if (previousStateValue != null && previousStateValue == tradeBotData.getStateValue())
|
||||
// Not changed
|
||||
return;
|
||||
|
||||
PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getState());
|
||||
PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getStateValue());
|
||||
}
|
||||
|
||||
List<TradeBotData> tradeBotEntries = Collections.singletonList(tradeBotData);
|
||||
for (Session session : getSessions())
|
||||
sendEntries(session, tradeBotEntries);
|
||||
|
||||
for (Session session : getSessions()) {
|
||||
// Only send if this session has this/no preferred blockchain
|
||||
String preferredBlockchain = sessionBlockchain.get(session);
|
||||
|
||||
if (preferredBlockchain == null || preferredBlockchain.equals(tradeBotData.getForeignBlockchain()))
|
||||
sendEntries(session, tradeBotEntries);
|
||||
}
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
|
||||
List<String> foreignBlockchains = queryParams.get("foreignBlockchain");
|
||||
final String foreignBlockchain = foreignBlockchains == null ? null : foreignBlockchains.get(0);
|
||||
|
||||
// Make sure blockchain (if any) is valid
|
||||
if (foreignBlockchain != null && SupportedBlockchain.fromString(foreignBlockchain) == null) {
|
||||
session.close(4003, "unknown blockchain: " + foreignBlockchain);
|
||||
return;
|
||||
}
|
||||
|
||||
// save session's preferred blockchain (if any)
|
||||
sessionBlockchain.put(session, foreignBlockchain);
|
||||
|
||||
// Send all known trade-bot entries
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<TradeBotData> tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
if (tradeBotEntries == null) {
|
||||
session.close(4001, "repository issue fetching trade-bot entries");
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional filtering
|
||||
if (foreignBlockchain != null)
|
||||
tradeBotEntries = tradeBotEntries.stream()
|
||||
.filter(tradeBotData -> tradeBotData.getForeignBlockchain().equals(foreignBlockchain))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!sendEntries(session, tradeBotEntries)) {
|
||||
session.close(4002, "websocket issue");
|
||||
return;
|
||||
}
|
||||
} catch (DataException e) {
|
||||
// No output this time
|
||||
session.close(4001, "repository issue fetching trade-bot entries");
|
||||
return;
|
||||
}
|
||||
|
||||
super.onWebSocketConnect(session);
|
||||
}
|
||||
|
||||
@OnWebSocketClose
|
||||
@Override
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
// clean up
|
||||
sessionBlockchain.remove(session);
|
||||
|
||||
super.onWebSocketClose(session, statusCode, reason);
|
||||
}
|
||||
|
||||
@OnWebSocketError
|
||||
public void onWebSocketError(Session session, Throwable throwable) {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
@OnWebSocketMessage
|
||||
public void onWebSocketMessage(Session session, String message) {
|
||||
/* ignored */
|
||||
|
||||
@@ -3,128 +3,228 @@ package org.qortal.api.websocket;
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
|
||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
|
||||
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||
import org.qortal.api.model.BlockInfo;
|
||||
import org.qortal.api.model.CrossChainOfferSummary;
|
||||
import org.qortal.controller.BlockNotifier;
|
||||
import org.qortal.crosschain.BTCACCT;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
import org.qortal.crosschain.ACCT;
|
||||
import org.qortal.crosschain.AcctMode;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.event.Event;
|
||||
import org.qortal.event.EventBus;
|
||||
import org.qortal.event.Listener;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.utils.ByteArray;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class TradeOffersWebSocket extends ApiWebSocket {
|
||||
public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(TradeOffersWebSocket.class);
|
||||
|
||||
private static class CachedOfferInfo {
|
||||
public final Map<String, AcctMode> previousAtModes = new HashMap<>();
|
||||
|
||||
// OFFERING
|
||||
public final Map<String, CrossChainOfferSummary> currentSummaries = new HashMap<>();
|
||||
// REDEEMED/REFUNDED/CANCELLED
|
||||
public final Map<String, CrossChainOfferSummary> historicSummaries = new HashMap<>();
|
||||
}
|
||||
// Manual synchronization
|
||||
private static final Map<String, CachedOfferInfo> cachedInfoByBlockchain = new HashMap<>();
|
||||
|
||||
private static final Predicate<CrossChainOfferSummary> isHistoric = offerSummary
|
||||
-> offerSummary.getMode() == AcctMode.REDEEMED
|
||||
|| offerSummary.getMode() == AcctMode.REFUNDED
|
||||
|| offerSummary.getMode() == AcctMode.CANCELLED;
|
||||
|
||||
private static final Map<Session, String> sessionBlockchain = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
factory.register(TradeOffersWebSocket.class);
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
|
||||
final boolean includeHistoric = queryParams.get("includeHistoric") != null;
|
||||
final Map<String, BTCACCT.Mode> previousAtModes = new HashMap<>();
|
||||
List<CrossChainOfferSummary> crossChainOfferSummaries;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<ATStateData> initialAtStates;
|
||||
populateCurrentSummaries(repository);
|
||||
|
||||
// We want ALL OFFERING trades
|
||||
Boolean isFinished = Boolean.FALSE;
|
||||
Integer dataByteOffset = BTCACCT.MODE_BYTE_OFFSET;
|
||||
Long expectedValue = (long) BTCACCT.Mode.OFFERING.value;
|
||||
Integer minimumFinalHeight = null;
|
||||
populateHistoricSummaries(repository);
|
||||
} catch (DataException e) {
|
||||
// How to fail properly?
|
||||
return;
|
||||
}
|
||||
|
||||
initialAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
|
||||
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
||||
null, null, null);
|
||||
EventBus.INSTANCE.addListener(this::listen);
|
||||
}
|
||||
|
||||
if (initialAtStates == null) {
|
||||
session.close(4001, "repository issue fetching OFFERING trades");
|
||||
return;
|
||||
}
|
||||
@Override
|
||||
public void listen(Event event) {
|
||||
if (!(event instanceof Controller.NewBlockEvent))
|
||||
return;
|
||||
|
||||
// Save initial AT modes
|
||||
previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> BTCACCT.Mode.OFFERING)));
|
||||
BlockData blockData = ((Controller.NewBlockEvent) event).getBlockData();
|
||||
|
||||
// Convert to offer summaries
|
||||
crossChainOfferSummaries = produceSummaries(repository, initialAtStates, null);
|
||||
// Process any new info
|
||||
|
||||
if (includeHistoric) {
|
||||
// We also want REDEEMED/REFUNDED/CANCELLED trades over the last 24 hours
|
||||
long timestamp = NTP.getTime() - 24 * 60 * 60 * 1000L;
|
||||
minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(timestamp);
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Find any new/changed trade ATs since this block
|
||||
final Boolean isFinished = null;
|
||||
final Integer dataByteOffset = null;
|
||||
final Long expectedValue = null;
|
||||
final Integer minimumFinalHeight = blockData.getHeight();
|
||||
|
||||
if (minimumFinalHeight != 0) {
|
||||
isFinished = Boolean.TRUE;
|
||||
dataByteOffset = null;
|
||||
expectedValue = null;
|
||||
++minimumFinalHeight; // because height is just *before* timestamp
|
||||
for (SupportedBlockchain blockchain : SupportedBlockchain.values()) {
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain);
|
||||
|
||||
List<ATStateData> historicAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
|
||||
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
||||
null, null, null);
|
||||
List<CrossChainOfferSummary> crossChainOfferSummaries = new ArrayList<>();
|
||||
|
||||
if (historicAtStates == null) {
|
||||
session.close(4002, "repository issue fetching historic trades");
|
||||
return;
|
||||
synchronized (cachedInfoByBlockchain) {
|
||||
CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo());
|
||||
|
||||
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||
byte[] codeHash = acctInfo.getKey().value;
|
||||
ACCT acct = acctInfo.getValue().get();
|
||||
|
||||
List<ATStateData> atStates = repository.getATRepository().getMatchingFinalATStates(codeHash,
|
||||
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
||||
null, null, null);
|
||||
|
||||
crossChainOfferSummaries.addAll(produceSummaries(repository, acct, atStates, blockData.getTimestamp()));
|
||||
}
|
||||
|
||||
for (ATStateData historicAtState : historicAtStates) {
|
||||
CrossChainOfferSummary historicOfferSummary = produceSummary(repository, historicAtState, null);
|
||||
// Remove any entries unchanged from last time
|
||||
crossChainOfferSummaries.removeIf(offerSummary -> cachedInfo.previousAtModes.get(offerSummary.getQortalAtAddress()) == offerSummary.getMode());
|
||||
|
||||
// Skip to next blockchain if nothing has changed (for this blockchain)
|
||||
if (crossChainOfferSummaries.isEmpty())
|
||||
continue;
|
||||
|
||||
// Update
|
||||
for (CrossChainOfferSummary offerSummary : crossChainOfferSummaries) {
|
||||
String offerAtAddress = offerSummary.getQortalAtAddress();
|
||||
|
||||
cachedInfo.previousAtModes.put(offerAtAddress, offerSummary.getMode());
|
||||
LOGGER.trace(() -> String.format("Block height: %d, AT: %s, mode: %s", blockData.getHeight(), offerAtAddress, offerSummary.getMode().name()));
|
||||
|
||||
switch (offerSummary.getMode()) {
|
||||
case OFFERING:
|
||||
cachedInfo.currentSummaries.put(offerAtAddress, offerSummary);
|
||||
cachedInfo.historicSummaries.remove(offerAtAddress);
|
||||
break;
|
||||
|
||||
switch (historicOfferSummary.getMode()) {
|
||||
case REDEEMED:
|
||||
case REFUNDED:
|
||||
case CANCELLED:
|
||||
cachedInfo.currentSummaries.remove(offerAtAddress);
|
||||
cachedInfo.historicSummaries.put(offerAtAddress, offerSummary);
|
||||
break;
|
||||
|
||||
default:
|
||||
continue;
|
||||
case TRADING:
|
||||
cachedInfo.currentSummaries.remove(offerAtAddress);
|
||||
cachedInfo.historicSummaries.remove(offerAtAddress);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add summary to initial burst
|
||||
crossChainOfferSummaries.add(historicOfferSummary);
|
||||
|
||||
// Save initial AT mode
|
||||
previousAtModes.put(historicAtState.getATAddress(), historicOfferSummary.getMode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any historic offers that are over 24 hours old
|
||||
final long tooOldTimestamp = NTP.getTime() - 24 * 60 * 60 * 1000L;
|
||||
cachedInfo.historicSummaries.values().removeIf(historicSummary -> historicSummary.getTimestamp() < tooOldTimestamp);
|
||||
}
|
||||
|
||||
// Notify sessions
|
||||
for (Session session : getSessions()) {
|
||||
// Only send if this session has this/no preferred blockchain
|
||||
String preferredBlockchain = sessionBlockchain.get(session);
|
||||
|
||||
if (preferredBlockchain == null || preferredBlockchain.equals(blockchain.name()))
|
||||
sendOfferSummaries(session, crossChainOfferSummaries);
|
||||
}
|
||||
|
||||
}
|
||||
} catch (DataException e) {
|
||||
session.close(4003, "generic repository issue");
|
||||
// No output this time
|
||||
}
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
final boolean includeHistoric = queryParams.get("includeHistoric") != null;
|
||||
|
||||
List<String> foreignBlockchains = queryParams.get("foreignBlockchain");
|
||||
final String foreignBlockchain = foreignBlockchains == null ? null : foreignBlockchains.get(0);
|
||||
|
||||
// Make sure blockchain (if any) is valid
|
||||
if (foreignBlockchain != null && SupportedBlockchain.fromString(foreignBlockchain) == null) {
|
||||
session.close(4003, "unknown blockchain: " + foreignBlockchain);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save session's preferred blockchain, if given
|
||||
if (foreignBlockchain != null)
|
||||
sessionBlockchain.put(session, foreignBlockchain);
|
||||
|
||||
List<CrossChainOfferSummary> crossChainOfferSummaries = new ArrayList<>();
|
||||
|
||||
synchronized (cachedInfoByBlockchain) {
|
||||
Collection<CachedOfferInfo> cachedInfos;
|
||||
|
||||
if (foreignBlockchain == null)
|
||||
// No preferred blockchain, so iterate through all of them
|
||||
cachedInfos = cachedInfoByBlockchain.values();
|
||||
else
|
||||
cachedInfos = Collections.singleton(cachedInfoByBlockchain.computeIfAbsent(foreignBlockchain, k -> new CachedOfferInfo()));
|
||||
|
||||
for (CachedOfferInfo cachedInfo : cachedInfos) {
|
||||
crossChainOfferSummaries.addAll(cachedInfo.currentSummaries.values());
|
||||
|
||||
if (includeHistoric)
|
||||
crossChainOfferSummaries.addAll(cachedInfo.historicSummaries.values());
|
||||
}
|
||||
}
|
||||
|
||||
if (!sendOfferSummaries(session, crossChainOfferSummaries)) {
|
||||
session.close(4004, "websocket issue");
|
||||
session.close(4002, "websocket issue");
|
||||
return;
|
||||
}
|
||||
|
||||
BlockNotifier.Listener listener = blockInfo -> onNotify(session, blockInfo, previousAtModes);
|
||||
BlockNotifier.getInstance().register(session, listener);
|
||||
super.onWebSocketConnect(session);
|
||||
}
|
||||
|
||||
@OnWebSocketClose
|
||||
@Override
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
BlockNotifier.getInstance().deregister(session);
|
||||
// clean up
|
||||
sessionBlockchain.remove(session);
|
||||
|
||||
super.onWebSocketClose(session, statusCode, reason);
|
||||
}
|
||||
|
||||
@OnWebSocketError
|
||||
public void onWebSocketError(Session session, Throwable throwable) {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
@OnWebSocketMessage
|
||||
@@ -132,45 +232,6 @@ public class TradeOffersWebSocket extends ApiWebSocket {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
private void onNotify(Session session, BlockInfo blockInfo, final Map<String, BTCACCT.Mode> previousAtModes) {
|
||||
List<CrossChainOfferSummary> crossChainOfferSummaries = null;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Find any new trade ATs since this block
|
||||
final Boolean isFinished = null;
|
||||
final Integer dataByteOffset = null;
|
||||
final Long expectedValue = null;
|
||||
final Integer minimumFinalHeight = blockInfo.getHeight();
|
||||
|
||||
List<ATStateData> atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
|
||||
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
||||
null, null, null);
|
||||
|
||||
if (atStates == null)
|
||||
return;
|
||||
|
||||
crossChainOfferSummaries = produceSummaries(repository, atStates, blockInfo.getTimestamp());
|
||||
} catch (DataException e) {
|
||||
// No output this time
|
||||
}
|
||||
|
||||
synchronized (previousAtModes) { //NOSONAR squid:S2445 suppressed because previousAtModes is final and curried in lambda
|
||||
// Remove any entries unchanged from last time
|
||||
crossChainOfferSummaries.removeIf(offerSummary -> previousAtModes.get(offerSummary.getQortalAtAddress()) == offerSummary.getMode());
|
||||
|
||||
// Don't send anything if no results
|
||||
if (crossChainOfferSummaries.isEmpty())
|
||||
return;
|
||||
|
||||
final boolean wasSent = sendOfferSummaries(session, crossChainOfferSummaries);
|
||||
|
||||
if (!wasSent)
|
||||
return;
|
||||
|
||||
previousAtModes.putAll(crossChainOfferSummaries.stream().collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, CrossChainOfferSummary::getMode)));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean sendOfferSummaries(Session session, List<CrossChainOfferSummary> crossChainOfferSummaries) {
|
||||
try {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
@@ -186,25 +247,103 @@ public class TradeOffersWebSocket extends ApiWebSocket {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static CrossChainOfferSummary produceSummary(Repository repository, ATStateData atState, Long timestamp) throws DataException {
|
||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState);
|
||||
private static void populateCurrentSummaries(Repository repository) throws DataException {
|
||||
// We want ALL OFFERING trades
|
||||
Boolean isFinished = Boolean.FALSE;
|
||||
Long expectedValue = (long) AcctMode.OFFERING.value;
|
||||
Integer minimumFinalHeight = null;
|
||||
|
||||
for (SupportedBlockchain blockchain : SupportedBlockchain.values()) {
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain);
|
||||
|
||||
CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo());
|
||||
|
||||
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||
byte[] codeHash = acctInfo.getKey().value;
|
||||
ACCT acct = acctInfo.getValue().get();
|
||||
|
||||
Integer dataByteOffset = acct.getModeByteOffset();
|
||||
List<ATStateData> initialAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash,
|
||||
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
||||
null, null, null);
|
||||
|
||||
if (initialAtStates == null)
|
||||
throw new DataException("Couldn't fetch current trades from repository");
|
||||
|
||||
// Save initial AT modes
|
||||
cachedInfo.previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> AcctMode.OFFERING)));
|
||||
|
||||
// Convert to offer summaries
|
||||
cachedInfo.currentSummaries.putAll(produceSummaries(repository, acct, initialAtStates, null).stream()
|
||||
.collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, offerSummary -> offerSummary)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void populateHistoricSummaries(Repository repository) throws DataException {
|
||||
// We want REDEEMED/REFUNDED/CANCELLED trades over the last 24 hours
|
||||
long timestamp = System.currentTimeMillis() - 24 * 60 * 60 * 1000L;
|
||||
int minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(timestamp);
|
||||
|
||||
if (minimumFinalHeight == 0)
|
||||
throw new DataException("Couldn't fetch block timestamp from repository");
|
||||
|
||||
Boolean isFinished = Boolean.TRUE;
|
||||
Integer dataByteOffset = null;
|
||||
Long expectedValue = null;
|
||||
++minimumFinalHeight; // because height is just *before* timestamp
|
||||
|
||||
for (SupportedBlockchain blockchain : SupportedBlockchain.values()) {
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain);
|
||||
|
||||
CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo());
|
||||
|
||||
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||
byte[] codeHash = acctInfo.getKey().value;
|
||||
ACCT acct = acctInfo.getValue().get();
|
||||
|
||||
List<ATStateData> historicAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash,
|
||||
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
||||
null, null, null);
|
||||
|
||||
if (historicAtStates == null)
|
||||
throw new DataException("Couldn't fetch historic trades from repository");
|
||||
|
||||
for (ATStateData historicAtState : historicAtStates) {
|
||||
CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null);
|
||||
|
||||
if (!isHistoric.test(historicOfferSummary))
|
||||
continue;
|
||||
|
||||
// Add summary to initial burst
|
||||
cachedInfo.historicSummaries.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary);
|
||||
|
||||
// Save initial AT mode
|
||||
cachedInfo.previousAtModes.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary.getMode());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, Long timestamp) throws DataException {
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState);
|
||||
|
||||
long atStateTimestamp;
|
||||
|
||||
if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING)
|
||||
if (crossChainTradeData.mode == AcctMode.OFFERING)
|
||||
// We want when trade was created, not when it was last updated
|
||||
atStateTimestamp = atState.getCreation();
|
||||
atStateTimestamp = crossChainTradeData.creationTimestamp;
|
||||
else
|
||||
atStateTimestamp = timestamp != null ? timestamp : repository.getBlockRepository().getTimestampFromHeight(atState.getHeight());
|
||||
|
||||
return new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp);
|
||||
}
|
||||
|
||||
private static List<CrossChainOfferSummary> produceSummaries(Repository repository, List<ATStateData> atStates, Long timestamp) throws DataException {
|
||||
private static List<CrossChainOfferSummary> produceSummaries(Repository repository, ACCT acct, List<ATStateData> atStates, Long timestamp) throws DataException {
|
||||
List<CrossChainOfferSummary> offerSummaries = new ArrayList<>();
|
||||
|
||||
for (ATStateData atState : atStates)
|
||||
offerSummaries.add(produceSummary(repository, atState, timestamp));
|
||||
offerSummaries.add(produceSummary(repository, acct, atState, timestamp));
|
||||
|
||||
return offerSummaries;
|
||||
}
|
||||
|
||||
@@ -51,16 +51,17 @@ public class AT {
|
||||
|
||||
MachineState machineState = new MachineState(api, loggerFactory, deployATTransactionData.getCreationBytes());
|
||||
|
||||
byte[] codeHash = Crypto.digest(machineState.getCodeBytes());
|
||||
byte[] codeBytes = machineState.getCodeBytes();
|
||||
byte[] codeHash = Crypto.digest(codeBytes);
|
||||
|
||||
this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, machineState.getCodeBytes(), codeHash,
|
||||
this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, codeBytes, codeHash,
|
||||
machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(),
|
||||
machineState.isFrozen(), machineState.getFrozenBalance());
|
||||
|
||||
byte[] stateData = machineState.toBytes();
|
||||
byte[] stateHash = Crypto.digest(stateData);
|
||||
|
||||
this.atStateData = new ATStateData(atAddress, height, creation, stateData, stateHash, 0L, true);
|
||||
this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true);
|
||||
}
|
||||
|
||||
// Getters / setters
|
||||
@@ -106,23 +107,18 @@ public class AT {
|
||||
throw new DataException(String.format("Uncaught exception while running AT '%s'", atAddress), e);
|
||||
}
|
||||
|
||||
long creation = this.atData.getCreation();
|
||||
byte[] stateData = state.toBytes();
|
||||
byte[] stateHash = Crypto.digest(stateData);
|
||||
long atFees = api.calcFinalFees(state);
|
||||
|
||||
this.atStateData = new ATStateData(atAddress, blockHeight, creation, stateData, stateHash, atFees, false);
|
||||
this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false);
|
||||
|
||||
return api.getTransactions();
|
||||
}
|
||||
|
||||
public void update(int blockHeight, long blockTimestamp) throws DataException {
|
||||
// [Re]create AT machine state using AT state data or from scratch as applicable
|
||||
QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp);
|
||||
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
|
||||
|
||||
byte[] codeBytes = this.atData.getCodeBytes();
|
||||
MachineState state = MachineState.fromBytes(api, loggerFactory, this.atStateData.getStateData(), codeBytes);
|
||||
// Extract minimal/flags-only AT machine state using AT state data
|
||||
MachineState state = MachineState.flagsOnlyfromBytes(this.atStateData.getStateData());
|
||||
|
||||
// Save latest AT state data
|
||||
this.repository.getATRepository().save(this.atStateData);
|
||||
@@ -151,12 +147,8 @@ public class AT {
|
||||
if (previousStateData == null)
|
||||
throw new DataException("Can't find previous AT state data for " + atAddress);
|
||||
|
||||
// [Re]create AT machine state using AT state data or from scratch as applicable
|
||||
QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp);
|
||||
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
|
||||
|
||||
byte[] codeBytes = this.atData.getCodeBytes();
|
||||
MachineState state = MachineState.fromBytes(api, loggerFactory, previousStateData.getStateData(), codeBytes);
|
||||
// Extract minimal/flags-only AT machine state using AT state data
|
||||
MachineState state = MachineState.flagsOnlyfromBytes(previousStateData.getStateData());
|
||||
|
||||
// Update AT info in repository
|
||||
this.atData.setIsSleeping(state.isSleeping());
|
||||
|
||||
@@ -146,7 +146,11 @@ public class QortalATAPI extends API {
|
||||
String atAddress = this.atData.getATAddress();
|
||||
|
||||
int height = timestamp.blockHeight;
|
||||
int sequence = timestamp.transactionSequence + 1;
|
||||
int sequence = timestamp.transactionSequence;
|
||||
|
||||
if (state.getCurrentBlockHeight() < BlockChain.getInstance().getAtFindNextTransactionFixHeight())
|
||||
// Off-by-one bug still in effect
|
||||
sequence += 1;
|
||||
|
||||
ATRepository.NextTransactionInfo nextTransactionInfo;
|
||||
try {
|
||||
|
||||
@@ -691,6 +691,11 @@ public final class QortalAtLogger extends ExtendedLoggerWrapper implements org.c
|
||||
logger.logIfEnabled(FQCN, ERROR, null, msgSupplier, (Throwable) null);
|
||||
}
|
||||
|
||||
/** Java 8 version */
|
||||
public void error(final java.util.function.Supplier<String> msgSupplier) {
|
||||
logger.logIfEnabled(FQCN, ERROR, null, () -> msgSupplier.get(), (Throwable) null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message (only to be constructed if the logging level is the {@code ERROR}
|
||||
* level) including the stack trace of the {@link Throwable} <code>t</code> passed as parameter.
|
||||
@@ -1375,6 +1380,11 @@ public final class QortalAtLogger extends ExtendedLoggerWrapper implements org.c
|
||||
logger.logIfEnabled(FQCN, DEBUG, null, msgSupplier, (Throwable) null);
|
||||
}
|
||||
|
||||
/** Java 8 version */
|
||||
public void debug(final java.util.function.Supplier<String> msgSupplier) {
|
||||
logger.logIfEnabled(FQCN, DEBUG, null, () -> msgSupplier.get(), (Throwable) null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message (only to be constructed if the logging level is the {@code DEBUG}
|
||||
* level) including the stack trace of the {@link Throwable} <code>t</code> passed as parameter.
|
||||
@@ -2059,6 +2069,11 @@ public final class QortalAtLogger extends ExtendedLoggerWrapper implements org.c
|
||||
logger.logIfEnabled(FQCN, ECHO, null, msgSupplier, (Throwable) null);
|
||||
}
|
||||
|
||||
/** Java 8 version */
|
||||
public void echo(final java.util.function.Supplier<String> msgSupplier) {
|
||||
logger.logIfEnabled(FQCN, ECHO, null, () -> msgSupplier.get(), (Throwable) null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message (only to be constructed if the logging level is the {@code ECHO}
|
||||
* level) including the stack trace of the {@link Throwable} <code>t</code> passed as parameter.
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.ciyam.at.ExecutionException;
|
||||
import org.ciyam.at.FunctionData;
|
||||
import org.ciyam.at.IllegalFunctionCodeException;
|
||||
import org.ciyam.at.MachineState;
|
||||
import org.qortal.crosschain.BTC;
|
||||
import org.qortal.crosschain.Bitcoin;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.settings.Settings;
|
||||
@@ -108,7 +108,7 @@ public enum QortalFunctionCode {
|
||||
CONVERT_B_TO_P2SH(0x0511, 0, false) {
|
||||
@Override
|
||||
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
|
||||
byte addressPrefix = Settings.getInstance().getBitcoinNet() == BTC.BitcoinNet.MAIN ? 0x05 : (byte) 0xc4;
|
||||
byte addressPrefix = Settings.getInstance().getBitcoinNet() == Bitcoin.BitcoinNet.MAIN ? 0x05 : (byte) 0xc4;
|
||||
|
||||
convertAddressInB(addressPrefix, state);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import static java.util.stream.Collectors.toMap;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
@@ -15,6 +17,7 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.account.Account;
|
||||
@@ -29,6 +32,7 @@ import org.qortal.controller.Controller;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.account.AccountBalanceData;
|
||||
import org.qortal.data.account.AccountData;
|
||||
import org.qortal.data.account.EligibleQoraHolderData;
|
||||
import org.qortal.data.account.QortFromQoraData;
|
||||
import org.qortal.data.account.RewardShareData;
|
||||
import org.qortal.data.at.ATData;
|
||||
@@ -53,7 +57,6 @@ import org.qortal.transform.transaction.TransactionTransformer;
|
||||
import org.qortal.utils.Amounts;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
import org.roaringbitmap.IntIterator;
|
||||
|
||||
import com.google.common.primitives.Bytes;
|
||||
import com.google.common.primitives.Longs;
|
||||
@@ -128,7 +131,7 @@ public class Block {
|
||||
|
||||
@FunctionalInterface
|
||||
private interface BlockRewardDistributor {
|
||||
long distribute(long amount, Map<Account, Long> balanceChanges) throws DataException;
|
||||
long distribute(long amount, Map<String, Long> balanceChanges) throws DataException;
|
||||
}
|
||||
|
||||
/** Lazy-instantiated expanded info on block's online accounts. */
|
||||
@@ -144,8 +147,8 @@ public class Block {
|
||||
private final Account recipientAccount;
|
||||
private final AccountData recipientAccountData;
|
||||
|
||||
ExpandedAccount(Repository repository, int accountIndex) throws DataException {
|
||||
this.rewardShareData = repository.getAccountRepository().getRewardShareByIndex(accountIndex);
|
||||
ExpandedAccount(Repository repository, RewardShareData rewardShareData) throws DataException {
|
||||
this.rewardShareData = rewardShareData;
|
||||
this.sharePercent = this.rewardShareData.getSharePercent();
|
||||
|
||||
this.mintingAccount = new Account(repository, this.rewardShareData.getMinter());
|
||||
@@ -173,27 +176,34 @@ 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<Account, Long> balanceChanges) {
|
||||
public long distribute(long accountAmount, Map<String, Long> balanceChanges) {
|
||||
if (this.isRecipientAlsoMinter) {
|
||||
// minter & recipient the same - simpler case
|
||||
LOGGER.trace(() -> String.format("Minter/recipient account %s share: %s", this.mintingAccount.getAddress(), Amounts.prettyAmount(accountAmount)));
|
||||
if (accountAmount != 0)
|
||||
balanceChanges.merge(this.mintingAccount, accountAmount, Long::sum);
|
||||
balanceChanges.merge(this.mintingAccount.getAddress(), accountAmount, Long::sum);
|
||||
} else {
|
||||
// minter & recipient different - extra work needed
|
||||
long recipientAmount = (accountAmount * this.sharePercent) / 100L / 100L; // because scaled by 2dp and 'percent' means "per 100"
|
||||
@@ -201,11 +211,11 @@ public class Block {
|
||||
|
||||
LOGGER.trace(() -> String.format("Minter account %s share: %s", this.mintingAccount.getAddress(), Amounts.prettyAmount(minterAmount)));
|
||||
if (minterAmount != 0)
|
||||
balanceChanges.merge(this.mintingAccount, minterAmount, Long::sum);
|
||||
balanceChanges.merge(this.mintingAccount.getAddress(), minterAmount, Long::sum);
|
||||
|
||||
LOGGER.trace(() -> String.format("Recipient account %s share: %s", this.recipientAccount.getAddress(), Amounts.prettyAmount(recipientAmount)));
|
||||
if (recipientAmount != 0)
|
||||
balanceChanges.merge(this.recipientAccount, recipientAmount, Long::sum);
|
||||
balanceChanges.merge(this.recipientAccount.getAddress(), recipientAmount, Long::sum);
|
||||
}
|
||||
|
||||
// We always distribute all of the amount
|
||||
@@ -215,9 +225,14 @@ public class Block {
|
||||
/** Always use getExpandedAccounts() to access this, as it's lazy-instantiated. */
|
||||
private List<ExpandedAccount> cachedExpandedAccounts = null;
|
||||
|
||||
/** Opportunistic cache of this block's valid online accounts. Only created by call to isValid(). */
|
||||
private List<OnlineAccountData> cachedValidOnlineAccounts = null;
|
||||
/** Opportunistic cache of this block's valid online reward-shares. Only created by call to isValid(). */
|
||||
private List<RewardShareData> cachedOnlineRewardShares = null;
|
||||
|
||||
// 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);
|
||||
@@ -349,12 +364,8 @@ public class Block {
|
||||
System.arraycopy(onlineAccountData.getSignature(), 0, onlineAccountsSignatures, i * Transformer.SIGNATURE_LENGTH, Transformer.SIGNATURE_LENGTH);
|
||||
}
|
||||
|
||||
byte[] minterSignature;
|
||||
try {
|
||||
minterSignature = minter.sign(BlockTransformer.getBytesForMinterSignature(parentBlockData.getMinterSignature(), minter, encodedOnlineAccounts));
|
||||
} catch (TransformationException e) {
|
||||
throw new DataException("Unable to calculate next block minter signature", e);
|
||||
}
|
||||
byte[] minterSignature = minter.sign(BlockTransformer.getBytesForMinterSignature(parentBlockData,
|
||||
minter.getPublicKey(), encodedOnlineAccounts));
|
||||
|
||||
// Qortal: minter is always a reward-share, so find actual minter and get their effective minting level
|
||||
int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, minter.getPublicKey());
|
||||
@@ -420,12 +431,8 @@ public class Block {
|
||||
int version = this.blockData.getVersion();
|
||||
byte[] reference = this.blockData.getReference();
|
||||
|
||||
byte[] minterSignature;
|
||||
try {
|
||||
minterSignature = minter.sign(BlockTransformer.getBytesForMinterSignature(parentBlockData.getMinterSignature(), minter, this.blockData.getEncodedOnlineAccounts()));
|
||||
} catch (TransformationException e) {
|
||||
throw new DataException("Unable to calculate next block's minter signature", e);
|
||||
}
|
||||
byte[] minterSignature = minter.sign(BlockTransformer.getBytesForMinterSignature(parentBlockData,
|
||||
minter.getPublicKey(), this.blockData.getEncodedOnlineAccounts()));
|
||||
|
||||
// Qortal: minter is always a reward-share, so find actual minter and get their effective minting level
|
||||
int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, minter.getPublicKey());
|
||||
@@ -564,22 +571,28 @@ public class Block {
|
||||
/**
|
||||
* Return expanded info on block's online accounts.
|
||||
* <p>
|
||||
* Typically called as part of Block.process() or Block.orphan()
|
||||
* so ideally after any calls to Block.isValid().
|
||||
*
|
||||
* @throws DataException
|
||||
*/
|
||||
public List<ExpandedAccount> getExpandedAccounts() throws DataException {
|
||||
if (this.cachedExpandedAccounts != null)
|
||||
return this.cachedExpandedAccounts;
|
||||
|
||||
ConciseSet accountIndexes = BlockTransformer.decodeOnlineAccounts(this.blockData.getEncodedOnlineAccounts());
|
||||
// We might already have a cache of online, reward-shares thanks to isValid()
|
||||
if (this.cachedOnlineRewardShares == null) {
|
||||
ConciseSet accountIndexes = BlockTransformer.decodeOnlineAccounts(this.blockData.getEncodedOnlineAccounts());
|
||||
this.cachedOnlineRewardShares = repository.getAccountRepository().getRewardSharesByIndexes(accountIndexes.toArray());
|
||||
|
||||
if (this.cachedOnlineRewardShares == null)
|
||||
throw new DataException("Online accounts invalid?");
|
||||
}
|
||||
|
||||
List<ExpandedAccount> expandedAccounts = new ArrayList<>();
|
||||
|
||||
IntIterator iterator = accountIndexes.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
int accountIndex = iterator.next();
|
||||
|
||||
ExpandedAccount accountInfo = new ExpandedAccount(repository, accountIndex);
|
||||
expandedAccounts.add(accountInfo);
|
||||
}
|
||||
for (RewardShareData rewardShare : this.cachedOnlineRewardShares)
|
||||
expandedAccounts.add(new ExpandedAccount(repository, rewardShare));
|
||||
|
||||
this.cachedExpandedAccounts = expandedAccounts;
|
||||
|
||||
@@ -732,11 +745,7 @@ public class Block {
|
||||
if (!(this.minter instanceof PrivateKeyAccount))
|
||||
throw new IllegalStateException("Block's minter is not a PrivateKeyAccount - can't sign!");
|
||||
|
||||
try {
|
||||
this.blockData.setMinterSignature(((PrivateKeyAccount) this.minter).sign(BlockTransformer.getBytesForMinterSignature(this.blockData)));
|
||||
} catch (TransformationException e) {
|
||||
throw new RuntimeException("Unable to calculate block's minter signature", e);
|
||||
}
|
||||
this.blockData.setMinterSignature(((PrivateKeyAccount) this.minter).sign(BlockTransformer.getBytesForMinterSignature(this.blockData)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -780,16 +789,49 @@ public class Block {
|
||||
return BigInteger.valueOf(blockSummaryData.getOnlineAccountsCount()).shiftLeft(ACCOUNTS_COUNT_SHIFT).add(keyDistance);
|
||||
}
|
||||
|
||||
public static BigInteger calcChainWeight(int commonBlockHeight, byte[] commonBlockSignature, List<BlockSummaryData> blockSummaries) {
|
||||
public static BigInteger calcChainWeight(int commonBlockHeight, byte[] commonBlockSignature, List<BlockSummaryData> blockSummaries, int maxHeight) {
|
||||
BigInteger cumulativeWeight = BigInteger.ZERO;
|
||||
int parentHeight = commonBlockHeight;
|
||||
byte[] parentBlockSignature = commonBlockSignature;
|
||||
NumberFormat formatter = new DecimalFormat("0.###E0");
|
||||
boolean isLogging = LOGGER.getLevel().isLessSpecificThan(Level.TRACE);
|
||||
|
||||
int blockCount = 0;
|
||||
for (BlockSummaryData blockSummaryData : blockSummaries) {
|
||||
cumulativeWeight = cumulativeWeight.shiftLeft(CHAIN_WEIGHT_SHIFT).add(calcBlockWeight(parentHeight, parentBlockSignature, blockSummaryData));
|
||||
blockCount++;
|
||||
StringBuilder stringBuilder = isLogging ? new StringBuilder(512) : null;
|
||||
|
||||
if (isLogging)
|
||||
stringBuilder.append(formatter.format(cumulativeWeight)).append(" -> ");
|
||||
|
||||
cumulativeWeight = cumulativeWeight.shiftLeft(CHAIN_WEIGHT_SHIFT);
|
||||
if (isLogging)
|
||||
stringBuilder.append(formatter.format(cumulativeWeight)).append(" + ");
|
||||
|
||||
BigInteger blockWeight = calcBlockWeight(parentHeight, parentBlockSignature, blockSummaryData);
|
||||
if (isLogging)
|
||||
stringBuilder.append("(height: ")
|
||||
.append(parentHeight + 1)
|
||||
.append(", online: ")
|
||||
.append(blockSummaryData.getOnlineAccountsCount())
|
||||
.append(") ")
|
||||
.append(formatter.format(blockWeight));
|
||||
|
||||
cumulativeWeight = cumulativeWeight.add(blockWeight);
|
||||
if (isLogging)
|
||||
stringBuilder.append(" -> ").append(formatter.format(cumulativeWeight));
|
||||
|
||||
if (isLogging && blockSummaries.size() > 1)
|
||||
LOGGER.debug(() -> stringBuilder.toString()); //NOSONAR S1612 (false positive?)
|
||||
|
||||
parentHeight = blockSummaryData.getHeight();
|
||||
parentBlockSignature = blockSummaryData.getSignature();
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -914,19 +956,9 @@ public class Block {
|
||||
if (accountIndexes.size() != this.blockData.getOnlineAccountsCount())
|
||||
return ValidationResult.ONLINE_ACCOUNTS_INVALID;
|
||||
|
||||
List<RewardShareData> expandedAccounts = new ArrayList<>();
|
||||
|
||||
IntIterator iterator = accountIndexes.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
int accountIndex = iterator.next();
|
||||
RewardShareData rewardShareData = repository.getAccountRepository().getRewardShareByIndex(accountIndex);
|
||||
|
||||
// Check that claimed online account actually exists
|
||||
if (rewardShareData == null)
|
||||
return ValidationResult.ONLINE_ACCOUNT_UNKNOWN;
|
||||
|
||||
expandedAccounts.add(rewardShareData);
|
||||
}
|
||||
List<RewardShareData> onlineRewardShares = repository.getAccountRepository().getRewardSharesByIndexes(accountIndexes.toArray());
|
||||
if (onlineRewardShares == null)
|
||||
return ValidationResult.ONLINE_ACCOUNT_UNKNOWN;
|
||||
|
||||
// If block is past a certain age then we simply assume the signatures were correct
|
||||
long signatureRequirementThreshold = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMinLifetime();
|
||||
@@ -936,28 +968,51 @@ public class Block {
|
||||
if (this.blockData.getOnlineAccountsSignatures() == null || this.blockData.getOnlineAccountsSignatures().length == 0)
|
||||
return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MISSING;
|
||||
|
||||
if (this.blockData.getOnlineAccountsSignatures().length != expandedAccounts.size() * Transformer.SIGNATURE_LENGTH)
|
||||
if (this.blockData.getOnlineAccountsSignatures().length != onlineRewardShares.size() * Transformer.SIGNATURE_LENGTH)
|
||||
return ValidationResult.ONLINE_ACCOUNT_SIGNATURES_MALFORMED;
|
||||
|
||||
// Check signatures
|
||||
List<byte[]> onlineAccountsSignatures = BlockTransformer.decodeTimestampSignatures(this.blockData.getOnlineAccountsSignatures());
|
||||
long onlineTimestamp = this.blockData.getOnlineAccountsTimestamp();
|
||||
byte[] onlineTimestampBytes = Longs.toByteArray(onlineTimestamp);
|
||||
List<OnlineAccountData> onlineAccounts = Controller.getInstance().getOnlineAccounts();
|
||||
|
||||
// If this block is much older than current online timestamp, then there's no point checking current online accounts
|
||||
List<OnlineAccountData> currentOnlineAccounts = onlineTimestamp < NTP.getTime() - Controller.ONLINE_TIMESTAMP_MODULUS
|
||||
? null
|
||||
: Controller.getInstance().getOnlineAccounts();
|
||||
List<OnlineAccountData> latestBlocksOnlineAccounts = Controller.getInstance().getLatestBlocksOnlineAccounts();
|
||||
|
||||
// Extract online accounts' timestamp signatures from block data
|
||||
List<byte[]> onlineAccountsSignatures = BlockTransformer.decodeTimestampSignatures(this.blockData.getOnlineAccountsSignatures());
|
||||
|
||||
// We'll build up a list of online accounts to hand over to Controller if block is added to chain
|
||||
// and this will become latestBlocksOnlineAccounts (above) to reduce CPU load when we process next block...
|
||||
List<OnlineAccountData> ourOnlineAccounts = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < onlineAccountsSignatures.size(); ++i) {
|
||||
byte[] signature = onlineAccountsSignatures.get(i);
|
||||
byte[] publicKey = expandedAccounts.get(i).getRewardSharePublicKey();
|
||||
byte[] publicKey = onlineRewardShares.get(i).getRewardSharePublicKey();
|
||||
|
||||
OnlineAccountData onlineAccountData = new OnlineAccountData(onlineTimestamp, signature, publicKey);
|
||||
ourOnlineAccounts.add(onlineAccountData);
|
||||
|
||||
// If signature is still current then no need to perform Ed25519 verify
|
||||
OnlineAccountData onlineAccountData = new OnlineAccountData(onlineTimestamp, signature, publicKey);
|
||||
if (onlineAccounts.remove(onlineAccountData)) // remove() is like contains() but also reduces the number to check next time
|
||||
if (currentOnlineAccounts != null && currentOnlineAccounts.remove(onlineAccountData))
|
||||
// remove() returned true, so online account still current
|
||||
// and one less entry in currentOnlineAccounts to check next time
|
||||
continue;
|
||||
|
||||
// If signature was okay in latest block then no need to perform Ed25519 verify
|
||||
if (latestBlocksOnlineAccounts != null && latestBlocksOnlineAccounts.contains(onlineAccountData))
|
||||
continue;
|
||||
|
||||
if (!Crypto.verify(publicKey, signature, onlineTimestampBytes))
|
||||
return ValidationResult.ONLINE_ACCOUNT_SIGNATURE_INCORRECT;
|
||||
}
|
||||
|
||||
// All online accounts valid, so save our list of online accounts for potential later use
|
||||
this.cachedValidOnlineAccounts = ourOnlineAccounts;
|
||||
this.cachedOnlineRewardShares = onlineRewardShares;
|
||||
|
||||
return ValidationResult.OK;
|
||||
}
|
||||
|
||||
@@ -1037,6 +1092,10 @@ public class Block {
|
||||
// Create repository savepoint here so we can rollback to it after testing transactions
|
||||
repository.setSavepoint();
|
||||
|
||||
if (this.blockData.getHeight() == 212937)
|
||||
// Apply fix for block 212937 but fix will be rolled back before we exit method
|
||||
Block212937.processFix(this);
|
||||
|
||||
for (Transaction transaction : this.getTransactions()) {
|
||||
TransactionData transactionData = transaction.getTransactionData();
|
||||
|
||||
@@ -1240,6 +1299,10 @@ public class Block {
|
||||
|
||||
// Distribute block rewards, including transaction fees, before transactions processed
|
||||
processBlockRewards();
|
||||
|
||||
if (this.blockData.getHeight() == 212937)
|
||||
// Apply fix for block 212937
|
||||
Block212937.processFix(this);
|
||||
}
|
||||
|
||||
// We're about to (test-)process a batch of transactions,
|
||||
@@ -1271,6 +1334,12 @@ public class Block {
|
||||
linkTransactionsToBlock();
|
||||
|
||||
postBlockTidy();
|
||||
|
||||
// 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 {
|
||||
@@ -1288,13 +1357,16 @@ public class Block {
|
||||
allUniqueExpandedAccounts.add(expandedAccount.recipientAccountData);
|
||||
}
|
||||
|
||||
// Decrease blocks minted count for all accounts
|
||||
// Increase blocks minted count for all accounts
|
||||
|
||||
// Batch update in repository
|
||||
repository.getAccountRepository().modifyMintedBlockCounts(allUniqueExpandedAccounts.stream().map(AccountData::getAddress).collect(Collectors.toList()), +1);
|
||||
|
||||
// Local changes and also checks for level bump
|
||||
for (AccountData accountData : allUniqueExpandedAccounts) {
|
||||
// Adjust count locally (in Java)
|
||||
accountData.setBlocksMinted(accountData.getBlocksMinted() + 1);
|
||||
|
||||
int rowCount = repository.getAccountRepository().modifyMintedBlockCount(accountData.getAddress(), +1);
|
||||
LOGGER.trace(() -> String.format("Block minter %s up to %d minted block%s (rowCount: %d)", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : ""), rowCount));
|
||||
LOGGER.trace(() -> String.format("Block minter %s up to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : "")));
|
||||
|
||||
final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment();
|
||||
|
||||
@@ -1449,6 +1521,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();
|
||||
|
||||
@@ -1462,6 +1537,10 @@ public class Block {
|
||||
// Invalidate expandedAccounts as they may have changed due to orphaning TRANSFER_PRIVS transactions, etc.
|
||||
this.cachedExpandedAccounts = null;
|
||||
|
||||
if (this.blockData.getHeight() == 212937)
|
||||
// Revert fix for block 212937
|
||||
Block212937.orphanFix(this);
|
||||
|
||||
// Block rewards, including transaction fees, removed after transactions undone
|
||||
orphanBlockRewards();
|
||||
|
||||
@@ -1474,6 +1553,9 @@ public class Block {
|
||||
this.blockData.setHeight(null);
|
||||
|
||||
postBlockTidy();
|
||||
|
||||
// Remove any cached, valid online accounts data from Controller
|
||||
Controller.getInstance().popLatestBlocksOnlineAccounts();
|
||||
}
|
||||
|
||||
protected void orphanTransactionsFromBlock() throws DataException {
|
||||
@@ -1581,12 +1663,14 @@ public class Block {
|
||||
}
|
||||
|
||||
// Decrease blocks minted count for all accounts
|
||||
|
||||
// Batch update in repository
|
||||
repository.getAccountRepository().modifyMintedBlockCounts(allUniqueExpandedAccounts.stream().map(AccountData::getAddress).collect(Collectors.toList()), -1);
|
||||
|
||||
for (AccountData accountData : allUniqueExpandedAccounts) {
|
||||
// Adjust count locally (in Java)
|
||||
accountData.setBlocksMinted(accountData.getBlocksMinted() - 1);
|
||||
|
||||
int rowCount = repository.getAccountRepository().modifyMintedBlockCount(accountData.getAddress(), -1);
|
||||
LOGGER.trace(() -> String.format("Block minter %s down to %d minted block%s (rowCount: %d)", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : ""), rowCount));
|
||||
LOGGER.trace(() -> String.format("Block minter %s down to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : "")));
|
||||
|
||||
final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment();
|
||||
|
||||
@@ -1615,7 +1699,7 @@ public class Block {
|
||||
this.distributionMethod = distributionMethod;
|
||||
}
|
||||
|
||||
public long distribute(long distibutionAmount, Map<Account, Long> balanceChanges) throws DataException {
|
||||
public long distribute(long distibutionAmount, Map<String, Long> balanceChanges) throws DataException {
|
||||
return this.distributionMethod.distribute(distibutionAmount, balanceChanges);
|
||||
}
|
||||
}
|
||||
@@ -1632,7 +1716,7 @@ public class Block {
|
||||
// Now distribute to candidates
|
||||
|
||||
// Collate all balance changes and then apply in one final step
|
||||
Map<Account, Long> balanceChanges = new HashMap<>();
|
||||
Map<String, Long> balanceChanges = new HashMap<>();
|
||||
|
||||
long remainingAmount = totalAmount;
|
||||
for (int r = 0; r < rewardCandidates.size(); ++r) {
|
||||
@@ -1657,8 +1741,10 @@ public class Block {
|
||||
}
|
||||
|
||||
// Apply balance changes
|
||||
for (Map.Entry<Account, Long> balanceChange : balanceChanges.entrySet())
|
||||
balanceChange.getKey().modifyAssetBalance(Asset.QORT, balanceChange.getValue());
|
||||
List<AccountBalanceData> accountBalanceDeltas = balanceChanges.entrySet().stream()
|
||||
.map(entry -> new AccountBalanceData(entry.getKey(), Asset.QORT, entry.getValue()))
|
||||
.collect(Collectors.toList());
|
||||
this.repository.getAccountRepository().modifyAssetBalances(accountBalanceDeltas);
|
||||
}
|
||||
|
||||
protected List<BlockRewardCandidate> determineBlockRewardCandidates(boolean isProcessingNotOrphaning) throws DataException {
|
||||
@@ -1712,7 +1798,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())
|
||||
@@ -1728,7 +1814,7 @@ public class Block {
|
||||
}
|
||||
|
||||
// Fetch list of legacy QORA holders who haven't reached their cap of QORT reward.
|
||||
List<AccountBalanceData> qoraHolders = this.repository.getAccountRepository().getEligibleLegacyQoraHolders(isProcessingNotOrphaning ? null : this.blockData.getHeight());
|
||||
List<EligibleQoraHolderData> qoraHolders = this.repository.getAccountRepository().getEligibleLegacyQoraHolders(isProcessingNotOrphaning ? null : this.blockData.getHeight());
|
||||
final boolean haveQoraHolders = !qoraHolders.isEmpty();
|
||||
final long qoraHoldersShare = BlockChain.getInstance().getQoraHoldersShare();
|
||||
|
||||
@@ -1781,7 +1867,7 @@ public class Block {
|
||||
return rewardCandidates;
|
||||
}
|
||||
|
||||
private static long distributeBlockRewardShare(long distributionAmount, List<ExpandedAccount> accounts, Map<Account, Long> balanceChanges) {
|
||||
private static long distributeBlockRewardShare(long distributionAmount, List<ExpandedAccount> accounts, Map<String, Long> balanceChanges) {
|
||||
// Collate all expanded accounts by minting account
|
||||
Map<String, List<ExpandedAccount>> accountsByMinter = new HashMap<>();
|
||||
|
||||
@@ -1810,7 +1896,7 @@ public class Block {
|
||||
return sharedAmount;
|
||||
}
|
||||
|
||||
private static long distributeBlockRewardToQoraHolders(long qoraHoldersAmount, List<AccountBalanceData> qoraHolders, Map<Account, Long> balanceChanges, Block block) throws DataException {
|
||||
private static long distributeBlockRewardToQoraHolders(long qoraHoldersAmount, List<EligibleQoraHolderData> qoraHolders, Map<String, Long> balanceChanges, Block block) throws DataException {
|
||||
final boolean isProcessingNotOrphaning = qoraHoldersAmount >= 0;
|
||||
|
||||
long qoraPerQortReward = BlockChain.getInstance().getQoraPerQortReward();
|
||||
@@ -1818,7 +1904,7 @@ public class Block {
|
||||
|
||||
long totalQoraHeld = 0;
|
||||
for (int i = 0; i < qoraHolders.size(); ++i)
|
||||
totalQoraHeld += qoraHolders.get(i).getBalance();
|
||||
totalQoraHeld += qoraHolders.get(i).getQoraBalance();
|
||||
|
||||
long finalTotalQoraHeld = totalQoraHeld;
|
||||
LOGGER.trace(() -> String.format("Total legacy QORA held: %s", Amounts.prettyAmount(finalTotalQoraHeld)));
|
||||
@@ -1831,9 +1917,13 @@ public class Block {
|
||||
BigInteger totalQoraHeldBI = BigInteger.valueOf(totalQoraHeld);
|
||||
|
||||
long sharedAmount = 0;
|
||||
// For batched update of QORT_FROM_QORA balances
|
||||
List<AccountBalanceData> newQortFromQoraBalances = new ArrayList<>();
|
||||
|
||||
for (int h = 0; h < qoraHolders.size(); ++h) {
|
||||
AccountBalanceData qoraHolder = qoraHolders.get(h);
|
||||
BigInteger qoraHolderBalanceBI = BigInteger.valueOf(qoraHolder.getBalance());
|
||||
EligibleQoraHolderData qoraHolder = qoraHolders.get(h);
|
||||
BigInteger qoraHolderBalanceBI = BigInteger.valueOf(qoraHolder.getQoraBalance());
|
||||
String qoraHolderAddress = qoraHolder.getAddress();
|
||||
|
||||
// This is where a 128bit integer library could help:
|
||||
// long holderReward = (qoraHoldersAmount * qoraHolder.getBalance()) / totalQoraHeld;
|
||||
@@ -1841,15 +1931,13 @@ public class Block {
|
||||
|
||||
final long holderRewardForLogging = holderReward;
|
||||
LOGGER.trace(() -> String.format("QORA holder %s has %s / %s QORA so share: %s",
|
||||
qoraHolder.getAddress(), Amounts.prettyAmount(qoraHolder.getBalance()), finalTotalQoraHeld, Amounts.prettyAmount(holderRewardForLogging)));
|
||||
qoraHolderAddress, Amounts.prettyAmount(qoraHolder.getQoraBalance()), finalTotalQoraHeld, Amounts.prettyAmount(holderRewardForLogging)));
|
||||
|
||||
// Too small to register this time?
|
||||
if (holderReward == 0)
|
||||
continue;
|
||||
|
||||
Account qoraHolderAccount = new Account(block.repository, qoraHolder.getAddress());
|
||||
|
||||
long newQortFromQoraBalance = qoraHolderAccount.getConfirmedBalance(Asset.QORT_FROM_QORA) + holderReward;
|
||||
long newQortFromQoraBalance = qoraHolder.getQortFromQoraBalance() + holderReward;
|
||||
|
||||
// If processing, make sure we don't overpay
|
||||
if (isProcessingNotOrphaning) {
|
||||
@@ -1863,44 +1951,43 @@ public class Block {
|
||||
newQortFromQoraBalance -= adjustment;
|
||||
|
||||
// This is also the QORA holder's final QORT-from-QORA block
|
||||
QortFromQoraData qortFromQoraData = new QortFromQoraData(qoraHolder.getAddress(), holderReward, block.blockData.getHeight());
|
||||
QortFromQoraData qortFromQoraData = new QortFromQoraData(qoraHolderAddress, holderReward, block.blockData.getHeight());
|
||||
block.repository.getAccountRepository().save(qortFromQoraData);
|
||||
|
||||
long finalAdjustedHolderReward = holderReward;
|
||||
LOGGER.trace(() -> String.format("QORA holder %s final share %s at height %d",
|
||||
qoraHolder.getAddress(), Amounts.prettyAmount(finalAdjustedHolderReward), block.blockData.getHeight()));
|
||||
qoraHolderAddress, Amounts.prettyAmount(finalAdjustedHolderReward), block.blockData.getHeight()));
|
||||
}
|
||||
} else {
|
||||
// Orphaning
|
||||
QortFromQoraData qortFromQoraData = block.repository.getAccountRepository().getQortFromQoraInfo(qoraHolder.getAddress());
|
||||
if (qortFromQoraData != null) {
|
||||
if (qoraHolder.getFinalBlockHeight() != null) {
|
||||
// Final QORT-from-QORA amount from repository was stored during processing, and hence positive.
|
||||
// So we use + here as qortFromQora is negative during orphaning.
|
||||
// More efficient than "holderReward - (0 - final-qort-from-qora)"
|
||||
long adjustment = holderReward + qortFromQoraData.getFinalQortFromQora();
|
||||
long adjustment = holderReward + qoraHolder.getFinalQortFromQora();
|
||||
|
||||
holderReward -= adjustment;
|
||||
newQortFromQoraBalance -= adjustment;
|
||||
|
||||
block.repository.getAccountRepository().deleteQortFromQoraInfo(qoraHolder.getAddress());
|
||||
block.repository.getAccountRepository().deleteQortFromQoraInfo(qoraHolderAddress);
|
||||
|
||||
long finalAdjustedHolderReward = holderReward;
|
||||
LOGGER.trace(() -> String.format("QORA holder %s final share %s was at height %d",
|
||||
qoraHolder.getAddress(), Amounts.prettyAmount(finalAdjustedHolderReward), block.blockData.getHeight()));
|
||||
qoraHolderAddress, Amounts.prettyAmount(finalAdjustedHolderReward), block.blockData.getHeight()));
|
||||
}
|
||||
}
|
||||
|
||||
balanceChanges.merge(qoraHolderAccount, holderReward, Long::sum);
|
||||
balanceChanges.merge(qoraHolderAddress, holderReward, Long::sum);
|
||||
|
||||
if (newQortFromQoraBalance > 0)
|
||||
qoraHolderAccount.setConfirmedBalance(Asset.QORT_FROM_QORA, newQortFromQoraBalance);
|
||||
else
|
||||
// Remove QORT_FROM_QORA balance as it's zero
|
||||
qoraHolderAccount.deleteBalance(Asset.QORT_FROM_QORA);
|
||||
// Add to batched QORT_FROM_QORA balance update list
|
||||
newQortFromQoraBalances.add(new AccountBalanceData(qoraHolderAddress, Asset.QORT_FROM_QORA, newQortFromQoraBalance));
|
||||
|
||||
sharedAmount += holderReward;
|
||||
}
|
||||
|
||||
// Perform batched update of QORT_FROM_QORA balances
|
||||
block.repository.getAccountRepository().setAssetBalances(newQortFromQoraBalances);
|
||||
|
||||
return sharedAmount;
|
||||
}
|
||||
|
||||
@@ -1909,4 +1996,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()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
153
src/main/java/org/qortal/block/Block212937.java
Normal file
153
src/main/java/org/qortal/block/Block212937.java
Normal file
@@ -0,0 +1,153 @@
|
||||
package org.qortal.block;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.xml.bind.JAXBContext;
|
||||
import javax.xml.bind.JAXBException;
|
||||
import javax.xml.bind.UnmarshalException;
|
||||
import javax.xml.bind.Unmarshaller;
|
||||
import javax.xml.transform.stream.StreamSource;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.eclipse.persistence.jaxb.JAXBContextFactory;
|
||||
import org.eclipse.persistence.jaxb.UnmarshallerProperties;
|
||||
import org.qortal.data.account.AccountBalanceData;
|
||||
import org.qortal.repository.DataException;
|
||||
|
||||
/**
|
||||
* Block 212937
|
||||
* <p>
|
||||
* Somehow a node minted a version of block 212937 that contained one transaction:
|
||||
* a PAYMENT transaction that attempted to spend more QORT than that account had as QORT balance.
|
||||
* <p>
|
||||
* This invalid transaction made block 212937 (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 212937 affected all new installations, regardless of whether
|
||||
* they synchronized from scratch (block 1) or used an 'official release' bootstrap.
|
||||
* <p>
|
||||
* After lengthy diagnosis, it was discovered that
|
||||
* the invalid transaction seemed to rely on incorrect balances in a corrupted database.
|
||||
* Copies of DB files containing the broken chain were also shared around, exacerbating the problem.
|
||||
* <p>
|
||||
* There were three options:
|
||||
* <ol>
|
||||
* <li>roll back the chain to last known valid block 212936 and re-mint empty blocks to current height</li>
|
||||
* <li>keep existing chain, but apply database edits at block 212937 to allow current chain to be valid</li>
|
||||
* <li>attempt to mint an alternative chain, retaining as many valid transactions as possible</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Option 1 was highly undesirable due to knock-on effects from wiping 700+ transactions, some of which
|
||||
* might have affect cross-chain trades, although there were no cross-chain trade completed during
|
||||
* the decision period.
|
||||
* <p>
|
||||
* Option 3 was essentially a slightly better version of option 1 and rejected for similar reasons.
|
||||
* Attempts at option 3 also rapidly hit cumulative problems with every replacement block due to
|
||||
* differing block timestamps making some transactions, and then even some blocks themselves, invalid.
|
||||
* <p>
|
||||
* This class is the implementation of option 2.
|
||||
* <p>
|
||||
* The change in account balances are relatively small, see <tt>block-212937-deltas.json</tt> resource
|
||||
* for actual values. These values were obtained by exporting the <tt>AccountBalances</tt> table from
|
||||
* both versions of the database with chain at block 212936, and then comparing. The values were also
|
||||
* tested by syncing both databases up to block 225500, re-exporting and re-comparing.
|
||||
* <p>
|
||||
* The invalid block 212937 signature is: <tt>2J3GVJjv...qavh6KkQ</tt>.
|
||||
* <p>
|
||||
* The invalid transaction in block 212937 is:
|
||||
* <p>
|
||||
* <code><pre>
|
||||
{
|
||||
"amount" : "0.10788294",
|
||||
"approvalStatus" : "NOT_REQUIRED",
|
||||
"blockHeight" : 212937,
|
||||
"creatorAddress" : "QLdw5uabviLJgRGkRiydAFmAtZzxHfNXSs",
|
||||
"fee" : "0.00100000",
|
||||
"recipient" : "QZi1mNHDbiLvsytxTgxDr9nhJe4pNZaWpw",
|
||||
"reference" : "J6JukdTVuXZ3JYbHatfZzwxG2vSiZwVCPDzW5K7PsVQKRj8XZeDtqnkGCGGjaSQZ9bQMtV44ky88NnGM4YBQKU6",
|
||||
"senderPublicKey" : "DBFfbD2M3uh4jPE5PaUcZVvNPfrrJzVB7seeEtBn5SPs",
|
||||
"signature" : "qkitxdCEEnKt8w6wRfFixtErbXsxWE6zG2ESNhpqBdScikV1WxeA6WZTTMJVV4tCeZdBFXw3V1X5NVztv6LirWK",
|
||||
"timestamp" : 1607863074904,
|
||||
"txGroupId" : 0,
|
||||
"type" : "PAYMENT"
|
||||
}
|
||||
</pre></code>
|
||||
* <p>
|
||||
* Account <tt>QLdw5uabviLJgRGkRiydAFmAtZzxHfNXSs</tt> attempted to spend <tt>0.10888294</tt> (including fees)
|
||||
* when their QORT balance was really only <tt>0.10886665</tt>.
|
||||
* <p>
|
||||
* However, on the broken DB nodes, their balance
|
||||
* seemed to be <tt>0.10890293</tt> which was sufficient to make the transaction valid.
|
||||
*/
|
||||
public final class Block212937 {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(Block212937.class);
|
||||
private static final String ACCOUNT_DELTAS_SOURCE = "block-212937-deltas.json";
|
||||
|
||||
private static final List<AccountBalanceData> accountDeltas = readAccountDeltas();
|
||||
|
||||
private Block212937() {
|
||||
/* Do not instantiate */
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<AccountBalanceData> readAccountDeltas() {
|
||||
Unmarshaller unmarshaller;
|
||||
|
||||
try {
|
||||
// Create JAXB context aware of classes we need to unmarshal
|
||||
JAXBContext jc = JAXBContextFactory.createContext(new Class[] {
|
||||
AccountBalanceData.class
|
||||
}, null);
|
||||
|
||||
// Create unmarshaller
|
||||
unmarshaller = jc.createUnmarshaller();
|
||||
|
||||
// Set the unmarshaller media type to JSON
|
||||
unmarshaller.setProperty(UnmarshallerProperties.MEDIA_TYPE, "application/json");
|
||||
|
||||
// Tell unmarshaller that there's no JSON root element in the JSON input
|
||||
unmarshaller.setProperty(UnmarshallerProperties.JSON_INCLUDE_ROOT, false);
|
||||
} catch (JAXBException e) {
|
||||
String message = "Failed to setup unmarshaller to read block 212937 deltas";
|
||||
LOGGER.error(message, e);
|
||||
throw new RuntimeException(message, e);
|
||||
}
|
||||
|
||||
ClassLoader classLoader = BlockChain.class.getClassLoader();
|
||||
InputStream in = classLoader.getResourceAsStream(ACCOUNT_DELTAS_SOURCE);
|
||||
StreamSource jsonSource = new StreamSource(in);
|
||||
|
||||
try {
|
||||
// Attempt to unmarshal JSON stream to BlockChain config
|
||||
return (List<AccountBalanceData>) unmarshaller.unmarshal(jsonSource, AccountBalanceData.class).getValue();
|
||||
} catch (UnmarshalException e) {
|
||||
String message = "Failed to parse block 212937 deltas";
|
||||
LOGGER.error(message, e);
|
||||
throw new RuntimeException(message, e);
|
||||
} catch (JAXBException e) {
|
||||
String message = "Unexpected JAXB issue while processing block 212937 deltas";
|
||||
LOGGER.error(message, e);
|
||||
throw new RuntimeException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void processFix(Block block) throws DataException {
|
||||
block.repository.getAccountRepository().modifyAssetBalances(accountDeltas);
|
||||
}
|
||||
|
||||
public static void orphanFix(Block block) throws DataException {
|
||||
// Create inverse deltas
|
||||
List<AccountBalanceData> inverseDeltas = accountDeltas.stream()
|
||||
.map(delta -> new AccountBalanceData(delta.getAddress(), delta.getAssetId(), 0 - delta.getBalance()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
block.repository.getAccountRepository().modifyAssetBalances(inverseDeltas);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -32,7 +32,6 @@ 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;
|
||||
import org.qortal.utils.StringLongMapXmlAdapter;
|
||||
|
||||
/**
|
||||
@@ -71,6 +70,10 @@ public class BlockChain {
|
||||
private GenesisBlock.GenesisInfo genesisInfo;
|
||||
|
||||
public enum FeatureTrigger {
|
||||
atFindNextTransactionFix,
|
||||
newBlockSigHeight,
|
||||
shareBinFix,
|
||||
calcChainWeightTimestamp;
|
||||
}
|
||||
|
||||
/** Map of which blockchain features are enabled when (height/timestamp) */
|
||||
@@ -372,6 +375,22 @@ public class BlockChain {
|
||||
|
||||
// Convenience methods for specific blockchain feature triggers
|
||||
|
||||
public int getAtFindNextTransactionFixHeight() {
|
||||
return this.featureTriggers.get(FeatureTrigger.atFindNextTransactionFix.name()).intValue();
|
||||
}
|
||||
|
||||
public int getNewBlockSigHeight() {
|
||||
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) {
|
||||
@@ -482,7 +501,7 @@ public class BlockChain {
|
||||
}
|
||||
|
||||
/**
|
||||
* Some sort start-up/initialization/checking method.
|
||||
* Some sort of start-up/initialization/checking method.
|
||||
*
|
||||
* @throws SQLException
|
||||
*/
|
||||
@@ -492,7 +511,11 @@ public class BlockChain {
|
||||
rebuildBlockchain();
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature();
|
||||
repository.checkConsistency();
|
||||
|
||||
int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - 1440, 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()));
|
||||
@@ -530,7 +553,8 @@ public class BlockChain {
|
||||
|
||||
private static void rebuildBlockchain() throws DataException {
|
||||
// (Re)build repository
|
||||
RepositoryManager.rebuild();
|
||||
if (!RepositoryManager.wasPristineAtOpen())
|
||||
RepositoryManager.rebuild();
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
GenesisBlock genesisBlock = GenesisBlock.getInstance(repository);
|
||||
@@ -552,17 +576,23 @@ public class BlockChain {
|
||||
|
||||
try {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
for (int height = repository.getBlockRepository().getBlockchainHeight(); height > targetHeight; --height) {
|
||||
int height = repository.getBlockRepository().getBlockchainHeight();
|
||||
BlockData orphanBlockData = repository.getBlockRepository().fromHeight(height);
|
||||
|
||||
while (height > targetHeight) {
|
||||
LOGGER.info(String.format("Forcably orphaning block %d", height));
|
||||
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
Block block = new Block(repository, blockData);
|
||||
Block block = new Block(repository, orphanBlockData);
|
||||
block.orphan();
|
||||
repository.saveChanges();
|
||||
}
|
||||
|
||||
BlockData lastBlockData = repository.getBlockRepository().getLastBlock();
|
||||
Controller.getInstance().setChainTip(lastBlockData);
|
||||
repository.saveChanges();
|
||||
|
||||
--height;
|
||||
orphanBlockData = repository.getBlockRepository().fromHeight(height);
|
||||
|
||||
repository.discardChanges(); // clear transaction status to prevent deadlocks
|
||||
Controller.getInstance().onOrphanedBlock(orphanBlockData);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -571,33 +601,4 @@ public class BlockChain {
|
||||
}
|
||||
}
|
||||
|
||||
public static void trimOldOnlineAccountsSignatures() {
|
||||
final Long now = NTP.getTime();
|
||||
if (now == null)
|
||||
return;
|
||||
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
if (!blockchainLock.tryLock())
|
||||
// Too busy to trim right now, try again later
|
||||
return;
|
||||
|
||||
try {
|
||||
try (final Repository repository = RepositoryManager.tryRepository()) {
|
||||
if (repository == null)
|
||||
return;
|
||||
|
||||
int numBlocksTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(now - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime());
|
||||
|
||||
if (numBlocksTrimmed > 0)
|
||||
LOGGER.debug(String.format("Trimmed old online accounts signatures from %d block%s", numBlocksTrimmed, (numBlocksTrimmed != 1 ? "s" : "")));
|
||||
|
||||
repository.saveChanges();
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to trim old online accounts signatures: %s", e.getMessage()));
|
||||
}
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
80
src/main/java/org/qortal/controller/AtStatesTrimmer.java
Normal file
80
src/main/java/org/qortal/controller/AtStatesTrimmer.java
Normal file
@@ -0,0 +1,80 @@
|
||||
package org.qortal.controller;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
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 AtStatesTrimmer implements Runnable {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(AtStatesTrimmer.class);
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Thread.currentThread().setName("AT States trimmer");
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int trimStartHeight = repository.getATRepository().getAtTrimHeight();
|
||||
|
||||
repository.getATRepository().prepareForAtStateTrimming();
|
||||
repository.saveChanges();
|
||||
|
||||
while (!Controller.isStopping()) {
|
||||
repository.discardChanges();
|
||||
|
||||
Thread.sleep(Settings.getInstance().getAtStatesTrimInterval());
|
||||
|
||||
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;
|
||||
|
||||
long currentTrimmableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime();
|
||||
// We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks
|
||||
long chainTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime();
|
||||
|
||||
long upperTrimmableTimestamp = Math.min(currentTrimmableTimestamp, chainTrimmableTimestamp);
|
||||
int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp);
|
||||
|
||||
int upperBatchHeight = trimStartHeight + Settings.getInstance().getAtStatesTrimBatchSize();
|
||||
int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight);
|
||||
|
||||
if (trimStartHeight >= upperTrimHeight)
|
||||
continue;
|
||||
|
||||
int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimStartHeight, upperTrimHeight, Settings.getInstance().getAtStatesTrimLimit());
|
||||
repository.saveChanges();
|
||||
|
||||
if (numAtStatesTrimmed > 0) {
|
||||
final int finalTrimStartHeight = trimStartHeight;
|
||||
LOGGER.debug(() -> String.format("Trimmed %d AT state%s between blocks %d and %d",
|
||||
numAtStatesTrimmed, (numAtStatesTrimmed != 1 ? "s" : ""),
|
||||
finalTrimStartHeight, upperTrimHeight));
|
||||
} else {
|
||||
// Can we move onto next batch?
|
||||
if (upperTrimmableHeight > upperBatchHeight) {
|
||||
trimStartHeight = upperBatchHeight;
|
||||
repository.getATRepository().setAtTrimHeight(trimStartHeight);
|
||||
repository.getATRepository().prepareForAtStateTrimming();
|
||||
repository.saveChanges();
|
||||
|
||||
final int finalTrimStartHeight = trimStartHeight;
|
||||
LOGGER.debug(() -> String.format("Bumping AT state base trim height to %d", finalTrimStartHeight));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to trim AT states: %s", e.getMessage()));
|
||||
} catch (InterruptedException e) {
|
||||
// Time to exit
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -214,8 +214,9 @@ public class AutoUpdate extends Thread {
|
||||
return false; // failed - try another repo
|
||||
}
|
||||
|
||||
// Give repository a chance to backup in case things go badly wrong
|
||||
RepositoryManager.backup(true);
|
||||
// Give repository a chance to backup in case things go badly wrong (if enabled)
|
||||
if (Settings.getInstance().getRepositoryBackupInterval() > 0)
|
||||
RepositoryManager.backup(true);
|
||||
|
||||
// Call ApplyUpdate to end this process (unlocking current JAR so it can be replaced)
|
||||
String javaHome = System.getProperty("java.home");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.qortal.block;
|
||||
package org.qortal.controller;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
@@ -13,8 +13,9 @@ import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.account.PrivateKeyAccount;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.block.Block.ValidationResult;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.data.account.MintingAccountData;
|
||||
import org.qortal.data.account.RewardShareData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
@@ -60,7 +61,7 @@ public class BlockMinter extends Thread {
|
||||
List<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions();
|
||||
|
||||
for (TransactionData transactionData : unconfirmedTransactions) {
|
||||
LOGGER.trace(String.format("Deleting unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
|
||||
LOGGER.trace(() -> String.format("Deleting unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
|
||||
repository.getTransactionRepository().delete(transactionData);
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@ public class BlockMinter extends Thread {
|
||||
|
||||
// Going to need this a lot...
|
||||
BlockRepository blockRepository = repository.getBlockRepository();
|
||||
Block previousBlock = null;
|
||||
BlockData previousBlockData = null;
|
||||
|
||||
List<Block> newBlocks = new ArrayList<>();
|
||||
|
||||
@@ -115,7 +116,7 @@ public class BlockMinter extends Thread {
|
||||
|
||||
RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey());
|
||||
if (rewardShareData == null) {
|
||||
// Reward-share doesn't even exist - probably not a good sign
|
||||
// Reward-share doesn't exist - probably cancelled but not yet removed from node's list of minting accounts
|
||||
madi.remove();
|
||||
continue;
|
||||
}
|
||||
@@ -134,24 +135,27 @@ 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 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)
|
||||
continue;
|
||||
|
||||
// There are enough peers with a recent block and our latest block is recent
|
||||
// so go ahead and mint a block if possible.
|
||||
isMintingPossible = true;
|
||||
|
||||
// Check blockchain hasn't changed
|
||||
if (previousBlock == null || !Arrays.equals(previousBlock.getSignature(), lastBlockData.getSignature())) {
|
||||
previousBlock = new Block(repository, lastBlockData);
|
||||
if (previousBlockData == null || !Arrays.equals(previousBlockData.getSignature(), lastBlockData.getSignature())) {
|
||||
previousBlockData = lastBlockData;
|
||||
newBlocks.clear();
|
||||
|
||||
// Reduce log timeout
|
||||
@@ -162,12 +166,20 @@ public class BlockMinter extends Thread {
|
||||
mintingAccountsData.removeIf(mintingAccountData -> newBlocks.stream().anyMatch(newBlock -> Arrays.equals(newBlock.getBlockData().getMinterPublicKey(), mintingAccountData.getPublicKey())));
|
||||
|
||||
// Do we need to build any potential new blocks?
|
||||
List<PrivateKeyAccount> mintingAccounts = mintingAccountsData.stream().map(accountData -> new PrivateKeyAccount(repository, accountData.getPrivateKey())).collect(Collectors.toList());
|
||||
List<PrivateKeyAccount> newBlocksMintingAccounts = mintingAccountsData.stream().map(accountData -> new PrivateKeyAccount(repository, accountData.getPrivateKey())).collect(Collectors.toList());
|
||||
|
||||
for (PrivateKeyAccount mintingAccount : mintingAccounts) {
|
||||
// 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()) {
|
||||
Block newBlock = Block.mint(repository, previousBlock.getBlockData(), mintingAccount);
|
||||
Block newBlock = Block.mint(repository, previousBlockData, mintingAccount);
|
||||
if (newBlock == null) {
|
||||
// For some reason we can't mint right now
|
||||
moderatedLog(() -> LOGGER.error("Couldn't build a to-be-minted block"));
|
||||
@@ -195,7 +207,7 @@ public class BlockMinter extends Thread {
|
||||
// Make sure we're the only thread modifying the blockchain
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
if (!blockchainLock.tryLock(30, TimeUnit.SECONDS)) {
|
||||
LOGGER.warn("Couldn't acquire blockchain lock even after waiting 30 seconds");
|
||||
LOGGER.debug("Couldn't acquire blockchain lock even after waiting 30 seconds");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -233,8 +245,8 @@ public class BlockMinter extends Thread {
|
||||
continue;
|
||||
|
||||
// Pick best block
|
||||
final int parentHeight = previousBlock.getBlockData().getHeight();
|
||||
final byte[] parentBlockSignature = previousBlock.getSignature();
|
||||
final int parentHeight = previousBlockData.getHeight();
|
||||
final byte[] parentBlockSignature = previousBlockData.getSignature();
|
||||
|
||||
BigInteger bestWeight = null;
|
||||
|
||||
@@ -274,28 +286,33 @@ public class BlockMinter extends Thread {
|
||||
try {
|
||||
newBlock.process();
|
||||
|
||||
LOGGER.info(String.format("Minted new block: %d", newBlock.getBlockData().getHeight()));
|
||||
repository.saveChanges();
|
||||
|
||||
LOGGER.info(String.format("Minted new block: %d", newBlock.getBlockData().getHeight()));
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
repository.saveChanges();
|
||||
|
||||
// Notify controller
|
||||
// Notify network after we're released blockchain lock
|
||||
newBlockMinted = true;
|
||||
|
||||
// Notify Controller
|
||||
repository.discardChanges(); // clear transaction status to prevent deadlocks
|
||||
Controller.getInstance().onNewBlock(newBlock.getBlockData());
|
||||
} catch (DataException e) {
|
||||
// Unable to process block - report and discard
|
||||
LOGGER.error("Unable to process newly minted block?", e);
|
||||
@@ -305,8 +322,13 @@ public class BlockMinter extends Thread {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
|
||||
if (newBlockMinted)
|
||||
Controller.getInstance().onNewBlock(newBlock.getBlockData());
|
||||
if (newBlockMinted) {
|
||||
// Broadcast our new chain to network
|
||||
BlockData newBlockData = newBlock.getBlockData();
|
||||
|
||||
Network network = Network.getInstance();
|
||||
network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newBlockData));
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn("Repository issue while running block minter", e);
|
||||
@@ -1,61 +0,0 @@
|
||||
package org.qortal.controller;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.qortal.api.model.BlockInfo;
|
||||
import org.qortal.data.block.BlockData;
|
||||
|
||||
public class BlockNotifier {
|
||||
|
||||
private static BlockNotifier instance;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Listener {
|
||||
void notify(BlockInfo blockInfo);
|
||||
}
|
||||
|
||||
private Map<Session, Listener> listenersBySession = new HashMap<>();
|
||||
|
||||
private BlockNotifier() {
|
||||
}
|
||||
|
||||
public static synchronized BlockNotifier getInstance() {
|
||||
if (instance == null)
|
||||
instance = new BlockNotifier();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void register(Session session, Listener listener) {
|
||||
synchronized (this.listenersBySession) {
|
||||
this.listenersBySession.put(session, listener);
|
||||
}
|
||||
}
|
||||
|
||||
public void deregister(Session session) {
|
||||
synchronized (this.listenersBySession) {
|
||||
this.listenersBySession.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
public void onNewBlock(BlockData blockData) {
|
||||
// Convert BlockData to BlockInfo
|
||||
BlockInfo blockInfo = new BlockInfo(blockData);
|
||||
|
||||
for (Listener listener : getAllListeners())
|
||||
listener.notify(blockInfo);
|
||||
}
|
||||
|
||||
private Collection<Listener> getAllListeners() {
|
||||
// Make a copy of listeners to both avoid concurrent modification
|
||||
// and reduce synchronization time
|
||||
synchronized (this.listenersBySession) {
|
||||
return new ArrayList<>(this.listenersBySession.values());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,21 +9,31 @@ import java.security.Security;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.atomic.AtomicLong;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
@@ -34,9 +44,9 @@ import org.qortal.account.PublicKeyAccount;
|
||||
import org.qortal.api.ApiService;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.block.BlockMinter;
|
||||
import org.qortal.block.BlockChain.BlockTimingByHeight;
|
||||
import org.qortal.controller.Synchronizer.SynchronizationResult;
|
||||
import org.qortal.controller.tradebot.TradeBot;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.account.MintingAccountData;
|
||||
import org.qortal.data.account.RewardShareData;
|
||||
@@ -48,6 +58,8 @@ import org.qortal.data.network.PeerData;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData.DataType;
|
||||
import org.qortal.event.Event;
|
||||
import org.qortal.event.EventBus;
|
||||
import org.qortal.data.transaction.ChatTransactionData;
|
||||
import org.qortal.globalization.Translator;
|
||||
import org.qortal.gui.Gui;
|
||||
@@ -55,8 +67,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,6 +95,7 @@ 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;
|
||||
|
||||
@@ -108,12 +121,15 @@ 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
|
||||
private static final long ONLINE_ACCOUNTS_BROADCAST_INTERVAL = 1 * 60 * 1000L; // ms
|
||||
private static final long ONLINE_TIMESTAMP_MODULUS = 5 * 60 * 1000L;
|
||||
public static final long ONLINE_TIMESTAMP_MODULUS = 5 * 60 * 1000L;
|
||||
private static final long LAST_SEEN_EXPIRY_PERIOD = (ONLINE_TIMESTAMP_MODULUS * 2) + (1 * 60 * 1000L);
|
||||
/** How many (latest) blocks' worth of online accounts we cache */
|
||||
private static final int MAX_BLOCKS_CACHED_ONLINE_ACCOUNTS = 2;
|
||||
|
||||
private static volatile boolean isStopping = false;
|
||||
private static BlockMinter blockMinter = null;
|
||||
@@ -128,16 +144,30 @@ public class Controller extends Thread {
|
||||
private ExecutorService callbackExecutor = Executors.newFixedThreadPool(3);
|
||||
private volatile boolean notifyGroupMembershipChange = false;
|
||||
|
||||
private volatile BlockData chainTip = null;
|
||||
/** Latest blocks on our chain. Note: tail/last is the latest block. */
|
||||
private final Deque<BlockData> latestBlocks = new LinkedList<>();
|
||||
|
||||
/** Cache of BlockMessages, indexed by block signature */
|
||||
@SuppressWarnings("serial")
|
||||
private final LinkedHashMap<ByteArray, CachedBlockMessage> blockMessageCache = new LinkedHashMap<>() {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<ByteArray, CachedBlockMessage> eldest) {
|
||||
return this.size() > Settings.getInstance().getBlockCacheSize();
|
||||
}
|
||||
};
|
||||
|
||||
private long repositoryBackupTimestamp = startTime; // ms
|
||||
private long repositoryCheckpointTimestamp = startTime; // ms
|
||||
private long ntpCheckTimestamp = startTime; // ms
|
||||
private long deleteExpiredTimestamp = startTime + DELETE_EXPIRED_INTERVAL; // ms
|
||||
|
||||
private long onlineAccountsTasksTimestamp = startTime + ONLINE_ACCOUNTS_TASKS_INTERVAL; // ms
|
||||
|
||||
/** Whether we can mint new blocks, as reported by BlockMinter. */
|
||||
private volatile boolean isMintingPossible = false;
|
||||
|
||||
/** Synchronization object for sync variables below */
|
||||
private final Object syncLock = new Object();
|
||||
/** Whether we are attempting to synchronize. */
|
||||
private volatile boolean isSynchronizing = false;
|
||||
/** Temporary estimate of synchronization progress for SysTray use. */
|
||||
@@ -146,6 +176,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>
|
||||
@@ -168,8 +203,51 @@ public class Controller extends Thread {
|
||||
/** Lock for only allowing one blockchain-modifying codepath at a time. e.g. synchronization or newly minted block. */
|
||||
private final ReentrantLock blockchainLock = new ReentrantLock();
|
||||
|
||||
/** Cache of 'online accounts' */
|
||||
/** Cache of current 'online accounts' */
|
||||
List<OnlineAccountData> onlineAccounts = new ArrayList<>();
|
||||
/** Cache of latest blocks' online accounts */
|
||||
Deque<List<OnlineAccountData>> latestBlocksOnlineAccounts = new ArrayDeque<>(MAX_BLOCKS_CACHED_ONLINE_ACCOUNTS);
|
||||
|
||||
// Stats
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public static class StatsSnapshot {
|
||||
public static class GetBlockMessageStats {
|
||||
public AtomicLong requests = new AtomicLong();
|
||||
public AtomicLong cacheHits = new AtomicLong();
|
||||
public AtomicLong unknownBlocks = new AtomicLong();
|
||||
public AtomicLong cacheFills = new AtomicLong();
|
||||
|
||||
public GetBlockMessageStats() {
|
||||
}
|
||||
}
|
||||
public GetBlockMessageStats getBlockMessageStats = new GetBlockMessageStats();
|
||||
|
||||
public static class GetBlockSummariesStats {
|
||||
public AtomicLong requests = new AtomicLong();
|
||||
public AtomicLong cacheHits = new AtomicLong();
|
||||
public AtomicLong fullyFromCache = new AtomicLong();
|
||||
|
||||
public GetBlockSummariesStats() {
|
||||
}
|
||||
}
|
||||
public GetBlockSummariesStats getBlockSummariesStats = new GetBlockSummariesStats();
|
||||
|
||||
public static class GetBlockSignaturesV2Stats {
|
||||
public AtomicLong requests = new AtomicLong();
|
||||
public AtomicLong cacheHits = new AtomicLong();
|
||||
public AtomicLong fullyFromCache = new AtomicLong();
|
||||
|
||||
public GetBlockSignaturesV2Stats() {
|
||||
}
|
||||
}
|
||||
public GetBlockSignaturesV2Stats getBlockSignaturesV2Stats = new GetBlockSignaturesV2Stats();
|
||||
|
||||
public AtomicLong latestBlocksCacheRefills = new AtomicLong();
|
||||
|
||||
public StatsSnapshot() {
|
||||
}
|
||||
}
|
||||
private final StatsSnapshot stats = new StatsSnapshot();
|
||||
|
||||
// Constructors
|
||||
|
||||
@@ -226,21 +304,37 @@ public class Controller extends Thread {
|
||||
|
||||
/** Returns current blockchain height, or 0 if it's not available. */
|
||||
public int getChainHeight() {
|
||||
BlockData blockData = this.chainTip;
|
||||
if (blockData == null)
|
||||
return 0;
|
||||
synchronized (this.latestBlocks) {
|
||||
BlockData blockData = this.latestBlocks.peekLast();
|
||||
if (blockData == null)
|
||||
return 0;
|
||||
|
||||
return blockData.getHeight();
|
||||
return blockData.getHeight();
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns highest block, or null if it's not available. */
|
||||
public BlockData getChainTip() {
|
||||
return this.chainTip;
|
||||
synchronized (this.latestBlocks) {
|
||||
return this.latestBlocks.peekLast();
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache new blockchain tip. */
|
||||
public void setChainTip(BlockData blockData) {
|
||||
this.chainTip = blockData;
|
||||
public void refillLatestBlocksCache() throws DataException {
|
||||
// Set initial chain height/tip
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
BlockData blockData = repository.getBlockRepository().getLastBlock();
|
||||
int blockCacheSize = Settings.getInstance().getBlockCacheSize();
|
||||
|
||||
synchronized (this.latestBlocks) {
|
||||
this.latestBlocks.clear();
|
||||
|
||||
for (int i = 0; i < blockCacheSize && blockData != null; ++i) {
|
||||
this.latestBlocks.addFirst(blockData);
|
||||
blockData = repository.getBlockRepository().fromHeight(blockData.getHeight() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ReentrantLock getBlockchainLock() {
|
||||
@@ -265,7 +359,13 @@ public class Controller extends Thread {
|
||||
}
|
||||
|
||||
public Integer getSyncPercent() {
|
||||
return this.isSynchronizing ? this.syncPercent : null;
|
||||
synchronized (this.syncLock) {
|
||||
return this.isSynchronizing ? this.syncPercent : null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean getRecoveryMode() {
|
||||
return this.recoveryMode;
|
||||
}
|
||||
|
||||
// Entry point
|
||||
@@ -320,13 +420,8 @@ public class Controller extends Thread {
|
||||
try {
|
||||
BlockChain.validate();
|
||||
|
||||
// Set initial chain height/tip
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
BlockData blockData = repository.getBlockRepository().getLastBlock();
|
||||
|
||||
Controller.getInstance().setChainTip(blockData);
|
||||
LOGGER.info(String.format("Our chain height at start-up: %d", blockData.getHeight()));
|
||||
}
|
||||
Controller.getInstance().refillLatestBlocksCache();
|
||||
LOGGER.info(String.format("Our chain height at start-up: %d", Controller.getInstance().getChainHeight()));
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Couldn't validate blockchain", e);
|
||||
Gui.getInstance().fatalError("Blockchain validation issue", e);
|
||||
@@ -360,6 +455,9 @@ public class Controller extends Thread {
|
||||
blockMinter = new BlockMinter();
|
||||
blockMinter.start();
|
||||
|
||||
LOGGER.info("Starting trade-bot");
|
||||
TradeBot.getInstance();
|
||||
|
||||
// Arbitrary transaction data manager
|
||||
// LOGGER.info("Starting arbitrary-transaction data manager");
|
||||
// ArbitraryDataManager.getInstance().start();
|
||||
@@ -398,6 +496,11 @@ public class Controller extends Thread {
|
||||
Thread.currentThread().setName("Controller");
|
||||
|
||||
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());
|
||||
|
||||
try {
|
||||
while (!isStopping) {
|
||||
@@ -439,6 +542,13 @@ public class Controller extends Thread {
|
||||
final long requestMinimumTimestamp = now - ARBITRARY_REQUEST_TIMEOUT;
|
||||
arbitraryDataRequests.entrySet().removeIf(entry -> entry.getValue().getC() < requestMinimumTimestamp);
|
||||
|
||||
// Time to 'checkpoint' uncommitted repository writes?
|
||||
if (now >= repositoryCheckpointTimestamp + repositoryCheckpointInterval) {
|
||||
repositoryCheckpointTimestamp = now + repositoryCheckpointInterval;
|
||||
|
||||
RepositoryManager.setRequestedCheckpoint(Boolean.TRUE);
|
||||
}
|
||||
|
||||
// Give repository a chance to backup (if enabled)
|
||||
if (repositoryBackupInterval > 0 && now >= repositoryBackupTimestamp + repositoryBackupInterval) {
|
||||
repositoryBackupTimestamp = now + repositoryBackupInterval;
|
||||
@@ -471,7 +581,17 @@ public class Controller extends Thread {
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// Clear interrupted flag so we can shutdown trim threads
|
||||
Thread.interrupted();
|
||||
// Fall-through to exit
|
||||
} finally {
|
||||
trimExecutor.shutdownNow();
|
||||
|
||||
try {
|
||||
trimExecutor.awaitTermination(2L, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
// We tried...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +623,16 @@ 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)
|
||||
return;
|
||||
|
||||
List<Peer> peers = Network.getInstance().getHandshakedPeers();
|
||||
|
||||
// Disregard peers that have "misbehaved" recently
|
||||
@@ -515,6 +644,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;
|
||||
@@ -525,9 +665,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.info(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.info(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);
|
||||
@@ -536,14 +698,21 @@ public class Controller extends Thread {
|
||||
}
|
||||
|
||||
public SynchronizationResult actuallySynchronize(Peer peer, boolean force) throws InterruptedException {
|
||||
syncPercent = (this.chainTip.getHeight() * 100) / peer.getChainTipData().getLastHeight();
|
||||
// Only update SysTray if we're potentially changing height
|
||||
if (syncPercent < 100) {
|
||||
isSynchronizing = true;
|
||||
updateSysTray();
|
||||
boolean hasStatusChanged = false;
|
||||
BlockData priorChainTip = this.getChainTip();
|
||||
|
||||
synchronized (this.syncLock) {
|
||||
this.syncPercent = (priorChainTip.getHeight() * 100) / peer.getChainTipData().getLastHeight();
|
||||
|
||||
// Only update SysTray if we're potentially changing height
|
||||
if (this.syncPercent < 100) {
|
||||
this.isSynchronizing = true;
|
||||
hasStatusChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
BlockData priorChainTip = this.chainTip;
|
||||
if (hasStatusChanged)
|
||||
updateSysTray();
|
||||
|
||||
try {
|
||||
SynchronizationResult syncResult = Synchronizer.getInstance().synchronize(peer, force);
|
||||
@@ -613,8 +782,8 @@ public class Controller extends Thread {
|
||||
// Reset our cache of inferior chains
|
||||
inferiorChainSignatures.clear();
|
||||
|
||||
// Update chain-tip, systray, notify peers, websockets, etc.
|
||||
this.onNewBlock(newChainTip);
|
||||
Network network = Network.getInstance();
|
||||
network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newChainTip));
|
||||
}
|
||||
|
||||
return syncResult;
|
||||
@@ -623,6 +792,51 @@ public class Controller extends Thread {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSysTray() {
|
||||
if (NTP.getTime() == null) {
|
||||
SysTray.getInstance().setToolTipText(Translator.INSTANCE.translate("SysTray", "SYNCHRONIZING_CLOCK"));
|
||||
@@ -637,20 +851,23 @@ public class Controller extends Thread {
|
||||
String heightText = Translator.INSTANCE.translate("SysTray", "BLOCK_HEIGHT");
|
||||
|
||||
String actionText;
|
||||
if (isMintingPossible)
|
||||
actionText = Translator.INSTANCE.translate("SysTray", "MINTING_ENABLED");
|
||||
else if (isSynchronizing)
|
||||
actionText = String.format("%s - %d%%", Translator.INSTANCE.translate("SysTray", "SYNCHRONIZING_BLOCKCHAIN"), syncPercent);
|
||||
else if (numberOfPeers < Settings.getInstance().getMinBlockchainPeers())
|
||||
actionText = Translator.INSTANCE.translate("SysTray", "CONNECTING");
|
||||
else
|
||||
actionText = Translator.INSTANCE.translate("SysTray", "MINTING_DISABLED");
|
||||
|
||||
String tooltip = String.format("%s - %d %s - %s %d", actionText, numberOfPeers, connectionsText, heightText, height);
|
||||
synchronized (this.syncLock) {
|
||||
if (this.isMintingPossible)
|
||||
actionText = Translator.INSTANCE.translate("SysTray", "MINTING_ENABLED");
|
||||
else if (this.isSynchronizing)
|
||||
actionText = String.format("%s - %d%%", Translator.INSTANCE.translate("SysTray", "SYNCHRONIZING_BLOCKCHAIN"), this.syncPercent);
|
||||
else if (numberOfPeers < Settings.getInstance().getMinBlockchainPeers())
|
||||
actionText = Translator.INSTANCE.translate("SysTray", "CONNECTING");
|
||||
else
|
||||
actionText = Translator.INSTANCE.translate("SysTray", "MINTING_DISABLED");
|
||||
}
|
||||
|
||||
String tooltip = String.format("%s - %d %s - %s %d", actionText, numberOfPeers, connectionsText, heightText, height) + "\n" + String.format("Build version: %s", this.buildVersion);
|
||||
SysTray.getInstance().setToolTipText(tooltip);
|
||||
|
||||
this.callbackExecutor.execute(() -> {
|
||||
StatusNotifier.getInstance().onStatusChange(NTP.getTime());
|
||||
EventBus.INSTANCE.notify(new StatusChangeEvent());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -666,15 +883,21 @@ public class Controller extends Thread {
|
||||
|
||||
List<TransactionData> transactions = repository.getTransactionRepository().getUnconfirmedTransactions();
|
||||
|
||||
for (TransactionData transactionData : transactions)
|
||||
if (now >= Transaction.getDeadline(transactionData)) {
|
||||
LOGGER.info(String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
|
||||
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())));
|
||||
repository.getTransactionRepository().delete(transactionData);
|
||||
}
|
||||
}
|
||||
|
||||
repository.saveChanges();
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Repository issue while deleting expired unconfirmed transactions", e);
|
||||
if (RepositoryManager.isDeadlockRelated(e))
|
||||
LOGGER.info("Couldn't delete some expired, unconfirmed transactions this round");
|
||||
else
|
||||
LOGGER.error("Repository issue while deleting expired unconfirmed transactions", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,32 +1000,154 @@ public class Controller extends Thread {
|
||||
requestSysTrayUpdate = true;
|
||||
}
|
||||
|
||||
public void onNewBlock(BlockData latestBlockData) {
|
||||
this.setChainTip(latestBlockData);
|
||||
requestSysTrayUpdate = true;
|
||||
public static class NewBlockEvent implements Event {
|
||||
private final BlockData blockData;
|
||||
|
||||
// Broadcast our new height info and notify websocket listeners
|
||||
this.callbackExecutor.execute(() -> {
|
||||
Network network = Network.getInstance();
|
||||
network.broadcast(peer -> network.buildHeightMessage(peer, latestBlockData));
|
||||
public NewBlockEvent(BlockData blockData) {
|
||||
this.blockData = blockData;
|
||||
}
|
||||
|
||||
BlockNotifier.getInstance().onNewBlock(latestBlockData);
|
||||
|
||||
if (this.notifyGroupMembershipChange) {
|
||||
this.notifyGroupMembershipChange = false;
|
||||
ChatNotifier.getInstance().onGroupMembershipChange();
|
||||
}
|
||||
|
||||
// Trade-bot might want to perform some actions too
|
||||
TradeBot.getInstance().onChainTipChange();
|
||||
});
|
||||
public BlockData getBlockData() {
|
||||
return this.blockData;
|
||||
}
|
||||
}
|
||||
|
||||
/** Callback for when we've received a new transaction via API or peer. */
|
||||
public void onNewTransaction(TransactionData transactionData, Peer peer) {
|
||||
/**
|
||||
* Callback for when we've received a new block.
|
||||
* <p>
|
||||
* See <b>WARNING</b> for {@link EventBus#notify(Event)}
|
||||
* to prevent deadlocks.
|
||||
*/
|
||||
public void onNewBlock(BlockData latestBlockData) {
|
||||
// Protective copy
|
||||
BlockData blockDataCopy = new BlockData(latestBlockData);
|
||||
int blockCacheSize = Settings.getInstance().getBlockCacheSize();
|
||||
|
||||
synchronized (this.latestBlocks) {
|
||||
BlockData cachedChainTip = this.latestBlocks.peekLast();
|
||||
|
||||
if (cachedChainTip != null && Arrays.equals(cachedChainTip.getSignature(), blockDataCopy.getReference())) {
|
||||
// Chain tip is parent for new latest block, so we can safely add new latest block
|
||||
this.latestBlocks.addLast(latestBlockData);
|
||||
|
||||
// Trim if necessary
|
||||
if (this.latestBlocks.size() >= blockCacheSize)
|
||||
this.latestBlocks.pollFirst();
|
||||
} else {
|
||||
if (cachedChainTip != null)
|
||||
// Chain tip didn't match - potentially abnormal behaviour?
|
||||
LOGGER.debug(() -> String.format("Cached chain tip %.8s not parent for new latest block %.8s (reference %.8s)",
|
||||
Base58.encode(cachedChainTip.getSignature()),
|
||||
Base58.encode(blockDataCopy.getSignature()),
|
||||
Base58.encode(blockDataCopy.getReference())));
|
||||
|
||||
// Defensively rebuild cache
|
||||
try {
|
||||
this.stats.latestBlocksCacheRefills.incrementAndGet();
|
||||
|
||||
this.refillLatestBlocksCache();
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(() -> "Couldn't refill latest blocks cache?", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.onNewOrOrphanedBlock(blockDataCopy, NewBlockEvent::new);
|
||||
}
|
||||
|
||||
public static class OrphanedBlockEvent implements Event {
|
||||
private final BlockData blockData;
|
||||
|
||||
public OrphanedBlockEvent(BlockData blockData) {
|
||||
this.blockData = blockData;
|
||||
}
|
||||
|
||||
public BlockData getBlockData() {
|
||||
return this.blockData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when we've orphaned a block.
|
||||
* <p>
|
||||
* See <b>WARNING</b> for {@link EventBus#notify(Event)}
|
||||
* to prevent deadlocks.
|
||||
*/
|
||||
public void onOrphanedBlock(BlockData latestBlockData) {
|
||||
// Protective copy
|
||||
BlockData blockDataCopy = new BlockData(latestBlockData);
|
||||
|
||||
synchronized (this.latestBlocks) {
|
||||
BlockData cachedChainTip = this.latestBlocks.pollLast();
|
||||
boolean refillNeeded = false;
|
||||
|
||||
if (cachedChainTip != null && Arrays.equals(cachedChainTip.getReference(), blockDataCopy.getSignature())) {
|
||||
// Chain tip was parent for new latest block that has been orphaned, so we're good
|
||||
|
||||
// However, if we've emptied the cache then we will need to refill it
|
||||
refillNeeded = this.latestBlocks.isEmpty();
|
||||
} else {
|
||||
if (cachedChainTip != null)
|
||||
// Chain tip didn't match - potentially abnormal behaviour?
|
||||
LOGGER.debug(() -> String.format("Cached chain tip %.8s (reference %.8s) was not parent for new latest block %.8s",
|
||||
Base58.encode(cachedChainTip.getSignature()),
|
||||
Base58.encode(cachedChainTip.getReference()),
|
||||
Base58.encode(blockDataCopy.getSignature())));
|
||||
|
||||
// Defensively rebuild cache
|
||||
refillNeeded = true;
|
||||
}
|
||||
|
||||
if (refillNeeded)
|
||||
try {
|
||||
this.stats.latestBlocksCacheRefills.incrementAndGet();
|
||||
|
||||
this.refillLatestBlocksCache();
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(() -> "Couldn't refill latest blocks cache?", e);
|
||||
}
|
||||
}
|
||||
|
||||
this.onNewOrOrphanedBlock(blockDataCopy, OrphanedBlockEvent::new);
|
||||
}
|
||||
|
||||
private void onNewOrOrphanedBlock(BlockData blockDataCopy, Function<BlockData, Event> eventConstructor) {
|
||||
requestSysTrayUpdate = true;
|
||||
|
||||
// Notify listeners, trade-bot, etc.
|
||||
EventBus.INSTANCE.notify(eventConstructor.apply(blockDataCopy));
|
||||
|
||||
if (this.notifyGroupMembershipChange) {
|
||||
this.notifyGroupMembershipChange = false;
|
||||
ChatNotifier.getInstance().onGroupMembershipChange();
|
||||
}
|
||||
}
|
||||
|
||||
public static class NewTransactionEvent implements Event {
|
||||
private final TransactionData transactionData;
|
||||
|
||||
public NewTransactionEvent(TransactionData transactionData) {
|
||||
this.transactionData = transactionData;
|
||||
}
|
||||
|
||||
public TransactionData getTransactionData() {
|
||||
return this.transactionData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when we've received a new transaction via API or peer.
|
||||
* <p>
|
||||
* @implSpec performs actions in a new thread
|
||||
*/
|
||||
public void onNewTransaction(TransactionData transactionData) {
|
||||
this.callbackExecutor.execute(() -> {
|
||||
// Notify all peers (except maybe peer that sent it to us if applicable)
|
||||
Network.getInstance().broadcast(broadcastPeer -> broadcastPeer == peer ? null : new TransactionSignaturesMessage(Arrays.asList(transactionData.getSignature())));
|
||||
// Notify all peers
|
||||
Message newTransactionSignatureMessage = new TransactionSignaturesMessage(Arrays.asList(transactionData.getSignature()));
|
||||
Network.getInstance().broadcast(broadcastPeer -> newTransactionSignatureMessage);
|
||||
|
||||
// Notify listeners
|
||||
EventBus.INSTANCE.notify(new NewTransactionEvent(transactionData));
|
||||
|
||||
// If this is a CHAT transaction, there may be extra listeners to notify
|
||||
if (transactionData.getType() == TransactionType.CHAT)
|
||||
@@ -890,21 +1235,62 @@ public class Controller extends Thread {
|
||||
private void onNetworkGetBlockMessage(Peer peer, Message message) {
|
||||
GetBlockMessage getBlockMessage = (GetBlockMessage) message;
|
||||
byte[] signature = getBlockMessage.getSignature();
|
||||
this.stats.getBlockMessageStats.requests.incrementAndGet();
|
||||
|
||||
ByteArray signatureAsByteArray = new ByteArray(signature);
|
||||
|
||||
CachedBlockMessage cachedBlockMessage = this.blockMessageCache.get(signatureAsByteArray);
|
||||
int blockCacheSize = Settings.getInstance().getBlockCacheSize();
|
||||
|
||||
// Check cached latest block message
|
||||
if (cachedBlockMessage != null) {
|
||||
this.stats.getBlockMessageStats.cacheHits.incrementAndGet();
|
||||
|
||||
// We need to duplicate it to prevent multiple threads setting ID on the same message
|
||||
CachedBlockMessage clonedBlockMessage = cachedBlockMessage.cloneWithNewId(message.getId());
|
||||
|
||||
if (!peer.sendMessage(clonedBlockMessage))
|
||||
peer.disconnect("failed to send block");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
BlockData blockData = repository.getBlockRepository().fromSignature(signature);
|
||||
|
||||
if (blockData == null) {
|
||||
LOGGER.debug(() -> String.format("Ignoring GET_BLOCK request from peer %s for unknown block %s", peer, Base58.encode(signature)));
|
||||
// Send no response at all???
|
||||
// We don't have this block
|
||||
this.stats.getBlockMessageStats.unknownBlocks.getAndIncrement();
|
||||
|
||||
// Send valid, yet unexpected message type in response, so peer's synchronizer doesn't have to wait for timeout
|
||||
LOGGER.debug(() -> String.format("Sending 'block unknown' response to peer %s for GET_BLOCK request for unknown block %s", peer, Base58.encode(signature)));
|
||||
|
||||
// We'll send empty block summaries message as it's very short
|
||||
Message blockUnknownMessage = new BlockSummariesMessage(Collections.emptyList());
|
||||
blockUnknownMessage.setId(message.getId());
|
||||
if (!peer.sendMessage(blockUnknownMessage))
|
||||
peer.disconnect("failed to send block-unknown response");
|
||||
return;
|
||||
}
|
||||
|
||||
Block block = new Block(repository, blockData);
|
||||
|
||||
Message blockMessage = new BlockMessage(block);
|
||||
CachedBlockMessage blockMessage = new CachedBlockMessage(block);
|
||||
blockMessage.setId(message.getId());
|
||||
if (!peer.sendMessage(blockMessage))
|
||||
|
||||
// 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);
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.error(String.format("Repository issue while send block %s to peer %s", Base58.encode(signature), peer), e);
|
||||
}
|
||||
@@ -914,6 +1300,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);
|
||||
|
||||
@@ -943,67 +1341,117 @@ 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();
|
||||
}
|
||||
|
||||
// Notify controller so it can notify other peers, etc.
|
||||
Controller.getInstance().onNewTransaction(transactionData, peer);
|
||||
}
|
||||
|
||||
private void onNetworkGetBlockSummariesMessage(Peer peer, Message message) {
|
||||
GetBlockSummariesMessage getBlockSummariesMessage = (GetBlockSummariesMessage) message;
|
||||
byte[] parentSignature = getBlockSummariesMessage.getParentSignature();
|
||||
final byte[] parentSignature = getBlockSummariesMessage.getParentSignature();
|
||||
this.stats.getBlockSummariesStats.requests.incrementAndGet();
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<BlockSummaryData> blockSummaries = new ArrayList<>();
|
||||
|
||||
int numberRequested = Math.min(Network.MAX_BLOCK_SUMMARIES_PER_REPLY, getBlockSummariesMessage.getNumberRequested());
|
||||
|
||||
do {
|
||||
BlockData blockData = repository.getBlockRepository().fromReference(parentSignature);
|
||||
|
||||
if (blockData == null)
|
||||
// No more blocks to send to peer
|
||||
break;
|
||||
|
||||
BlockSummaryData blockSummary = new BlockSummaryData(blockData);
|
||||
blockSummaries.add(blockSummary);
|
||||
parentSignature = blockData.getSignature();
|
||||
} while (blockSummaries.size() < numberRequested);
|
||||
|
||||
Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries);
|
||||
// If peer's parent signature matches our latest block signature
|
||||
// then we can short-circuit with an empty response
|
||||
BlockData chainTip = getChainTip();
|
||||
if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) {
|
||||
Message blockSummariesMessage = new BlockSummariesMessage(Collections.emptyList());
|
||||
blockSummariesMessage.setId(message.getId());
|
||||
if (!peer.sendMessage(blockSummariesMessage))
|
||||
peer.disconnect("failed to send block summaries");
|
||||
} catch (DataException e) {
|
||||
LOGGER.error(String.format("Repository issue while sending block summaries after %s to peer %s", Base58.encode(parentSignature), peer), e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
List<BlockSummaryData> blockSummaries = new ArrayList<>();
|
||||
|
||||
// Attempt to serve from our cache of latest blocks
|
||||
synchronized (this.latestBlocks) {
|
||||
blockSummaries = this.latestBlocks.stream()
|
||||
.dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature))
|
||||
.map(BlockSummaryData::new)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
if (blockSummaries.isEmpty()) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int numberRequested = Math.min(Network.MAX_BLOCK_SUMMARIES_PER_REPLY, getBlockSummariesMessage.getNumberRequested());
|
||||
|
||||
BlockData blockData = repository.getBlockRepository().fromReference(parentSignature);
|
||||
|
||||
while (blockData != null && blockSummaries.size() < numberRequested) {
|
||||
BlockSummaryData blockSummary = new BlockSummaryData(blockData);
|
||||
blockSummaries.add(blockSummary);
|
||||
|
||||
blockData = repository.getBlockRepository().fromReference(blockData.getSignature());
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.error(String.format("Repository issue while sending block summaries after %s to peer %s", Base58.encode(parentSignature), peer), e);
|
||||
}
|
||||
} else {
|
||||
this.stats.getBlockSummariesStats.cacheHits.incrementAndGet();
|
||||
|
||||
if (blockSummaries.size() >= getBlockSummariesMessage.getNumberRequested())
|
||||
this.stats.getBlockSummariesStats.fullyFromCache.incrementAndGet();
|
||||
}
|
||||
|
||||
Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries);
|
||||
blockSummariesMessage.setId(message.getId());
|
||||
if (!peer.sendMessage(blockSummariesMessage))
|
||||
peer.disconnect("failed to send block summaries");
|
||||
}
|
||||
|
||||
private void onNetworkGetSignaturesV2Message(Peer peer, Message message) {
|
||||
GetSignaturesV2Message getSignaturesMessage = (GetSignaturesV2Message) message;
|
||||
byte[] parentSignature = getSignaturesMessage.getParentSignature();
|
||||
final byte[] parentSignature = getSignaturesMessage.getParentSignature();
|
||||
this.stats.getBlockSignaturesV2Stats.requests.incrementAndGet();
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<byte[]> signatures = new ArrayList<>();
|
||||
|
||||
do {
|
||||
BlockData blockData = repository.getBlockRepository().fromReference(parentSignature);
|
||||
|
||||
if (blockData == null)
|
||||
// No more signatures to send to peer
|
||||
break;
|
||||
|
||||
parentSignature = blockData.getSignature();
|
||||
signatures.add(parentSignature);
|
||||
} while (signatures.size() < getSignaturesMessage.getNumberRequested());
|
||||
|
||||
Message signaturesMessage = new SignaturesMessage(signatures);
|
||||
// If peer's parent signature matches our latest block signature
|
||||
// then we can short-circuit with an empty response
|
||||
BlockData chainTip = getChainTip();
|
||||
if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) {
|
||||
Message signaturesMessage = new SignaturesMessage(Collections.emptyList());
|
||||
signaturesMessage.setId(message.getId());
|
||||
if (!peer.sendMessage(signaturesMessage))
|
||||
peer.disconnect("failed to send signatures (v2)");
|
||||
} catch (DataException e) {
|
||||
LOGGER.error(String.format("Repository issue while sending V2 signatures after %s to peer %s", Base58.encode(parentSignature), peer), e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
List<byte[]> signatures = new ArrayList<>();
|
||||
|
||||
// Attempt to serve from our cache of latest blocks
|
||||
synchronized (this.latestBlocks) {
|
||||
signatures = this.latestBlocks.stream()
|
||||
.dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature))
|
||||
.map(BlockData::getSignature)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
if (signatures.isEmpty()) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int numberRequested = getSignaturesMessage.getNumberRequested();
|
||||
BlockData blockData = repository.getBlockRepository().fromReference(parentSignature);
|
||||
|
||||
while (blockData != null && signatures.size() < numberRequested) {
|
||||
signatures.add(blockData.getSignature());
|
||||
|
||||
blockData = repository.getBlockRepository().fromReference(blockData.getSignature());
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.error(String.format("Repository issue while sending V2 signatures after %s to peer %s", Base58.encode(parentSignature), peer), e);
|
||||
}
|
||||
} else {
|
||||
this.stats.getBlockSignaturesV2Stats.cacheHits.incrementAndGet();
|
||||
|
||||
if (signatures.size() >= getSignaturesMessage.getNumberRequested())
|
||||
this.stats.getBlockSignaturesV2Stats.fullyFromCache.incrementAndGet();
|
||||
}
|
||||
|
||||
Message signaturesMessage = new SignaturesMessage(signatures);
|
||||
signaturesMessage.setId(message.getId());
|
||||
if (!peer.sendMessage(signaturesMessage))
|
||||
peer.disconnect("failed to send signatures (v2)");
|
||||
}
|
||||
|
||||
private void onNetworkHeightV2Message(Peer peer, Message message) {
|
||||
@@ -1353,9 +1801,6 @@ public class Controller extends Thread {
|
||||
|
||||
// Refresh our online accounts signatures?
|
||||
sendOurOnlineAccountsInfo();
|
||||
|
||||
// Trim blockchain by removing 'old' online accounts signatures
|
||||
BlockChain.trimOldOnlineAccountsSignatures();
|
||||
}
|
||||
|
||||
private void sendOurOnlineAccountsInfo() {
|
||||
@@ -1457,6 +1902,32 @@ public class Controller extends Thread {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns cached, unmodifiable list of latest block's online accounts. */
|
||||
public List<OnlineAccountData> getLatestBlocksOnlineAccounts() {
|
||||
synchronized (this.latestBlocksOnlineAccounts) {
|
||||
return this.latestBlocksOnlineAccounts.peekFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/** Caches list of latest block's online accounts. Typically called by Block.process() */
|
||||
public void pushLatestBlocksOnlineAccounts(List<OnlineAccountData> latestBlocksOnlineAccounts) {
|
||||
synchronized (this.latestBlocksOnlineAccounts) {
|
||||
if (this.latestBlocksOnlineAccounts.size() == MAX_BLOCKS_CACHED_ONLINE_ACCOUNTS)
|
||||
this.latestBlocksOnlineAccounts.pollLast();
|
||||
|
||||
this.latestBlocksOnlineAccounts.addFirst(latestBlocksOnlineAccounts == null
|
||||
? Collections.emptyList()
|
||||
: Collections.unmodifiableList(latestBlocksOnlineAccounts));
|
||||
}
|
||||
}
|
||||
|
||||
/** Reverts list of latest block's online accounts. Typically called by Block.orphan() */
|
||||
public void popLatestBlocksOnlineAccounts() {
|
||||
synchronized (this.latestBlocksOnlineAccounts) {
|
||||
this.latestBlocksOnlineAccounts.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] fetchArbitraryData(byte[] signature) throws InterruptedException {
|
||||
// Build request
|
||||
Message getArbitraryDataMessage = new GetArbitraryDataMessage(signature);
|
||||
@@ -1591,4 +2062,8 @@ public class Controller extends Thread {
|
||||
return now - offset;
|
||||
}
|
||||
|
||||
public StatsSnapshot getStatsSnapshot() {
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.qortal.controller;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.block.BlockChain;
|
||||
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 OnlineAccountsSignaturesTrimmer implements Runnable {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(OnlineAccountsSignaturesTrimmer.class);
|
||||
|
||||
private static final long INITIAL_SLEEP_PERIOD = 5 * 60 * 1000L + 1234L; // ms
|
||||
|
||||
public void run() {
|
||||
Thread.currentThread().setName("Online Accounts trimmer");
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Don't even start trimming until initial rush has ended
|
||||
Thread.sleep(INITIAL_SLEEP_PERIOD);
|
||||
|
||||
int trimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight();
|
||||
|
||||
while (!Controller.isStopping()) {
|
||||
repository.discardChanges();
|
||||
|
||||
Thread.sleep(Settings.getInstance().getOnlineSignaturesTrimInterval());
|
||||
|
||||
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;
|
||||
|
||||
// Trim blockchain by removing 'old' online accounts signatures
|
||||
long upperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime();
|
||||
int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp);
|
||||
|
||||
int upperBatchHeight = trimStartHeight + Settings.getInstance().getOnlineSignaturesTrimBatchSize();
|
||||
int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight);
|
||||
|
||||
if (trimStartHeight >= upperTrimHeight)
|
||||
continue;
|
||||
|
||||
int numSigsTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(trimStartHeight, upperTrimHeight);
|
||||
repository.saveChanges();
|
||||
|
||||
if (numSigsTrimmed > 0) {
|
||||
final int finalTrimStartHeight = trimStartHeight;
|
||||
LOGGER.debug(() -> String.format("Trimmed %d online accounts signature%s between blocks %d and %d",
|
||||
numSigsTrimmed, (numSigsTrimmed != 1 ? "s" : ""),
|
||||
finalTrimStartHeight, upperTrimHeight));
|
||||
} else {
|
||||
// Can we move onto next batch?
|
||||
if (upperTrimmableHeight > upperBatchHeight) {
|
||||
trimStartHeight = upperBatchHeight;
|
||||
|
||||
repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(trimStartHeight);
|
||||
repository.saveChanges();
|
||||
|
||||
final int finalTrimStartHeight = trimStartHeight;
|
||||
LOGGER.debug(() -> String.format("Bumping online accounts signatures base trim height to %d", finalTrimStartHeight));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to trim online accounts signatures: %s", e.getMessage()));
|
||||
} catch (InterruptedException e) {
|
||||
// Time to exit
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package org.qortal.controller;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
|
||||
public class StatusNotifier {
|
||||
|
||||
private static StatusNotifier instance;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Listener {
|
||||
void notify(long timestamp);
|
||||
}
|
||||
|
||||
private Map<Session, Listener> listenersBySession = new HashMap<>();
|
||||
|
||||
private StatusNotifier() {
|
||||
}
|
||||
|
||||
public static synchronized StatusNotifier getInstance() {
|
||||
if (instance == null)
|
||||
instance = new StatusNotifier();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void register(Session session, Listener listener) {
|
||||
synchronized (this.listenersBySession) {
|
||||
this.listenersBySession.put(session, listener);
|
||||
}
|
||||
}
|
||||
|
||||
public void deregister(Session session) {
|
||||
synchronized (this.listenersBySession) {
|
||||
this.listenersBySession.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
public void onStatusChange(long now) {
|
||||
for (Listener listener : getAllListeners())
|
||||
listener.notify(now);
|
||||
}
|
||||
|
||||
private Collection<Listener> getAllListeners() {
|
||||
// Make a copy of listeners to both avoid concurrent modification
|
||||
// and reduce synchronization time
|
||||
synchronized (this.listenersBySession) {
|
||||
return new ArrayList<>(this.listenersBySession.values());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
package org.qortal.controller.tradebot;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.qortal.api.model.crosschain.TradeBotCreateRequest;
|
||||
import org.qortal.crosschain.ACCT;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
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;
|
||||
|
||||
public interface AcctTradeBot {
|
||||
|
||||
public enum ResponseResult { OK, BALANCE_ISSUE, NETWORK_ISSUE, TRADE_ALREADY_EXISTS }
|
||||
|
||||
/** Returns list of state names for trade-bot entries that have ended, e.g. redeemed, refunded or cancelled. */
|
||||
public List<String> getEndStates();
|
||||
|
||||
public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException;
|
||||
|
||||
public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct,
|
||||
CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException;
|
||||
|
||||
public boolean canDelete(Repository repository, TradeBotData tradeBotData) throws DataException;
|
||||
|
||||
public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException;
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,903 @@
|
||||
package org.qortal.controller.tradebot;
|
||||
|
||||
import static java.util.Arrays.stream;
|
||||
import static java.util.stream.Collectors.toMap;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bitcoinj.core.Address;
|
||||
import org.bitcoinj.core.AddressFormatException;
|
||||
import org.bitcoinj.core.Coin;
|
||||
import org.bitcoinj.core.ECKey;
|
||||
import org.bitcoinj.core.Transaction;
|
||||
import org.bitcoinj.core.TransactionOutput;
|
||||
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.ACCT;
|
||||
import org.qortal.crosschain.AcctMode;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.Litecoin;
|
||||
import org.qortal.crosschain.LitecoinACCTv1;
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
import org.qortal.crosschain.BitcoinyHTLC;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 LitecoinACCTv1TradeBot implements AcctTradeBot {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(LitecoinACCTv1TradeBot.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 LitecoinACCTv1TradeBot 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 LitecoinACCTv1TradeBot() {
|
||||
}
|
||||
|
||||
public static synchronized LitecoinACCTv1TradeBot getInstance() {
|
||||
if (instance == null)
|
||||
instance = new LitecoinACCTv1TradeBot();
|
||||
|
||||
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 LTC.
|
||||
* <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 Litecoin) 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'/Litecoin public key hash - used by Alice's P2SH scripts to allow redeem</li>
|
||||
* <li>QORT amount on offer by Bob</li>
|
||||
* <li>LTC 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 Litecoin receiving address into public key hash (we only support P2PKH at this time)
|
||||
Address litecoinReceivingAddress;
|
||||
try {
|
||||
litecoinReceivingAddress = Address.fromString(Litecoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress);
|
||||
} catch (AddressFormatException e) {
|
||||
throw new DataException("Unsupported Litecoin receiving address: " + tradeBotCreateRequest.receivingAddress);
|
||||
}
|
||||
if (litecoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||
throw new DataException("Unsupported Litecoin receiving address: " + tradeBotCreateRequest.receivingAddress);
|
||||
|
||||
byte[] litecoinReceivingAccountInfo = litecoinReceivingAddress.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/LTC ACCT";
|
||||
String description = "QORT/LTC cross-chain trade";
|
||||
String aTType = "ACCT";
|
||||
String tags = "ACCT QORT LTC";
|
||||
byte[] creationBytes = LitecoinACCTv1.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, LitecoinACCTv1.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.LITECOIN.name(),
|
||||
tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
||||
tradeBotCreateRequest.foreignAmount, null, null, null, litecoinReceivingAccountInfo);
|
||||
|
||||
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 LTC to an existing offer.
|
||||
* <p>
|
||||
* Requires a chosen trade offer from Bob, passed by <tt>crossChainTradeData</tt>
|
||||
* and access to a Litecoin 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 Litecoin 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 Litecoin main-net)
|
||||
* or 'tprv' for (Litecoin 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 Litecoin amount expected by 'Bob'.
|
||||
* <p>
|
||||
* If the Litecoin 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 Litecoin 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, LitecoinACCTv1.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.LITECOIN.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 = Litecoin.getInstance().getP2shFee(now);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
LOGGER.debug("Couldn't estimate Litecoin 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 = Litecoin.getInstance().deriveP2shAddress(redeemScriptBytes);
|
||||
|
||||
// Build transaction for funding P2SH-A
|
||||
Transaction p2shFundingTransaction = Litecoin.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 {
|
||||
Litecoin.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 = LitecoinACCTv1.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:
|
||||
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()));
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (tradeBotState.requiresTradeData) {
|
||||
tradeData = LitecoinACCTv1.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 Litecoin 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;
|
||||
}
|
||||
|
||||
Litecoin litecoin = Litecoin.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 Litecoin pubkeyhash and lockTime-A
|
||||
byte[] messageData = messageTransactionData.getData();
|
||||
LitecoinACCTv1.OfferMessageData offerMessageData = LitecoinACCTv1.extractOfferMessageData(messageData);
|
||||
if (offerMessageData == null)
|
||||
continue;
|
||||
|
||||
byte[] aliceForeignPublicKeyHash = offerMessageData.partnerLitecoinPKH;
|
||||
byte[] hashOfSecretA = offerMessageData.hashOfSecretA;
|
||||
int lockTimeA = (int) offerMessageData.lockTimeA;
|
||||
long messageTimestamp = messageTransactionData.getTimestamp();
|
||||
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(messageTimestamp, lockTimeA);
|
||||
|
||||
// Determine P2SH-A address and confirm funded
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA);
|
||||
String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
|
||||
|
||||
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee;
|
||||
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.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 = LitecoinACCTv1.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 LTC 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;
|
||||
|
||||
Litecoin litecoin = Litecoin.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 = litecoin.deriveP2shAddress(redeemScriptA);
|
||||
|
||||
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.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 = LitecoinACCTv1.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 = LitecoinACCTv1.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 LTC 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 LTC funds from P2SH-A
|
||||
* to Bob's 'foreign'/Litecoin trade legacy-format address, as derived from trade private key.
|
||||
* <p>
|
||||
* (This could potentially be 'improved' to send LTC 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 not REDEEMED then something has gone wrong
|
||||
if (crossChainTradeData.mode != AcctMode.REDEEMED) {
|
||||
// Not redeemed so must be refunded/cancelled
|
||||
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);
|
||||
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
|
||||
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo();
|
||||
int lockTimeA = crossChainTradeData.lockTimeA;
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA);
|
||||
String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
|
||||
|
||||
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.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 = litecoin.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey,
|
||||
fundingOutputs, redeemScriptA, secretA, receivingAccountInfo);
|
||||
|
||||
litecoin.broadcastTransaction(p2shRedeemTransaction);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
String receivingAddress = litecoin.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;
|
||||
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
// We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
|
||||
int medianBlockTime = litecoin.getMedianBlockTime();
|
||||
if (medianBlockTime <= lockTimeA)
|
||||
return;
|
||||
|
||||
byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||
String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
|
||||
|
||||
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.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 = litecoin.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
// Determine receive address for refund
|
||||
String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey());
|
||||
Address receiving = Address.fromString(litecoin.getNetworkParameters(), receiveAddress);
|
||||
|
||||
Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(litecoin.getNetworkParameters(), refundAmount, refundKey,
|
||||
fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash());
|
||||
|
||||
litecoin.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;
|
||||
}
|
||||
|
||||
}
|
||||
373
src/main/java/org/qortal/controller/tradebot/TradeBot.java
Normal file
373
src/main/java/org/qortal/controller/tradebot/TradeBot.java
Normal file
@@ -0,0 +1,373 @@
|
||||
package org.qortal.controller.tradebot;
|
||||
|
||||
import java.awt.TrayIcon.MessageType;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collections;
|
||||
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;
|
||||
import org.apache.logging.log4j.util.Supplier;
|
||||
import org.bitcoinj.core.ECKey;
|
||||
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.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.PresenceTransactionData;
|
||||
import org.qortal.event.Event;
|
||||
import org.qortal.event.EventBus;
|
||||
import org.qortal.event.Listener;
|
||||
import org.qortal.group.Group;
|
||||
import org.qortal.gui.SysTray;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.PresenceTransaction;
|
||||
import org.qortal.transaction.PresenceTransaction.PresenceType;
|
||||
import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.transform.transaction.TransactionTransformer;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
import com.google.common.primitives.Longs;
|
||||
|
||||
/**
|
||||
* 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 TradeBot implements Listener {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(TradeBot.class);
|
||||
private static final Random RANDOM = new SecureRandom();
|
||||
|
||||
public interface StateNameAndValueSupplier {
|
||||
public String getState();
|
||||
public int getStateValue();
|
||||
}
|
||||
|
||||
public static class StateChangeEvent implements Event {
|
||||
private final TradeBotData tradeBotData;
|
||||
|
||||
public StateChangeEvent(TradeBotData tradeBotData) {
|
||||
this.tradeBotData = tradeBotData;
|
||||
}
|
||||
|
||||
public TradeBotData getTradeBotData() {
|
||||
return this.tradeBotData;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<Class<? extends ACCT>, Supplier<AcctTradeBot>> acctTradeBotSuppliers = new HashMap<>();
|
||||
static {
|
||||
acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance);
|
||||
acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance);
|
||||
}
|
||||
|
||||
private static TradeBot instance;
|
||||
|
||||
private final Map<String, Long> presenceTimestampsByAtAddress = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
private TradeBot() {
|
||||
EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event));
|
||||
}
|
||||
|
||||
public static synchronized TradeBot getInstance() {
|
||||
if (instance == null)
|
||||
instance = new TradeBot();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ACCT getAcctUsingAtData(ATData atData) {
|
||||
byte[] codeHash = atData.getCodeHash();
|
||||
if (codeHash == null)
|
||||
return null;
|
||||
|
||||
return SupportedBlockchain.getAcctByCodeHash(codeHash);
|
||||
}
|
||||
|
||||
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
||||
ACCT acct = this.getAcctUsingAtData(atData);
|
||||
if (acct == null)
|
||||
return null;
|
||||
|
||||
return acct.populateTradeData(repository, atData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new trade-bot entry from the "Bob" viewpoint,
|
||||
* i.e. OFFERing QORT in exchange for foreign blockchain currency.
|
||||
* <p>
|
||||
* Generates:
|
||||
* <ul>
|
||||
* <li>new 'trade' private key</li>
|
||||
* <li>secret(s)</li>
|
||||
* </ul>
|
||||
* Derives:
|
||||
* <ul>
|
||||
* <li>'native' (as in Qortal) public key, public key hash, address (starting with Q)</li>
|
||||
* <li>'foreign' public key, public key hash</li>
|
||||
* <li>hash(es) of secret(s)</li>
|
||||
* </ul>
|
||||
* A Qortal AT is then constructed including the following as constants in the 'data segment':
|
||||
* <ul>
|
||||
* <li>'native' (Qortal) 'trade' address - used to MESSAGE AT</li>
|
||||
* <li>'foreign' public key hash - used by Alice's to allow redeem of currency on foreign blockchain</li>
|
||||
* <li>hash(es) of secret(s) - used by AT (optional) and foreign blockchain as needed</li>
|
||||
* <li>QORT amount on offer by Bob</li>
|
||||
* <li>foreign currency 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 {
|
||||
// Fetch latest ACCT version for requested foreign blockchain
|
||||
ACCT acct = tradeBotCreateRequest.foreignBlockchain.getLatestAcct();
|
||||
|
||||
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
|
||||
if (acctTradeBot == null)
|
||||
return null;
|
||||
|
||||
return acctTradeBot.createTrade(repository, tradeBotCreateRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a trade-bot entry from the 'Alice' viewpoint,
|
||||
* i.e. matching foreign blockchain currency to an existing QORT offer.
|
||||
* <p>
|
||||
* Requires a chosen trade offer from Bob, passed by <tt>crossChainTradeData</tt>
|
||||
* and access to a foreign blockchain wallet via <tt>foreignKey</tt>.
|
||||
* <p>
|
||||
* @param repository
|
||||
* @param crossChainTradeData chosen trade OFFER that Alice wants to match
|
||||
* @param foreignKey foreign blockchain wallet key
|
||||
* @throws DataException
|
||||
*/
|
||||
public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct,
|
||||
CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException {
|
||||
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
|
||||
if (acctTradeBot == null) {
|
||||
LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for AT %s", atData.getATAddress()));
|
||||
return ResponseResult.NETWORK_ISSUE;
|
||||
}
|
||||
|
||||
// Check Alice doesn't already have an existing, on-going trade-bot entry for this AT.
|
||||
if (repository.getCrossChainRepository().existsTradeWithAtExcludingStates(atData.getATAddress(), acctTradeBot.getEndStates()))
|
||||
return ResponseResult.TRADE_ALREADY_EXISTS;
|
||||
|
||||
return acctTradeBot.startResponse(repository, atData, acct, crossChainTradeData, foreignKey, receivingAddress);
|
||||
}
|
||||
|
||||
public boolean deleteEntry(Repository repository, byte[] tradePrivateKey) throws DataException {
|
||||
TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey);
|
||||
if (tradeBotData == null)
|
||||
// Can't delete what we don't have!
|
||||
return false;
|
||||
|
||||
boolean canDelete = false;
|
||||
|
||||
ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName());
|
||||
if (acct == null)
|
||||
// We can't/no longer support this ACCT
|
||||
canDelete = true;
|
||||
else {
|
||||
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
|
||||
canDelete = acctTradeBot == null || acctTradeBot.canDelete(repository, tradeBotData);
|
||||
}
|
||||
|
||||
if (canDelete) {
|
||||
repository.getCrossChainRepository().delete(tradePrivateKey);
|
||||
repository.saveChanges();
|
||||
}
|
||||
|
||||
return canDelete;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listen(Event event) {
|
||||
if (!(event instanceof Controller.NewBlockEvent))
|
||||
return;
|
||||
|
||||
synchronized (this) {
|
||||
List<TradeBotData> allTradeBotData;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Couldn't run trade bot due to repository issue", e);
|
||||
return;
|
||||
}
|
||||
|
||||
for (TradeBotData tradeBotData : allTradeBotData)
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
// Find ACCT-specific trade-bot for this entry
|
||||
ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName());
|
||||
if (acct == null) {
|
||||
LOGGER.debug(() -> String.format("Couldn't find ACCT matching name %s", tradeBotData.getAcctName()));
|
||||
continue;
|
||||
}
|
||||
|
||||
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
|
||||
if (acctTradeBot == null) {
|
||||
LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot matching name %s", tradeBotData.getAcctName()));
|
||||
continue;
|
||||
}
|
||||
|
||||
acctTradeBot.progress(repository, tradeBotData);
|
||||
} catch (DataException e) {
|
||||
LOGGER.error("Couldn't run trade bot due to repository issue", e);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
LOGGER.warn(() -> String.format("Foreign blockchain issue processing trade-bot entry for AT %s: %s", tradeBotData.getAtAddress(), e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*package*/ 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) {
|
||||
return PrivateKeyAccount.toPublicKey(privateKey);
|
||||
}
|
||||
|
||||
/*package*/ static byte[] deriveTradeForeignPublicKey(byte[] privateKey) {
|
||||
return ECKey.fromPrivate(privateKey).getPubKey();
|
||||
}
|
||||
|
||||
/*package*/ static byte[] generateSecret() {
|
||||
byte[] secret = new byte[32];
|
||||
RANDOM.nextBytes(secret);
|
||||
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 {
|
||||
tradeBotData.setState(newState);
|
||||
tradeBotData.setStateValue(newStateValue);
|
||||
tradeBotData.setTimestamp(NTP.getTime());
|
||||
repository.getCrossChainRepository().save(tradeBotData);
|
||||
repository.saveChanges();
|
||||
|
||||
if (Settings.getInstance().isTradebotSystrayEnabled())
|
||||
SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState), MessageType.INFO);
|
||||
|
||||
if (logMessageSupplier != null)
|
||||
LOGGER.info(logMessageSupplier);
|
||||
|
||||
LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState));
|
||||
|
||||
notifyStateChange(tradeBotData);
|
||||
}
|
||||
|
||||
/** 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, StateNameAndValueSupplier newStateSupplier, Supplier<String> logMessageSupplier) throws DataException {
|
||||
updateTradeBotState(repository, tradeBotData, newStateSupplier.getState(), newStateSupplier.getStateValue(), logMessageSupplier);
|
||||
}
|
||||
|
||||
/** 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, Supplier<String> logMessageSupplier) throws DataException {
|
||||
updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), tradeBotData.getStateValue(), logMessageSupplier);
|
||||
}
|
||||
|
||||
/*package*/ static void notifyStateChange(TradeBotData tradeBotData) {
|
||||
StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData);
|
||||
EventBus.INSTANCE.notify(stateChangeEvent);
|
||||
}
|
||||
|
||||
/*package*/ static AcctTradeBot findTradeBotForAcct(ACCT acct) {
|
||||
Supplier<AcctTradeBot> acctTradeBotSupplier = acctTradeBotSuppliers.get(acct.getClass());
|
||||
if (acctTradeBotSupplier == null)
|
||||
return null;
|
||||
|
||||
return acctTradeBotSupplier.get();
|
||||
}
|
||||
|
||||
// PRESENCE-related
|
||||
/*package*/ void updatePresence(Repository repository, TradeBotData tradeBotData, CrossChainTradeData tradeData)
|
||||
throws DataException {
|
||||
String atAddress = tradeBotData.getAtAddress();
|
||||
|
||||
PrivateKeyAccount tradeNativeAccount = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
|
||||
String signerAddress = tradeNativeAccount.getAddress();
|
||||
|
||||
/*
|
||||
* There's no point in Alice trying to build a PRESENCE transaction
|
||||
* for an AT that isn't locked to her, as other peers won't be able
|
||||
* to validate the PRESENCE transaction as signing public key won't
|
||||
* be visible.
|
||||
*/
|
||||
if (!signerAddress.equals(tradeData.qortalCreatorTradeAddress) && !signerAddress.equals(tradeData.qortalPartnerAddress))
|
||||
// Signer is neither Bob, nor Alice, or trade not yet locked to Alice
|
||||
return;
|
||||
|
||||
long now = NTP.getTime();
|
||||
long threshold = now - PresenceType.TRADE_BOT.getLifetime();
|
||||
|
||||
long timestamp = presenceTimestampsByAtAddress.compute(atAddress, (k, v) -> (v == null || v < threshold) ? now : v);
|
||||
|
||||
// If timestamp hasn't been updated then nothing to do
|
||||
if (timestamp != now)
|
||||
return;
|
||||
|
||||
int txGroupId = Group.NO_GROUP;
|
||||
byte[] reference = new byte[TransactionTransformer.SIGNATURE_LENGTH];
|
||||
byte[] creatorPublicKey = tradeNativeAccount.getPublicKey();
|
||||
long fee = 0L;
|
||||
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null);
|
||||
|
||||
int nonce = 0;
|
||||
byte[] timestampSignature = tradeNativeAccount.sign(Longs.toByteArray(timestamp));
|
||||
|
||||
PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.TRADE_BOT, timestampSignature);
|
||||
|
||||
PresenceTransaction presenceTransaction = new PresenceTransaction(repository, transactionData);
|
||||
presenceTransaction.computeNonce();
|
||||
|
||||
presenceTransaction.sign(tradeNativeAccount);
|
||||
|
||||
ValidationResult result = presenceTransaction.importAsUnconfirmed();
|
||||
if (result != ValidationResult.OK)
|
||||
LOGGER.debug(() -> String.format("Unable to build trade-bot PRESENCE transaction for %s: %s", tradeBotData.getAtAddress(), result.name()));
|
||||
}
|
||||
|
||||
}
|
||||
23
src/main/java/org/qortal/crosschain/ACCT.java
Normal file
23
src/main/java/org/qortal/crosschain/ACCT.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
|
||||
public interface ACCT {
|
||||
|
||||
public byte[] getCodeBytesHash();
|
||||
|
||||
public int getModeByteOffset();
|
||||
|
||||
public ForeignBlockchain getBlockchain();
|
||||
|
||||
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException;
|
||||
|
||||
public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException;
|
||||
|
||||
public byte[] buildCancelMessage(String creatorQortalAddress);
|
||||
|
||||
}
|
||||
21
src/main/java/org/qortal/crosschain/AcctMode.java
Normal file
21
src/main/java/org/qortal/crosschain/AcctMode.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import static java.util.Arrays.stream;
|
||||
import static java.util.stream.Collectors.toMap;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public enum AcctMode {
|
||||
OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4);
|
||||
|
||||
public final int value;
|
||||
private static final Map<Integer, AcctMode> map = stream(AcctMode.values()).collect(toMap(mode -> mode.value, mode -> mode));
|
||||
|
||||
AcctMode(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public static AcctMode valueOf(int value) {
|
||||
return map.get(value);
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bitcoinj.core.Address;
|
||||
import org.bitcoinj.core.Coin;
|
||||
import org.bitcoinj.core.ECKey;
|
||||
import org.bitcoinj.core.InsufficientMoneyException;
|
||||
import org.bitcoinj.core.LegacyAddress;
|
||||
import org.bitcoinj.core.NetworkParameters;
|
||||
import org.bitcoinj.core.Sha256Hash;
|
||||
import org.bitcoinj.core.Transaction;
|
||||
import org.bitcoinj.core.TransactionOutput;
|
||||
import org.bitcoinj.core.UTXO;
|
||||
import org.bitcoinj.core.UTXOProvider;
|
||||
import org.bitcoinj.core.UTXOProviderException;
|
||||
import org.bitcoinj.crypto.DeterministicHierarchy;
|
||||
import org.bitcoinj.crypto.DeterministicKey;
|
||||
import org.bitcoinj.params.MainNetParams;
|
||||
import org.bitcoinj.params.RegTestParams;
|
||||
import org.bitcoinj.params.TestNet3Params;
|
||||
import org.bitcoinj.script.Script.ScriptType;
|
||||
import org.bitcoinj.script.ScriptBuilder;
|
||||
import org.bitcoinj.utils.MonetaryFormat;
|
||||
import org.bitcoinj.wallet.DeterministicKeyChain;
|
||||
import org.bitcoinj.wallet.SendRequest;
|
||||
import org.bitcoinj.wallet.Wallet;
|
||||
import org.qortal.crosschain.ElectrumX.UnspentOutput;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
|
||||
public class BTC {
|
||||
|
||||
public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL;
|
||||
public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1;
|
||||
public static final int HASH160_LENGTH = 20;
|
||||
|
||||
protected static final Logger LOGGER = LogManager.getLogger(BTC.class);
|
||||
|
||||
private static final int TIMESTAMP_OFFSET = 4 + 32 + 32;
|
||||
private static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode();
|
||||
|
||||
public enum BitcoinNet {
|
||||
MAIN {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return MainNetParams.get();
|
||||
}
|
||||
},
|
||||
TEST3 {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return TestNet3Params.get();
|
||||
}
|
||||
},
|
||||
REGTEST {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return RegTestParams.get();
|
||||
}
|
||||
};
|
||||
|
||||
public abstract NetworkParameters getParams();
|
||||
}
|
||||
|
||||
private static BTC instance;
|
||||
private final NetworkParameters params;
|
||||
private final ElectrumX electrumX;
|
||||
|
||||
// Let ECKey.equals() do the hard work
|
||||
private final Set<ECKey> spentKeys = new HashSet<>();
|
||||
|
||||
// Constructors and instance
|
||||
|
||||
private BTC() {
|
||||
BitcoinNet bitcoinNet = Settings.getInstance().getBitcoinNet();
|
||||
this.params = bitcoinNet.getParams();
|
||||
|
||||
LOGGER.info(() -> String.format("Starting Bitcoin support using %s", bitcoinNet.name()));
|
||||
|
||||
this.electrumX = ElectrumX.getInstance(bitcoinNet.name());
|
||||
}
|
||||
|
||||
public static synchronized BTC getInstance() {
|
||||
if (instance == null)
|
||||
instance = new BTC();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Getters & setters
|
||||
|
||||
public NetworkParameters getNetworkParameters() {
|
||||
return this.params;
|
||||
}
|
||||
|
||||
public static synchronized void resetForTesting() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
// Actual useful methods for use by other classes
|
||||
|
||||
public static String format(Coin amount) {
|
||||
return BTC.FORMAT.format(amount).toString();
|
||||
}
|
||||
|
||||
public static String format(long amount) {
|
||||
return format(Coin.valueOf(amount));
|
||||
}
|
||||
|
||||
public boolean isValidXprv(String xprv58) {
|
||||
try {
|
||||
DeterministicKey.deserializeB58(null, xprv58, this.params);
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns P2PKH Bitcoin address using passed public key hash. */
|
||||
public String pkhToAddress(byte[] publicKeyHash) {
|
||||
return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString();
|
||||
}
|
||||
|
||||
public String deriveP2shAddress(byte[] redeemScriptBytes) {
|
||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||
return p2shAddress.toString();
|
||||
}
|
||||
|
||||
/** Returns median timestamp from latest 11 blocks, in seconds. */
|
||||
public Integer getMedianBlockTime() {
|
||||
Integer height = this.electrumX.getCurrentHeight();
|
||||
if (height == null)
|
||||
return null;
|
||||
|
||||
// Grab latest 11 blocks
|
||||
List<byte[]> blockHeaders = this.electrumX.getBlockHeaders(height - 11, 11);
|
||||
if (blockHeaders == null || blockHeaders.size() < 11)
|
||||
return null;
|
||||
|
||||
List<Integer> blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
|
||||
|
||||
// Descending order
|
||||
blockTimestamps.sort((a, b) -> Integer.compare(b, a));
|
||||
|
||||
// Pick median
|
||||
return blockTimestamps.get(5);
|
||||
}
|
||||
|
||||
public Long getBalance(String base58Address) {
|
||||
return this.electrumX.getBalance(addressToScript(base58Address));
|
||||
}
|
||||
|
||||
public List<TransactionOutput> getUnspentOutputs(String base58Address) {
|
||||
List<UnspentOutput> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address));
|
||||
if (unspentOutputs == null)
|
||||
return null;
|
||||
|
||||
List<TransactionOutput> unspentTransactionOutputs = new ArrayList<>();
|
||||
for (UnspentOutput unspentOutput : unspentOutputs) {
|
||||
List<TransactionOutput> transactionOutputs = getOutputs(unspentOutput.hash);
|
||||
if (transactionOutputs == null)
|
||||
return null;
|
||||
|
||||
unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index));
|
||||
}
|
||||
|
||||
return unspentTransactionOutputs;
|
||||
}
|
||||
|
||||
public List<TransactionOutput> getOutputs(byte[] txHash) {
|
||||
byte[] rawTransactionBytes = this.electrumX.getRawTransaction(txHash);
|
||||
if (rawTransactionBytes == null)
|
||||
return null;
|
||||
|
||||
Transaction transaction = new Transaction(this.params, rawTransactionBytes);
|
||||
return transaction.getOutputs();
|
||||
}
|
||||
|
||||
/** Returns list of raw transactions spending passed address. */
|
||||
public List<byte[]> getAddressTransactions(String base58Address) {
|
||||
return this.electrumX.getAddressTransactions(addressToScript(base58Address));
|
||||
}
|
||||
|
||||
public boolean broadcastTransaction(Transaction transaction) {
|
||||
return this.electrumX.broadcastTransaction(transaction.bitcoinSerialize());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns bitcoinj transaction sending <tt>amount</tt> to <tt>recipient</tt>.
|
||||
*
|
||||
* @param xprv58 BIP32 extended Bitcoin private key
|
||||
* @param recipient P2PKH address
|
||||
* @param amount unscaled amount
|
||||
* @return transaction, or null if insufficient funds
|
||||
*/
|
||||
public Transaction buildSpend(String xprv58, String recipient, long amount) {
|
||||
Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
||||
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ALL_SPENT));
|
||||
|
||||
Address destination = Address.fromString(this.params, recipient);
|
||||
SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount));
|
||||
|
||||
if (this.params == TestNet3Params.get())
|
||||
// Much smaller fee for TestNet3
|
||||
sendRequest.feePerKb = Coin.valueOf(2000L);
|
||||
|
||||
try {
|
||||
wallet.completeTx(sendRequest);
|
||||
return sendRequest.tx;
|
||||
} catch (InsufficientMoneyException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns unspent Bitcoin balance given 'm' BIP32 key.
|
||||
*
|
||||
* @param xprv58 BIP32 extended Bitcoin private key
|
||||
* @return unspent BTC balance, or null if unable to determine balance
|
||||
*/
|
||||
public Long getWalletBalance(String xprv58) {
|
||||
Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
||||
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ANY_SPENT));
|
||||
|
||||
Coin balance = wallet.getBalance();
|
||||
if (balance == null)
|
||||
return null;
|
||||
|
||||
return balance.value;
|
||||
}
|
||||
|
||||
// UTXOProvider support
|
||||
|
||||
static class WalletAwareUTXOProvider implements UTXOProvider {
|
||||
private static final int LOOKAHEAD_INCREMENT = 3;
|
||||
|
||||
private final BTC btc;
|
||||
private final Wallet wallet;
|
||||
|
||||
enum KeySearchMode {
|
||||
REQUEST_MORE_IF_ALL_SPENT, REQUEST_MORE_IF_ANY_SPENT;
|
||||
}
|
||||
private final KeySearchMode keySearchMode;
|
||||
private final DeterministicKeyChain keyChain;
|
||||
|
||||
public WalletAwareUTXOProvider(BTC btc, Wallet wallet, KeySearchMode keySearchMode) {
|
||||
this.btc = btc;
|
||||
this.wallet = wallet;
|
||||
this.keySearchMode = keySearchMode;
|
||||
this.keyChain = this.wallet.getActiveKeyChain();
|
||||
|
||||
// Set up wallet's key chain
|
||||
this.keyChain.setLookaheadSize(LOOKAHEAD_INCREMENT);
|
||||
this.keyChain.maybeLookAhead();
|
||||
}
|
||||
|
||||
public List<UTXO> getOpenTransactionOutputs(List<ECKey> keys) throws UTXOProviderException {
|
||||
List<UTXO> allUnspentOutputs = new ArrayList<>();
|
||||
final boolean coinbase = false;
|
||||
|
||||
int ki = 0;
|
||||
do {
|
||||
boolean areAllKeysUnspent = true;
|
||||
boolean areAllKeysSpent = true;
|
||||
|
||||
for (; ki < keys.size(); ++ki) {
|
||||
ECKey key = keys.get(ki);
|
||||
|
||||
Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH);
|
||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||
|
||||
List<UnspentOutput> unspentOutputs = btc.electrumX.getUnspentOutputs(script);
|
||||
if (unspentOutputs == null)
|
||||
throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address));
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
if (unspentOutputs.isEmpty()) {
|
||||
// If this is a known key that has been spent before, then we can skip asking for transaction history
|
||||
if (btc.spentKeys.contains(key)) {
|
||||
wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
|
||||
areAllKeysUnspent = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ask for transaction history - if it's empty then key has never been used
|
||||
List<byte[]> historicTransactionHashes = btc.electrumX.getAddressTransactions(script);
|
||||
if (historicTransactionHashes == null)
|
||||
throw new UTXOProviderException(
|
||||
String.format("Unable to fetch transaction history for %s", address));
|
||||
|
||||
if (!historicTransactionHashes.isEmpty()) {
|
||||
// Fully spent key - case (a)
|
||||
btc.spentKeys.add(key);
|
||||
wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
|
||||
areAllKeysUnspent = false;
|
||||
} else {
|
||||
// Key never been used - case (b)
|
||||
areAllKeysSpent = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we reach here, then there's definitely at least one unspent key
|
||||
areAllKeysSpent = false;
|
||||
|
||||
for (UnspentOutput unspentOutput : unspentOutputs) {
|
||||
List<TransactionOutput> transactionOutputs = btc.getOutputs(unspentOutput.hash);
|
||||
if (transactionOutputs == null)
|
||||
throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s",
|
||||
HashCode.fromBytes(unspentOutput.hash)));
|
||||
|
||||
TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index);
|
||||
|
||||
UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index,
|
||||
Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase,
|
||||
transactionOutput.getScriptPubKey());
|
||||
|
||||
allUnspentOutputs.add(utxo);
|
||||
}
|
||||
}
|
||||
|
||||
if ((this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ALL_SPENT && areAllKeysSpent)
|
||||
|| (this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ANY_SPENT && !areAllKeysUnspent)) {
|
||||
// Generate some more keys
|
||||
this.keyChain.setLookaheadSize(this.keyChain.getLookaheadSize() + LOOKAHEAD_INCREMENT);
|
||||
this.keyChain.maybeLookAhead();
|
||||
|
||||
// This returns all keys, including those already in 'keys'
|
||||
List<DeterministicKey> allLeafKeys = this.keyChain.getLeafKeys();
|
||||
// Add only new keys onto our list of keys to search
|
||||
List<DeterministicKey> newKeys = allLeafKeys.subList(ki, allLeafKeys.size());
|
||||
keys.addAll(newKeys);
|
||||
// Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again
|
||||
}
|
||||
|
||||
// If we have processed all keys, then we're done
|
||||
} while (ki < keys.size());
|
||||
|
||||
return allUnspentOutputs;
|
||||
}
|
||||
|
||||
public int getChainHeadHeight() throws UTXOProviderException {
|
||||
Integer height = btc.electrumX.getCurrentHeight();
|
||||
if (height == null)
|
||||
throw new UTXOProviderException("Unable to determine Bitcoin chain height");
|
||||
|
||||
return height.intValue();
|
||||
}
|
||||
|
||||
public NetworkParameters getParams() {
|
||||
return btc.params;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods for us
|
||||
|
||||
private byte[] addressToScript(String base58Address) {
|
||||
Address address = Address.fromString(this.params, base58Address);
|
||||
return ScriptBuilder.createOutputScript(address).getProgram();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.bitcoinj.core.Address;
|
||||
import org.bitcoinj.core.Coin;
|
||||
import org.bitcoinj.core.ECKey;
|
||||
import org.bitcoinj.core.LegacyAddress;
|
||||
import org.bitcoinj.core.NetworkParameters;
|
||||
import org.bitcoinj.core.Transaction;
|
||||
import org.bitcoinj.core.Transaction.SigHash;
|
||||
import org.bitcoinj.core.TransactionInput;
|
||||
import org.bitcoinj.core.TransactionOutput;
|
||||
import org.bitcoinj.crypto.TransactionSignature;
|
||||
import org.bitcoinj.script.Script;
|
||||
import org.bitcoinj.script.ScriptBuilder;
|
||||
import org.bitcoinj.script.ScriptChunk;
|
||||
import org.bitcoinj.script.ScriptOpCodes;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
public class BTCP2SH {
|
||||
|
||||
public static final int SECRET_LENGTH = 32;
|
||||
public static final int MIN_LOCKTIME = 1500000000;
|
||||
|
||||
/*
|
||||
* OP_TUCK (to copy public key to before signature)
|
||||
* OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails)
|
||||
* OP_HASH160 (convert public key to PKH)
|
||||
* OP_DUP (duplicate PKH)
|
||||
* <push 20 bytes> <refund PKH> OP_EQUAL (does PKH match refund PKH?)
|
||||
* OP_IF
|
||||
* OP_DROP (no need for duplicate PKH)
|
||||
* <push 4 bytes> <locktime>
|
||||
* OP_CHECKLOCKTIMEVERIFY (if this passes, leftover stack is <locktime> so script passes)
|
||||
* OP_ELSE
|
||||
* <push 20 bytes> <redeem PKH> OP_EQUALVERIFY (duplicate PKH must match redeem PKH or script fails)
|
||||
* OP_HASH160 (hash secret)
|
||||
* <push 20 bytes> <hash of secret> OP_EQUAL (do hashes of secrets match? if true, script passes else script fails)
|
||||
* OP_ENDIF
|
||||
*/
|
||||
|
||||
private static final byte[] redeemScript1 = HashCode.fromString("7dada97614").asBytes(); // OP_TUCK OP_CHECKSIGVERIFY OP_HASH160 OP_DUP push(0x14 bytes)
|
||||
private static final byte[] redeemScript2 = HashCode.fromString("87637504").asBytes(); // OP_EQUAL OP_IF OP_DROP push(0x4 bytes)
|
||||
private static final byte[] redeemScript3 = HashCode.fromString("b16714").asBytes(); // OP_CHECKLOCKTIMEVERIFY OP_ELSE push(0x14 bytes)
|
||||
private static final byte[] redeemScript4 = HashCode.fromString("88a914").asBytes(); // OP_EQUALVERIFY OP_HASH160 push(0x14 bytes)
|
||||
private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF
|
||||
|
||||
/**
|
||||
* Returns Bitcoin redeemScript used for cross-chain trading.
|
||||
* <p>
|
||||
* See comments in {@link BTCP2SH} for more details.
|
||||
*
|
||||
* @param refunderPubKeyHash 20-byte HASH160 of P2SH funder's public key, for refunding purposes
|
||||
* @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund
|
||||
* @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key
|
||||
* @param secretHash 20-byte HASH160 of secret, used by P2SH redeemer to claim funds
|
||||
* @return
|
||||
*/
|
||||
public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] secretHash) {
|
||||
return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)),
|
||||
redeemScript3, redeemerPubKeyHash, redeemScript4, secretHash, redeemScript5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a custom transaction to spend P2SH.
|
||||
*
|
||||
* @param amount output amount, should be total of input amounts, less miner fees
|
||||
* @param spendKey key for signing transaction, and also where funds are 'sent' (output)
|
||||
* @param fundingOutput output from transaction that funded P2SH address
|
||||
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
||||
* @param lockTime (optional) transaction nLockTime, used in refund scenario
|
||||
* @param scriptSigBuilder function for building scriptSig using transaction input signature
|
||||
* @param outputPublicKeyHash PKH used to create P2PKH output
|
||||
* @return Signed Bitcoin transaction for spending P2SH
|
||||
*/
|
||||
public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes,
|
||||
Long lockTime, Function<byte[], Script> scriptSigBuilder, byte[] outputPublicKeyHash) {
|
||||
NetworkParameters params = BTC.getInstance().getNetworkParameters();
|
||||
|
||||
Transaction transaction = new Transaction(params);
|
||||
transaction.setVersion(2);
|
||||
|
||||
// Output is back to P2SH funder
|
||||
transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(outputPublicKeyHash));
|
||||
|
||||
for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) {
|
||||
TransactionOutput fundingOutput = fundingOutputs.get(inputIndex);
|
||||
|
||||
// Input (without scriptSig prior to signing)
|
||||
TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor());
|
||||
if (lockTime != null)
|
||||
input.setSequenceNumber(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
|
||||
else
|
||||
input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF
|
||||
transaction.addInput(input);
|
||||
}
|
||||
|
||||
// Set locktime after inputs added but before input signatures are generated
|
||||
if (lockTime != null)
|
||||
transaction.setLockTime(lockTime);
|
||||
|
||||
for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) {
|
||||
// Generate transaction signature for input
|
||||
final boolean anyoneCanPay = false;
|
||||
TransactionSignature txSig = transaction.calculateSignature(inputIndex, spendKey, redeemScriptBytes, SigHash.ALL, anyoneCanPay);
|
||||
|
||||
// Calculate transaction signature
|
||||
byte[] txSigBytes = txSig.encodeToBitcoin();
|
||||
|
||||
// Build scriptSig using lambda and tx signature
|
||||
Script scriptSig = scriptSigBuilder.apply(txSigBytes);
|
||||
|
||||
// Set input scriptSig
|
||||
transaction.getInput(inputIndex).setScriptSig(scriptSig);
|
||||
}
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns signed Bitcoin transaction claiming refund from P2SH address.
|
||||
*
|
||||
* @param refundAmount refund amount, should be total of input amounts, less miner fees
|
||||
* @param refundKey key for signing transaction, and also where refund is 'sent' (output)
|
||||
* @param fundingOutput output from transaction that funded P2SH address
|
||||
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
||||
* @param lockTime transaction nLockTime - must be at least locktime used in redeemScript
|
||||
* @return Signed Bitcoin transaction for refunding P2SH
|
||||
*/
|
||||
public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, long lockTime) {
|
||||
Function<byte[], Script> refundSigScriptBuilder = (txSigBytes) -> {
|
||||
// Build scriptSig with...
|
||||
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
||||
|
||||
// transaction signature
|
||||
scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes));
|
||||
|
||||
// redeem public key
|
||||
byte[] refundPubKey = refundKey.getPubKey();
|
||||
scriptBuilder.addChunk(new ScriptChunk(refundPubKey.length, refundPubKey));
|
||||
|
||||
// redeem script
|
||||
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
|
||||
|
||||
return scriptBuilder.build();
|
||||
};
|
||||
|
||||
// Send funds back to funding address
|
||||
return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, refundKey.getPubKeyHash());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns signed Bitcoin transaction redeeming funds from P2SH address.
|
||||
*
|
||||
* @param redeemAmount redeem amount, should be total of input amounts, less miner fees
|
||||
* @param redeemKey key for signing transaction, and also where funds are 'sent' (output)
|
||||
* @param fundingOutput output from transaction that funded P2SH address
|
||||
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
||||
* @param secret actual 32-byte secret used when building redeemScript
|
||||
* @param receivingAccountInfo Bitcoin PKH used for output
|
||||
* @return Signed Bitcoin transaction for redeeming P2SH
|
||||
*/
|
||||
public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) {
|
||||
Function<byte[], Script> redeemSigScriptBuilder = (txSigBytes) -> {
|
||||
// Build scriptSig with...
|
||||
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
||||
|
||||
// secret
|
||||
scriptBuilder.addChunk(new ScriptChunk(secret.length, secret));
|
||||
|
||||
// transaction signature
|
||||
scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes));
|
||||
|
||||
// redeem public key
|
||||
byte[] redeemPubKey = redeemKey.getPubKey();
|
||||
scriptBuilder.addChunk(new ScriptChunk(redeemPubKey.length, redeemPubKey));
|
||||
|
||||
// redeem script
|
||||
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
|
||||
|
||||
return scriptBuilder.build();
|
||||
};
|
||||
|
||||
return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo);
|
||||
}
|
||||
|
||||
/** Returns 'secret', if any, given list of raw bitcoin transactions. */
|
||||
public static byte[] findP2shSecret(String p2shAddress, List<byte[]> rawTransactions) {
|
||||
NetworkParameters params = BTC.getInstance().getNetworkParameters();
|
||||
|
||||
for (byte[] rawTransaction : rawTransactions) {
|
||||
Transaction transaction = new Transaction(params, rawTransaction);
|
||||
|
||||
// Cycle through inputs, looking for one that spends our P2SH
|
||||
for (TransactionInput input : transaction.getInputs()) {
|
||||
Script scriptSig = input.getScriptSig();
|
||||
List<ScriptChunk> scriptChunks = scriptSig.getChunks();
|
||||
|
||||
// Expected number of script chunks for redeem. Refund might not have the same number.
|
||||
int expectedChunkCount = 1 /*secret*/ + 1 /*sig*/ + 1 /*pubkey*/ + 1 /*redeemScript*/;
|
||||
if (scriptChunks.size() != expectedChunkCount)
|
||||
continue;
|
||||
|
||||
// We're expecting last chunk to contain the actual redeemScript
|
||||
ScriptChunk lastChunk = scriptChunks.get(scriptChunks.size() - 1);
|
||||
byte[] redeemScriptBytes = lastChunk.data;
|
||||
|
||||
// If non-push scripts, redeemScript will be null
|
||||
if (redeemScriptBytes == null)
|
||||
continue;
|
||||
|
||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||
Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||
|
||||
if (!inputAddress.toString().equals(p2shAddress))
|
||||
// Input isn't spending our P2SH
|
||||
continue;
|
||||
|
||||
byte[] secret = scriptChunks.get(0).data;
|
||||
if (secret.length != BTCP2SH.SECRET_LENGTH)
|
||||
continue;
|
||||
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
190
src/main/java/org/qortal/crosschain/Bitcoin.java
Normal file
190
src/main/java/org/qortal/crosschain/Bitcoin.java
Normal file
@@ -0,0 +1,190 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.bitcoinj.core.Context;
|
||||
import org.bitcoinj.core.NetworkParameters;
|
||||
import org.bitcoinj.params.MainNetParams;
|
||||
import org.bitcoinj.params.RegTestParams;
|
||||
import org.bitcoinj.params.TestNet3Params;
|
||||
import org.qortal.crosschain.ElectrumX.Server;
|
||||
import org.qortal.crosschain.ElectrumX.Server.ConnectionType;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
public class Bitcoin extends Bitcoiny {
|
||||
|
||||
public static final String CURRENCY_CODE = "BTC";
|
||||
|
||||
// Temporary values until a dynamic fee system is written.
|
||||
private static final long OLD_FEE_AMOUNT = 4_000L; // Not 5000 so that existing P2SH-B can output 1000, avoiding dust issue, leaving 4000 for fees.
|
||||
private static final long NEW_FEE_TIMESTAMP = 1598280000000L; // milliseconds since epoch
|
||||
private static final long NEW_FEE_AMOUNT = 10_000L;
|
||||
|
||||
private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST
|
||||
|
||||
private static final Map<ElectrumX.Server.ConnectionType, Integer> DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ElectrumX.Server.ConnectionType.class);
|
||||
static {
|
||||
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001);
|
||||
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002);
|
||||
}
|
||||
|
||||
public enum BitcoinNet {
|
||||
MAIN {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return MainNetParams.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ElectrumX.Server> getServers() {
|
||||
return Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
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("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));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGenesisHash() {
|
||||
return "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getP2shFee(Long timestamp) {
|
||||
// TODO: This will need to be replaced with something better in the near future!
|
||||
if (timestamp != null && timestamp < NEW_FEE_TIMESTAMP)
|
||||
return OLD_FEE_AMOUNT;
|
||||
|
||||
return NEW_FEE_AMOUNT;
|
||||
}
|
||||
},
|
||||
TEST3 {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return TestNet3Params.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ElectrumX.Server> getServers() {
|
||||
return Arrays.asList(
|
||||
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.SSL, 51002),
|
||||
new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001),
|
||||
new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002),
|
||||
new Server("testnet.hsmiths.com", Server.ConnectionType.SSL, 53012));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGenesisHash() {
|
||||
return "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943";
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getP2shFee(Long timestamp) {
|
||||
return NON_MAINNET_FEE;
|
||||
}
|
||||
},
|
||||
REGTEST {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return RegTestParams.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ElectrumX.Server> getServers() {
|
||||
return Arrays.asList(
|
||||
new Server("localhost", Server.ConnectionType.TCP, 50001),
|
||||
new Server("localhost", Server.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<ElectrumX.Server> getServers();
|
||||
public abstract String getGenesisHash();
|
||||
public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException;
|
||||
}
|
||||
|
||||
private static Bitcoin instance;
|
||||
|
||||
private final BitcoinNet bitcoinNet;
|
||||
|
||||
// Constructors and instance
|
||||
|
||||
private Bitcoin(BitcoinNet bitcoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) {
|
||||
super(blockchain, bitcoinjContext, currencyCode);
|
||||
this.bitcoinNet = bitcoinNet;
|
||||
|
||||
LOGGER.info(() -> String.format("Starting Bitcoin support using %s", this.bitcoinNet.name()));
|
||||
}
|
||||
|
||||
public static synchronized Bitcoin getInstance() {
|
||||
if (instance == null) {
|
||||
BitcoinNet bitcoinNet = Settings.getInstance().getBitcoinNet();
|
||||
|
||||
BitcoinyBlockchainProvider electrumX = new ElectrumX("Bitcoin-" + bitcoinNet.name(), bitcoinNet.getGenesisHash(), bitcoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS);
|
||||
Context bitcoinjContext = new Context(bitcoinNet.getParams());
|
||||
|
||||
instance = new Bitcoin(bitcoinNet, electrumX, bitcoinjContext, CURRENCY_CODE);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Getters & setters
|
||||
|
||||
public static synchronized void resetForTesting() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
// Actual useful methods for use by other classes
|
||||
|
||||
/**
|
||||
* Returns estimated BTC 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.bitcoinNet.getP2shFee(timestamp);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import static java.util.Arrays.stream;
|
||||
import static java.util.stream.Collectors.toMap;
|
||||
import static org.ciyam.at.OpCode.calcOffset;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.ciyam.at.API;
|
||||
import org.ciyam.at.CompilationException;
|
||||
@@ -101,11 +98,12 @@ import com.google.common.primitives.Bytes;
|
||||
* </li>
|
||||
* </ul>
|
||||
*/
|
||||
public class BTCACCT {
|
||||
public class BitcoinACCTv1 implements ACCT {
|
||||
|
||||
public static final String NAME = BitcoinACCTv1.class.getSimpleName();
|
||||
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes
|
||||
|
||||
public static final int SECRET_LENGTH = 32;
|
||||
public static final int MIN_LOCKTIME = 1500000000;
|
||||
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes
|
||||
|
||||
/** <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 = 68;
|
||||
@@ -126,22 +124,31 @@ public class BTCACCT {
|
||||
public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret*/ + 32 /*secret*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/;
|
||||
public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/;
|
||||
|
||||
public enum Mode {
|
||||
OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4);
|
||||
private static BitcoinACCTv1 instance;
|
||||
|
||||
public final int value;
|
||||
private static final Map<Integer, Mode> map = stream(Mode.values()).collect(toMap(mode -> mode.value, mode -> mode));
|
||||
|
||||
Mode(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public static Mode valueOf(int value) {
|
||||
return map.get(value);
|
||||
}
|
||||
private BitcoinACCTv1() {
|
||||
}
|
||||
|
||||
private BTCACCT() {
|
||||
public static synchronized BitcoinACCTv1 getInstance() {
|
||||
if (instance == null)
|
||||
instance = new BitcoinACCTv1();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getCodeBytesHash() {
|
||||
return CODE_BYTES_HASH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getModeByteOffset() {
|
||||
return MODE_BYTE_OFFSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ForeignBlockchain getBlockchain() {
|
||||
return Bitcoin.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,7 +163,6 @@ public class BTCACCT {
|
||||
* @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT
|
||||
* @param bitcoinAmount how much BTC the AT creator is expecting to trade
|
||||
* @param tradeTimeout suggested timeout for entire trade
|
||||
* @return
|
||||
*/
|
||||
public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout) {
|
||||
// Labels for data segment addresses
|
||||
@@ -419,7 +425,7 @@ public class BTCACCT {
|
||||
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, Mode.CANCELLED.value));
|
||||
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());
|
||||
|
||||
@@ -470,7 +476,7 @@ public class BTCACCT {
|
||||
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, Mode.TRADING.value));
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value));
|
||||
|
||||
// Set restart position to after this opcode
|
||||
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
||||
@@ -568,7 +574,7 @@ public class BTCACCT {
|
||||
// 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, Mode.REDEEMED.value));
|
||||
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());
|
||||
|
||||
@@ -578,7 +584,7 @@ public class BTCACCT {
|
||||
labelRefund = codeByteBuffer.position();
|
||||
|
||||
// Set refunded mode
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REFUNDED.value));
|
||||
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) {
|
||||
@@ -591,7 +597,7 @@ public class BTCACCT {
|
||||
byte[] codeBytes = new byte[codeByteBuffer.limit()];
|
||||
codeByteBuffer.get(codeBytes);
|
||||
|
||||
assert Arrays.equals(Crypto.digest(codeBytes), BTCACCT.CODE_BYTES_HASH)
|
||||
assert Arrays.equals(Crypto.digest(codeBytes), BitcoinACCTv1.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;
|
||||
@@ -604,44 +610,37 @@ public class BTCACCT {
|
||||
|
||||
/**
|
||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||
*
|
||||
* @param repository
|
||||
* @param atAddress
|
||||
* @throws DataException
|
||||
*/
|
||||
public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
||||
@Override
|
||||
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
||||
ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
|
||||
return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData);
|
||||
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||
*
|
||||
* @param repository
|
||||
* @param atAddress
|
||||
* @throws DataException
|
||||
*/
|
||||
public static CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
|
||||
byte[] creatorPublicKey = repository.getATRepository().getCreatorPublicKey(atStateData.getATAddress());
|
||||
return populateTradeData(repository, creatorPublicKey, atStateData);
|
||||
@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.
|
||||
*
|
||||
* @param repository
|
||||
* @param atAddress
|
||||
* @throws DataException
|
||||
*/
|
||||
public static CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, ATStateData atStateData) throws DataException {
|
||||
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.BITCOIN.name();
|
||||
tradeData.acctName = NAME;
|
||||
|
||||
tradeData.qortalAtAddress = atAddress;
|
||||
tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey);
|
||||
tradeData.creationTimestamp = atStateData.getCreation();
|
||||
tradeData.creationTimestamp = creationTimestamp;
|
||||
|
||||
Account atAccount = new Account(repository, atAddress);
|
||||
tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT);
|
||||
@@ -658,9 +657,9 @@ public class BTCACCT {
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
|
||||
|
||||
// Creator's Bitcoin/foreign public key hash
|
||||
tradeData.creatorBitcoinPKH = new byte[20];
|
||||
dataByteBuffer.get(tradeData.creatorBitcoinPKH);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorBitcoinPKH.length); // skip to 32 bytes
|
||||
tradeData.creatorForeignPKH = new byte[20];
|
||||
dataByteBuffer.get(tradeData.creatorForeignPKH);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes
|
||||
|
||||
// Hash of secret B
|
||||
tradeData.hashOfSecretB = new byte[20];
|
||||
@@ -671,7 +670,7 @@ public class BTCACCT {
|
||||
tradeData.qortAmount = dataByteBuffer.getLong();
|
||||
|
||||
// Expected BTC amount
|
||||
tradeData.expectedBitcoin = dataByteBuffer.getLong();
|
||||
tradeData.expectedForeignAmount = dataByteBuffer.getLong();
|
||||
|
||||
// Trade timeout
|
||||
tradeData.tradeTimeout = (int) dataByteBuffer.getLong();
|
||||
@@ -784,26 +783,28 @@ public class BTCACCT {
|
||||
|
||||
// Trade AT's 'mode'
|
||||
long modeValue = dataByteBuffer.getLong();
|
||||
Mode mode = Mode.valueOf((int) (modeValue & 0xffL));
|
||||
AcctMode acctMode = AcctMode.valueOf((int) (modeValue & 0xffL));
|
||||
|
||||
/* End of variables */
|
||||
|
||||
if (mode != null && mode != Mode.OFFERING) {
|
||||
tradeData.mode = mode;
|
||||
if (acctMode != null && acctMode != AcctMode.OFFERING) {
|
||||
tradeData.mode = acctMode;
|
||||
tradeData.refundTimeout = refundTimeout;
|
||||
tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
|
||||
tradeData.qortalPartnerAddress = qortalRecipient;
|
||||
tradeData.hashOfSecretA = hashOfSecretA;
|
||||
tradeData.partnerBitcoinPKH = partnerBitcoinPKH;
|
||||
tradeData.partnerForeignPKH = partnerBitcoinPKH;
|
||||
tradeData.lockTimeA = lockTimeA;
|
||||
tradeData.lockTimeB = lockTimeB;
|
||||
|
||||
if (mode == Mode.REDEEMED)
|
||||
if (acctMode == AcctMode.REDEEMED)
|
||||
tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress);
|
||||
} else {
|
||||
tradeData.mode = Mode.OFFERING;
|
||||
tradeData.mode = AcctMode.OFFERING;
|
||||
}
|
||||
|
||||
tradeData.duplicateDeprecated();
|
||||
|
||||
return tradeData;
|
||||
}
|
||||
|
||||
@@ -843,7 +844,8 @@ public class BTCACCT {
|
||||
}
|
||||
|
||||
/** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */
|
||||
public static byte[] buildCancelMessage(String creatorQortalAddress) {
|
||||
@Override
|
||||
public byte[] buildCancelMessage(String creatorQortalAddress) {
|
||||
byte[] data = new byte[CANCEL_MESSAGE_LENGTH];
|
||||
byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress);
|
||||
|
||||
@@ -866,7 +868,7 @@ public class BTCACCT {
|
||||
|
||||
/** Returns P2SH-B lockTime (epoch seconds) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */
|
||||
public static int calcLockTimeB(long offerMessageTimestamp, int lockTimeA) {
|
||||
// lockTimeB is halfway between offerMessageTimesamp and lockTimeA
|
||||
// lockTimeB is halfway between offerMessageTimestamp and lockTimeA
|
||||
return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L);
|
||||
}
|
||||
|
||||
@@ -874,7 +876,8 @@ public class BTCACCT {
|
||||
String atAddress = crossChainTradeData.qortalAtAddress;
|
||||
String redeemerAddress = crossChainTradeData.qortalPartnerAddress;
|
||||
|
||||
List<MessageTransactionData> messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(atAddress, null, null, null);
|
||||
// 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;
|
||||
|
||||
740
src/main/java/org/qortal/crosschain/Bitcoiny.java
Normal file
740
src/main/java/org/qortal/crosschain/Bitcoiny.java
Normal file
@@ -0,0 +1,740 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bitcoinj.core.Address;
|
||||
import org.bitcoinj.core.AddressFormatException;
|
||||
import org.bitcoinj.core.Coin;
|
||||
import org.bitcoinj.core.Context;
|
||||
import org.bitcoinj.core.ECKey;
|
||||
import org.bitcoinj.core.InsufficientMoneyException;
|
||||
import org.bitcoinj.core.LegacyAddress;
|
||||
import org.bitcoinj.core.NetworkParameters;
|
||||
import org.bitcoinj.core.Sha256Hash;
|
||||
import org.bitcoinj.core.Transaction;
|
||||
import org.bitcoinj.core.TransactionOutput;
|
||||
import org.bitcoinj.core.UTXO;
|
||||
import org.bitcoinj.core.UTXOProvider;
|
||||
import org.bitcoinj.core.UTXOProviderException;
|
||||
import org.bitcoinj.crypto.ChildNumber;
|
||||
import org.bitcoinj.crypto.DeterministicHierarchy;
|
||||
import org.bitcoinj.crypto.DeterministicKey;
|
||||
import org.bitcoinj.script.Script.ScriptType;
|
||||
import org.bitcoinj.script.ScriptBuilder;
|
||||
import org.bitcoinj.wallet.DeterministicKeyChain;
|
||||
import org.bitcoinj.wallet.SendRequest;
|
||||
import org.bitcoinj.wallet.Wallet;
|
||||
import org.qortal.api.model.SimpleForeignTransaction;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.utils.Amounts;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
|
||||
/** Bitcoin-like (Bitcoin, Litecoin, etc.) support */
|
||||
public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
|
||||
protected static final Logger LOGGER = LogManager.getLogger(Bitcoiny.class);
|
||||
|
||||
public static final int HASH160_LENGTH = 20;
|
||||
|
||||
protected final BitcoinyBlockchainProvider blockchain;
|
||||
protected final Context bitcoinjContext;
|
||||
protected final String currencyCode;
|
||||
|
||||
protected final NetworkParameters params;
|
||||
|
||||
/** Keys that have been previously marked as fully spent,<br>
|
||||
* i.e. keys with transactions but with no unspent outputs. */
|
||||
protected final Set<ECKey> spentKeys = Collections.synchronizedSet(new HashSet<>());
|
||||
|
||||
/** How many bitcoinj wallet keys to generate in each batch. */
|
||||
private static final int WALLET_KEY_LOOKAHEAD_INCREMENT = 3;
|
||||
|
||||
/** Byte offset into raw block headers to block timestamp. */
|
||||
private static final int TIMESTAMP_OFFSET = 4 + 32 + 32;
|
||||
|
||||
// Constructors and instance
|
||||
|
||||
protected Bitcoiny(BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) {
|
||||
this.blockchain = blockchain;
|
||||
this.bitcoinjContext = bitcoinjContext;
|
||||
this.currencyCode = currencyCode;
|
||||
|
||||
this.params = this.bitcoinjContext.getParams();
|
||||
}
|
||||
|
||||
// Getters & setters
|
||||
|
||||
public BitcoinyBlockchainProvider getBlockchainProvider() {
|
||||
return this.blockchain;
|
||||
}
|
||||
|
||||
public Context getBitcoinjContext() {
|
||||
return this.bitcoinjContext;
|
||||
}
|
||||
|
||||
public String getCurrencyCode() {
|
||||
return this.currencyCode;
|
||||
}
|
||||
|
||||
public NetworkParameters getNetworkParameters() {
|
||||
return this.params;
|
||||
}
|
||||
|
||||
// Interface obligations
|
||||
|
||||
@Override
|
||||
public boolean isValidAddress(String address) {
|
||||
try {
|
||||
ScriptType addressType = Address.fromString(this.params, address).getOutputScriptType();
|
||||
|
||||
return addressType == ScriptType.P2PKH || addressType == ScriptType.P2SH;
|
||||
} catch (AddressFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValidWalletKey(String walletKey) {
|
||||
return this.isValidDeterministicKey(walletKey);
|
||||
}
|
||||
|
||||
// Actual useful methods for use by other classes
|
||||
|
||||
public String format(Coin amount) {
|
||||
return this.format(amount.value);
|
||||
}
|
||||
|
||||
public String format(long amount) {
|
||||
return Amounts.prettyAmount(amount) + " " + this.currencyCode;
|
||||
}
|
||||
|
||||
public boolean isValidDeterministicKey(String key58) {
|
||||
try {
|
||||
Context.propagate(this.bitcoinjContext);
|
||||
DeterministicKey.deserializeB58(null, key58, this.params);
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns P2PKH address using passed public key hash. */
|
||||
public String pkhToAddress(byte[] publicKeyHash) {
|
||||
Context.propagate(this.bitcoinjContext);
|
||||
return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString();
|
||||
}
|
||||
|
||||
/** Returns P2SH address using passed redeem script. */
|
||||
public String deriveP2shAddress(byte[] redeemScriptBytes) {
|
||||
Context.propagate(bitcoinjContext);
|
||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||
return LegacyAddress.fromScriptHash(this.params, redeemScriptHash).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns median timestamp from latest 11 blocks, in seconds.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
public int getMedianBlockTime() throws ForeignBlockchainException {
|
||||
int height = this.blockchain.getCurrentHeight();
|
||||
|
||||
// Grab latest 11 blocks
|
||||
List<byte[]> blockHeaders = this.blockchain.getRawBlockHeaders(height - 11, 11);
|
||||
if (blockHeaders.size() < 11)
|
||||
throw new ForeignBlockchainException("Not enough blocks to determine median block time");
|
||||
|
||||
List<Integer> blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
|
||||
|
||||
// Descending order
|
||||
blockTimestamps.sort((a, b) -> Integer.compare(b, a));
|
||||
|
||||
// Pick median
|
||||
return blockTimestamps.get(5);
|
||||
}
|
||||
|
||||
/** Returns fee per transaction KB. To be overridden for testnet/regtest. */
|
||||
public Coin getFeePerKb() {
|
||||
return this.bitcoinjContext.getFeePerKb();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException;
|
||||
|
||||
/**
|
||||
* Returns confirmed balance, based on passed payment script.
|
||||
* <p>
|
||||
* @return confirmed balance, or zero if script unknown
|
||||
* @throws ForeignBlockchainException if there was an error
|
||||
*/
|
||||
public long getConfirmedBalance(String base58Address) throws ForeignBlockchainException {
|
||||
return this.blockchain.getConfirmedBalance(addressToScriptPubKey(base58Address));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of unspent outputs pertaining to passed address.
|
||||
* <p>
|
||||
* @return list of unspent outputs, or empty list if address unknown
|
||||
* @throws ForeignBlockchainException if there was an error.
|
||||
*/
|
||||
// TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead
|
||||
public List<TransactionOutput> getUnspentOutputs(String base58Address) throws ForeignBlockchainException {
|
||||
List<UnspentOutput> unspentOutputs = this.blockchain.getUnspentOutputs(addressToScriptPubKey(base58Address), false);
|
||||
|
||||
List<TransactionOutput> unspentTransactionOutputs = new ArrayList<>();
|
||||
for (UnspentOutput unspentOutput : unspentOutputs) {
|
||||
List<TransactionOutput> transactionOutputs = this.getOutputs(unspentOutput.hash);
|
||||
|
||||
unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index));
|
||||
}
|
||||
|
||||
return unspentTransactionOutputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of outputs pertaining to passed transaction hash.
|
||||
* <p>
|
||||
* @return list of outputs, or empty list if transaction unknown
|
||||
* @throws ForeignBlockchainException if there was an error.
|
||||
*/
|
||||
// TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead
|
||||
public List<TransactionOutput> getOutputs(byte[] txHash) throws ForeignBlockchainException {
|
||||
byte[] rawTransactionBytes = this.blockchain.getRawTransaction(txHash);
|
||||
|
||||
Context.propagate(bitcoinjContext);
|
||||
Transaction transaction = new Transaction(this.params, rawTransactionBytes);
|
||||
return transaction.getOutputs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of transaction hashes pertaining to passed address.
|
||||
* <p>
|
||||
* @return list of unspent outputs, or empty list if script unknown
|
||||
* @throws ForeignBlockchainException if there was an error.
|
||||
*/
|
||||
public List<TransactionHash> getAddressTransactions(String base58Address, boolean includeUnconfirmed) throws ForeignBlockchainException {
|
||||
return this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), includeUnconfirmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of raw, confirmed transactions involving given address.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException if there was an error
|
||||
*/
|
||||
public List<byte[]> getAddressTransactions(String base58Address) throws ForeignBlockchainException {
|
||||
List<TransactionHash> transactionHashes = this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), false);
|
||||
|
||||
List<byte[]> rawTransactions = new ArrayList<>();
|
||||
for (TransactionHash transactionInfo : transactionHashes) {
|
||||
byte[] rawTransaction = this.blockchain.getRawTransaction(HashCode.fromString(transactionInfo.txHash).asBytes());
|
||||
rawTransactions.add(rawTransaction);
|
||||
}
|
||||
|
||||
return rawTransactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns transaction info for passed transaction hash.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException.NotFoundException if transaction unknown
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
public BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException {
|
||||
return this.blockchain.getTransaction(txHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts raw transaction to network.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
public void broadcastTransaction(Transaction transaction) throws ForeignBlockchainException {
|
||||
this.blockchain.broadcastTransaction(transaction.bitcoinSerialize());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns bitcoinj transaction sending <tt>amount</tt> to <tt>recipient</tt>.
|
||||
*
|
||||
* @param xprv58 BIP32 private key
|
||||
* @param recipient P2PKH address
|
||||
* @param amount unscaled amount
|
||||
* @param feePerByte unscaled fee per byte, or null to use default fees
|
||||
* @return transaction, or null if insufficient funds
|
||||
*/
|
||||
public Transaction buildSpend(String xprv58, String recipient, long amount, Long feePerByte) {
|
||||
Context.propagate(bitcoinjContext);
|
||||
|
||||
Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
||||
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet));
|
||||
|
||||
Address destination = Address.fromString(this.params, recipient);
|
||||
SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount));
|
||||
|
||||
if (feePerByte != null)
|
||||
sendRequest.feePerKb = Coin.valueOf(feePerByte * 1000L); // Note: 1000 not 1024
|
||||
else
|
||||
// Allow override of default for TestNet3, etc.
|
||||
sendRequest.feePerKb = this.getFeePerKb();
|
||||
|
||||
try {
|
||||
wallet.completeTx(sendRequest);
|
||||
return sendRequest.tx;
|
||||
} catch (InsufficientMoneyException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return transaction, or null if insufficient funds
|
||||
*/
|
||||
public Transaction buildSpend(String xprv58, String recipient, long amount) {
|
||||
return buildSpend(xprv58, recipient, amount, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns unspent Bitcoin balance given 'm' BIP32 key.
|
||||
*
|
||||
* @param key58 BIP32/HD extended Bitcoin private/public key
|
||||
* @return unspent BTC balance, or null if unable to determine balance
|
||||
*/
|
||||
public Long getWalletBalance(String key58) {
|
||||
Context.propagate(bitcoinjContext);
|
||||
|
||||
Wallet wallet = walletFromDeterministicKey58(key58);
|
||||
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet));
|
||||
|
||||
Coin balance = wallet.getBalance();
|
||||
if (balance == null)
|
||||
return null;
|
||||
|
||||
return balance.value;
|
||||
}
|
||||
|
||||
public List<SimpleTransaction> getWalletTransactions(String key58) throws ForeignBlockchainException {
|
||||
Context.propagate(bitcoinjContext);
|
||||
|
||||
Wallet wallet = walletFromDeterministicKey58(key58);
|
||||
DeterministicKeyChain keyChain = wallet.getActiveKeyChain();
|
||||
|
||||
keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
|
||||
keyChain.maybeLookAhead();
|
||||
|
||||
List<DeterministicKey> keys = new ArrayList<>(keyChain.getLeafKeys());
|
||||
|
||||
Set<BitcoinyTransaction> walletTransactions = new HashSet<>();
|
||||
Set<String> keySet = new HashSet<>();
|
||||
|
||||
int ki = 0;
|
||||
do {
|
||||
boolean areAllKeysUnused = true;
|
||||
|
||||
for (; ki < keys.size(); ++ki) {
|
||||
DeterministicKey dKey = keys.get(ki);
|
||||
|
||||
// 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
|
||||
List<TransactionHash> historicTransactionHashes = this.blockchain.getAddressTransactions(script, false);
|
||||
|
||||
if (!historicTransactionHashes.isEmpty()) {
|
||||
areAllKeysUnused = false;
|
||||
|
||||
for (TransactionHash transactionHash : historicTransactionHashes)
|
||||
walletTransactions.add(this.getTransaction(transactionHash.txHash));
|
||||
}
|
||||
}
|
||||
|
||||
if (areAllKeysUnused)
|
||||
// No transactions for this batch of keys so assume we're done searching.
|
||||
break;
|
||||
|
||||
// Generate some more keys
|
||||
keys.addAll(generateMoreKeys(keyChain));
|
||||
|
||||
// Process new keys
|
||||
} while (true);
|
||||
|
||||
Comparator<SimpleTransaction> newestTimestampFirstComparator = Comparator.comparingInt(SimpleTransaction::getTimestamp).reversed();
|
||||
|
||||
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;
|
||||
for (BitcoinyTransaction.Input input : t.inputs) {
|
||||
try {
|
||||
BitcoinyTransaction t2 = getTransaction(input.outputTxHash);
|
||||
List<String> senders = t2.outputs.get(input.outputVout).addresses;
|
||||
for (String sender : senders) {
|
||||
if (keySet.contains(sender)) {
|
||||
total += t2.outputs.get(input.outputVout).value;
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
if (keySet.contains(address)) {
|
||||
if (total > 0L) {
|
||||
amount -= (total - output.value);
|
||||
} else {
|
||||
amount += output.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new SimpleTransaction(t.txHash, t.timestamp, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns first unused receive address given 'm' BIP32 key.
|
||||
*
|
||||
* @param key58 BIP32/HD extended Bitcoin private/public key
|
||||
* @return P2PKH address
|
||||
* @throws ForeignBlockchainException if something went wrong
|
||||
*/
|
||||
public String getUnusedReceiveAddress(String key58) throws ForeignBlockchainException {
|
||||
Context.propagate(bitcoinjContext);
|
||||
|
||||
Wallet wallet = walletFromDeterministicKey58(key58);
|
||||
DeterministicKeyChain keyChain = wallet.getActiveKeyChain();
|
||||
|
||||
keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
|
||||
keyChain.maybeLookAhead();
|
||||
|
||||
final int keyChainPathSize = keyChain.getAccountPath().size();
|
||||
List<DeterministicKey> keys = new ArrayList<>(keyChain.getLeafKeys());
|
||||
|
||||
int ki = 0;
|
||||
do {
|
||||
for (; ki < keys.size(); ++ki) {
|
||||
DeterministicKey dKey = keys.get(ki);
|
||||
List<ChildNumber> dKeyPath = dKey.getPath();
|
||||
|
||||
// If keyChain is based on 'm', then make sure dKey is m/0/ki - i.e. a 'receive' address, not 'change' (m/1/ki)
|
||||
if (dKeyPath.size() != keyChainPathSize + 2 || dKeyPath.get(dKeyPath.size() - 2) != ChildNumber.ZERO)
|
||||
continue;
|
||||
|
||||
// Check unspent
|
||||
Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH);
|
||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||
|
||||
List<UnspentOutput> unspentOutputs = this.blockchain.getUnspentOutputs(script, false);
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
if (unspentOutputs.isEmpty()) {
|
||||
// If this is a known key that has been spent before, then we can skip asking for transaction history
|
||||
if (this.spentKeys.contains(dKey)) {
|
||||
wallet.getActiveKeyChain().markKeyAsUsed(dKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ask for transaction history - if it's empty then key has never been used
|
||||
List<TransactionHash> historicTransactionHashes = this.blockchain.getAddressTransactions(script, false);
|
||||
|
||||
if (!historicTransactionHashes.isEmpty()) {
|
||||
// Fully spent key - case (a)
|
||||
this.spentKeys.add(dKey);
|
||||
wallet.getActiveKeyChain().markKeyAsUsed(dKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key never been used - case (b)
|
||||
return address.toString();
|
||||
}
|
||||
|
||||
// Key has unspent outputs, hence used, so no good to us
|
||||
this.spentKeys.remove(dKey);
|
||||
}
|
||||
|
||||
// Generate some more keys
|
||||
keys.addAll(generateMoreKeys(keyChain));
|
||||
|
||||
// Process new keys
|
||||
} while (true);
|
||||
}
|
||||
|
||||
// UTXOProvider support
|
||||
|
||||
static class WalletAwareUTXOProvider implements UTXOProvider {
|
||||
private final Bitcoiny bitcoiny;
|
||||
private final Wallet wallet;
|
||||
|
||||
private final DeterministicKeyChain keyChain;
|
||||
|
||||
public WalletAwareUTXOProvider(Bitcoiny bitcoiny, Wallet wallet) {
|
||||
this.bitcoiny = bitcoiny;
|
||||
this.wallet = wallet;
|
||||
this.keyChain = this.wallet.getActiveKeyChain();
|
||||
|
||||
// Set up wallet's key chain
|
||||
this.keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
|
||||
this.keyChain.maybeLookAhead();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UTXO> getOpenTransactionOutputs(List<ECKey> keys) throws UTXOProviderException {
|
||||
List<UTXO> allUnspentOutputs = new ArrayList<>();
|
||||
final boolean coinbase = false;
|
||||
|
||||
int ki = 0;
|
||||
do {
|
||||
boolean areAllKeysUnspent = true;
|
||||
|
||||
for (; ki < keys.size(); ++ki) {
|
||||
ECKey key = keys.get(ki);
|
||||
|
||||
Address address = Address.fromKey(this.bitcoiny.params, key, ScriptType.P2PKH);
|
||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||
|
||||
List<UnspentOutput> unspentOutputs;
|
||||
try {
|
||||
unspentOutputs = this.bitcoiny.blockchain.getUnspentOutputs(script, false);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address));
|
||||
}
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
if (unspentOutputs.isEmpty()) {
|
||||
// If this is a known key that has been spent before, then we can skip asking for transaction history
|
||||
if (this.bitcoiny.spentKeys.contains(key)) {
|
||||
this.wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
|
||||
areAllKeysUnspent = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ask for transaction history - if it's empty then key has never been used
|
||||
List<TransactionHash> historicTransactionHashes;
|
||||
try {
|
||||
historicTransactionHashes = this.bitcoiny.blockchain.getAddressTransactions(script, false);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw new UTXOProviderException(String.format("Unable to fetch transaction history for %s", address));
|
||||
}
|
||||
|
||||
if (!historicTransactionHashes.isEmpty()) {
|
||||
// Fully spent key - case (a)
|
||||
this.bitcoiny.spentKeys.add(key);
|
||||
this.wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
|
||||
areAllKeysUnspent = false;
|
||||
} else {
|
||||
// Key never been used - case (b)
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we reach here, then there's definitely at least one unspent key
|
||||
this.bitcoiny.spentKeys.remove(key);
|
||||
|
||||
for (UnspentOutput unspentOutput : unspentOutputs) {
|
||||
List<TransactionOutput> transactionOutputs;
|
||||
try {
|
||||
transactionOutputs = this.bitcoiny.getOutputs(unspentOutput.hash);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s",
|
||||
HashCode.fromBytes(unspentOutput.hash)));
|
||||
}
|
||||
|
||||
TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index);
|
||||
|
||||
UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index,
|
||||
Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase,
|
||||
transactionOutput.getScriptPubKey());
|
||||
|
||||
allUnspentOutputs.add(utxo);
|
||||
}
|
||||
}
|
||||
|
||||
if (areAllKeysUnspent)
|
||||
// No transactions for this batch of keys so assume we're done searching.
|
||||
return allUnspentOutputs;
|
||||
|
||||
// Generate some more keys
|
||||
keys.addAll(Bitcoiny.generateMoreKeys(this.keyChain));
|
||||
|
||||
// Process new keys
|
||||
} while (true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getChainHeadHeight() throws UTXOProviderException {
|
||||
try {
|
||||
return this.bitcoiny.blockchain.getCurrentHeight();
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw new UTXOProviderException("Unable to determine Bitcoiny chain height");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return this.bitcoiny.params;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods for others
|
||||
|
||||
public static List<SimpleForeignTransaction> simplifyWalletTransactions(List<BitcoinyTransaction> transactions) {
|
||||
// Sort by oldest timestamp first
|
||||
transactions.sort(Comparator.comparingInt(t -> t.timestamp));
|
||||
|
||||
// Manual 2nd-level sort same-timestamp transactions so that a transaction's input comes first
|
||||
int fromIndex = 0;
|
||||
do {
|
||||
int timestamp = transactions.get(fromIndex).timestamp;
|
||||
|
||||
int toIndex;
|
||||
for (toIndex = fromIndex + 1; toIndex < transactions.size(); ++toIndex)
|
||||
if (transactions.get(toIndex).timestamp != timestamp)
|
||||
break;
|
||||
|
||||
// Process same-timestamp sub-list
|
||||
List<BitcoinyTransaction> subList = transactions.subList(fromIndex, toIndex);
|
||||
|
||||
// Only if necessary
|
||||
if (subList.size() > 1) {
|
||||
// Quick index lookup
|
||||
Map<String, Integer> indexByTxHash = subList.stream().collect(Collectors.toMap(t -> t.txHash, t -> t.timestamp));
|
||||
|
||||
int restartIndex = 0;
|
||||
boolean isSorted;
|
||||
do {
|
||||
isSorted = true;
|
||||
|
||||
for (int ourIndex = restartIndex; ourIndex < subList.size(); ++ourIndex) {
|
||||
BitcoinyTransaction ourTx = subList.get(ourIndex);
|
||||
|
||||
for (BitcoinyTransaction.Input input : ourTx.inputs) {
|
||||
Integer inputIndex = indexByTxHash.get(input.outputTxHash);
|
||||
|
||||
if (inputIndex != null && inputIndex > ourIndex) {
|
||||
// Input tx is currently after current tx, so swap
|
||||
BitcoinyTransaction tmpTx = subList.get(inputIndex);
|
||||
subList.set(inputIndex, ourTx);
|
||||
subList.set(ourIndex, tmpTx);
|
||||
|
||||
// Update index lookup too
|
||||
indexByTxHash.put(ourTx.txHash, inputIndex);
|
||||
indexByTxHash.put(tmpTx.txHash, ourIndex);
|
||||
|
||||
if (isSorted)
|
||||
restartIndex = Math.max(restartIndex, ourIndex);
|
||||
|
||||
isSorted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (!isSorted);
|
||||
}
|
||||
|
||||
fromIndex = toIndex;
|
||||
} while (fromIndex < transactions.size());
|
||||
|
||||
// Simplify
|
||||
List<SimpleForeignTransaction> simpleTransactions = new ArrayList<>();
|
||||
|
||||
// Quick lookup of txs in our wallet
|
||||
Set<String> walletTxHashes = transactions.stream().map(t -> t.txHash).collect(Collectors.toSet());
|
||||
|
||||
for (BitcoinyTransaction transaction : transactions) {
|
||||
SimpleForeignTransaction.Builder builder = new SimpleForeignTransaction.Builder();
|
||||
builder.txHash(transaction.txHash);
|
||||
builder.timestamp(transaction.timestamp);
|
||||
|
||||
builder.isSentNotReceived(false);
|
||||
|
||||
for (BitcoinyTransaction.Input input : transaction.inputs) {
|
||||
// TODO: add input via builder
|
||||
|
||||
if (walletTxHashes.contains(input.outputTxHash))
|
||||
builder.isSentNotReceived(true);
|
||||
}
|
||||
|
||||
for (BitcoinyTransaction.Output output : transaction.outputs)
|
||||
builder.output(output.addresses, output.value);
|
||||
|
||||
simpleTransactions.add(builder.build());
|
||||
}
|
||||
|
||||
return simpleTransactions;
|
||||
}
|
||||
|
||||
// Utility methods for us
|
||||
|
||||
protected static List<DeterministicKey> generateMoreKeys(DeterministicKeyChain keyChain) {
|
||||
int existingLeafKeyCount = keyChain.getLeafKeys().size();
|
||||
|
||||
// Increase lookahead size...
|
||||
keyChain.setLookaheadSize(keyChain.getLookaheadSize() + Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
|
||||
// ...and lookahead threshold (minimum number of keys to generate)...
|
||||
keyChain.setLookaheadThreshold(0);
|
||||
// ...so that this call will generate more keys
|
||||
keyChain.maybeLookAhead();
|
||||
|
||||
// This returns *all* keys
|
||||
List<DeterministicKey> allLeafKeys = keyChain.getLeafKeys();
|
||||
|
||||
// Only return newly generated keys
|
||||
return allLeafKeys.subList(existingLeafKeyCount, allLeafKeys.size());
|
||||
}
|
||||
|
||||
protected byte[] addressToScriptPubKey(String base58Address) {
|
||||
Context.propagate(this.bitcoinjContext);
|
||||
Address address = Address.fromString(this.params, base58Address);
|
||||
return ScriptBuilder.createOutputScript(address).getProgram();
|
||||
}
|
||||
|
||||
protected Wallet walletFromDeterministicKey58(String key58) {
|
||||
DeterministicKey dKey = DeterministicKey.deserializeB58(null, key58, this.params);
|
||||
|
||||
if (dKey.hasPrivKey())
|
||||
return Wallet.fromSpendingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
||||
else
|
||||
return Wallet.fromWatchingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public abstract class BitcoinyBlockchainProvider {
|
||||
|
||||
public static final boolean INCLUDE_UNCONFIRMED = true;
|
||||
public static final boolean EXCLUDE_UNCONFIRMED = false;
|
||||
|
||||
/** Returns ID unique to bitcoiny network (e.g. "Litecoin-TEST3") */
|
||||
public abstract String getNetId();
|
||||
|
||||
/** Returns current blockchain height. */
|
||||
public abstract int getCurrentHeight() throws ForeignBlockchainException;
|
||||
|
||||
/** Returns a list of raw block headers, starting at <tt>startHeight</tt> (inclusive), up to <tt>count</tt> max. */
|
||||
public abstract List<byte[]> getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException;
|
||||
|
||||
/** Returns balance of address represented by <tt>scriptPubKey</tt>. */
|
||||
public abstract long getConfirmedBalance(byte[] scriptPubKey) throws ForeignBlockchainException;
|
||||
|
||||
/** Returns raw, serialized, transaction bytes given <tt>txHash</tt>. */
|
||||
public abstract byte[] getRawTransaction(String txHash) throws ForeignBlockchainException;
|
||||
|
||||
/** Returns raw, serialized, transaction bytes given <tt>txHash</tt>. */
|
||||
public abstract byte[] getRawTransaction(byte[] txHash) throws ForeignBlockchainException;
|
||||
|
||||
/** Returns unpacked transaction given <tt>txHash</tt>. */
|
||||
public abstract BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException;
|
||||
|
||||
/** Returns list of transaction hashes (and heights) for address represented by <tt>scriptPubKey</tt>, optionally including unconfirmed transactions. */
|
||||
public abstract List<TransactionHash> getAddressTransactions(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException;
|
||||
|
||||
/** Returns list of unspent transaction outputs for address represented by <tt>scriptPubKey</tt>, optionally including unconfirmed transactions. */
|
||||
public abstract List<UnspentOutput> getUnspentOutputs(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException;
|
||||
|
||||
/** Broadcasts raw, serialized, transaction bytes to network, returning success/failure. */
|
||||
public abstract void broadcastTransaction(byte[] rawTransaction) throws ForeignBlockchainException;
|
||||
|
||||
}
|
||||
438
src/main/java/org/qortal/crosschain/BitcoinyHTLC.java
Normal file
438
src/main/java/org/qortal/crosschain/BitcoinyHTLC.java
Normal file
@@ -0,0 +1,438 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.bitcoinj.core.Address;
|
||||
import org.bitcoinj.core.Coin;
|
||||
import org.bitcoinj.core.ECKey;
|
||||
import org.bitcoinj.core.LegacyAddress;
|
||||
import org.bitcoinj.core.NetworkParameters;
|
||||
import org.bitcoinj.core.Transaction;
|
||||
import org.bitcoinj.core.Transaction.SigHash;
|
||||
import org.bitcoinj.core.TransactionInput;
|
||||
import org.bitcoinj.core.TransactionOutput;
|
||||
import org.bitcoinj.crypto.TransactionSignature;
|
||||
import org.bitcoinj.script.Script;
|
||||
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;
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
public class BitcoinyHTLC {
|
||||
|
||||
public enum Status {
|
||||
UNFUNDED, FUNDING_IN_PROGRESS, FUNDED, REDEEM_IN_PROGRESS, REDEEMED, REFUND_IN_PROGRESS, REFUNDED
|
||||
}
|
||||
|
||||
public static final int SECRET_LENGTH = 32;
|
||||
public static final int MIN_LOCKTIME = 1500000000;
|
||||
|
||||
public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL;
|
||||
public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1;
|
||||
|
||||
// Assuming node's trade-bot has no more than 100 entries?
|
||||
private static final int MAX_CACHE_ENTRIES = 100;
|
||||
|
||||
// Max time-to-live for cache entries (milliseconds)
|
||||
private static final long CACHE_TIMEOUT = 30_000L;
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
private static final Map<String, byte[]> SECRET_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) {
|
||||
// This method is called just after a new entry has been added
|
||||
@Override
|
||||
public boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
|
||||
return size() > MAX_CACHE_ENTRIES;
|
||||
}
|
||||
};
|
||||
private static final byte[] NO_SECRET_CACHE_ENTRY = new byte[0];
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
private static final Map<String, Status> STATUS_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) {
|
||||
// This method is called just after a new entry has been added
|
||||
@Override
|
||||
public boolean removeEldestEntry(Map.Entry<String, Status> eldest) {
|
||||
return size() > MAX_CACHE_ENTRIES;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* OP_TUCK (to copy public key to before signature)
|
||||
* OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails)
|
||||
* OP_HASH160 (convert public key to PKH)
|
||||
* OP_DUP (duplicate PKH)
|
||||
* <push 20 bytes> <refund PKH> OP_EQUAL (does PKH match refund PKH?)
|
||||
* OP_IF
|
||||
* OP_DROP (no need for duplicate PKH)
|
||||
* <push 4 bytes> <locktime>
|
||||
* OP_CHECKLOCKTIMEVERIFY (if this passes, leftover stack is <locktime> so script passes)
|
||||
* OP_ELSE
|
||||
* <push 20 bytes> <redeem PKH> OP_EQUALVERIFY (duplicate PKH must match redeem PKH or script fails)
|
||||
* OP_HASH160 (hash secret)
|
||||
* <push 20 bytes> <hash of secret> OP_EQUAL (do hashes of secrets match? if true, script passes else script fails)
|
||||
* OP_ENDIF
|
||||
*/
|
||||
|
||||
private static final byte[] redeemScript1 = HashCode.fromString("7dada97614").asBytes(); // OP_TUCK OP_CHECKSIGVERIFY OP_HASH160 OP_DUP push(0x14 bytes)
|
||||
private static final byte[] redeemScript2 = HashCode.fromString("87637504").asBytes(); // OP_EQUAL OP_IF OP_DROP push(0x4 bytes)
|
||||
private static final byte[] redeemScript3 = HashCode.fromString("b16714").asBytes(); // OP_CHECKLOCKTIMEVERIFY OP_ELSE push(0x14 bytes)
|
||||
private static final byte[] redeemScript4 = HashCode.fromString("88a914").asBytes(); // OP_EQUALVERIFY OP_HASH160 push(0x14 bytes)
|
||||
private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF
|
||||
|
||||
/**
|
||||
* Returns redeemScript used for cross-chain trading.
|
||||
* <p>
|
||||
* See comments in {@link BitcoinyHTLC} for more details.
|
||||
*
|
||||
* @param refunderPubKeyHash 20-byte HASH160 of P2SH funder's public key, for refunding purposes
|
||||
* @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund
|
||||
* @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key
|
||||
* @param hashOfSecret 20-byte HASH160 of secret, used by P2SH redeemer to claim funds
|
||||
*/
|
||||
public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] hashOfSecret) {
|
||||
return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)),
|
||||
redeemScript3, redeemerPubKeyHash, redeemScript4, hashOfSecret, redeemScript5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a custom transaction to spend HTLC P2SH.
|
||||
*
|
||||
* @param params blockchain network parameters
|
||||
* @param amount output amount, should be total of input amounts, less miner fees
|
||||
* @param spendKey key for signing transaction, and also where funds are 'sent' (output)
|
||||
* @param fundingOutput output from transaction that funded P2SH address
|
||||
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
||||
* @param lockTime (optional) transaction nLockTime, used in refund scenario
|
||||
* @param scriptSigBuilder function for building scriptSig using transaction input signature
|
||||
* @param outputPublicKeyHash PKH used to create P2PKH output
|
||||
* @return Signed transaction for spending P2SH
|
||||
*/
|
||||
public static Transaction buildP2shTransaction(NetworkParameters params, Coin amount, ECKey spendKey,
|
||||
List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes,
|
||||
Long lockTime, Function<byte[], Script> scriptSigBuilder, byte[] outputPublicKeyHash) {
|
||||
Transaction transaction = new Transaction(params);
|
||||
transaction.setVersion(2);
|
||||
|
||||
// Output is back to P2SH funder
|
||||
transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(outputPublicKeyHash));
|
||||
|
||||
for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) {
|
||||
TransactionOutput fundingOutput = fundingOutputs.get(inputIndex);
|
||||
|
||||
// Input (without scriptSig prior to signing)
|
||||
TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor());
|
||||
if (lockTime != null)
|
||||
input.setSequenceNumber(LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
|
||||
else
|
||||
input.setSequenceNumber(NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF
|
||||
transaction.addInput(input);
|
||||
}
|
||||
|
||||
// Set locktime after inputs added but before input signatures are generated
|
||||
if (lockTime != null)
|
||||
transaction.setLockTime(lockTime);
|
||||
|
||||
for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) {
|
||||
// Generate transaction signature for input
|
||||
final boolean anyoneCanPay = false;
|
||||
TransactionSignature txSig = transaction.calculateSignature(inputIndex, spendKey, redeemScriptBytes, SigHash.ALL, anyoneCanPay);
|
||||
|
||||
// Calculate transaction signature
|
||||
byte[] txSigBytes = txSig.encodeToBitcoin();
|
||||
|
||||
// Build scriptSig using lambda and tx signature
|
||||
Script scriptSig = scriptSigBuilder.apply(txSigBytes);
|
||||
|
||||
// Set input scriptSig
|
||||
transaction.getInput(inputIndex).setScriptSig(scriptSig);
|
||||
}
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns signed transaction claiming refund from HTLC P2SH.
|
||||
*
|
||||
* @param params blockchain network parameters
|
||||
* @param refundAmount refund amount, should be total of input amounts, less miner fees
|
||||
* @param refundKey key for signing transaction
|
||||
* @param fundingOutputs outputs from transaction that funded P2SH address
|
||||
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
||||
* @param lockTime transaction nLockTime - must be at least locktime used in redeemScript
|
||||
* @param receivingAccountInfo public-key-hash used for P2PKH output
|
||||
* @return Signed transaction for refunding P2SH
|
||||
*/
|
||||
public static Transaction buildRefundTransaction(NetworkParameters params, Coin refundAmount, ECKey refundKey,
|
||||
List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, long lockTime, byte[] receivingAccountInfo) {
|
||||
Function<byte[], Script> refundSigScriptBuilder = (txSigBytes) -> {
|
||||
// Build scriptSig with...
|
||||
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
||||
|
||||
// transaction signature
|
||||
scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes));
|
||||
|
||||
// redeem public key
|
||||
byte[] refundPubKey = refundKey.getPubKey();
|
||||
scriptBuilder.addChunk(new ScriptChunk(refundPubKey.length, refundPubKey));
|
||||
|
||||
// redeem script
|
||||
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
|
||||
|
||||
return scriptBuilder.build();
|
||||
};
|
||||
|
||||
// Send funds back to funding address
|
||||
return buildP2shTransaction(params, refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, receivingAccountInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns signed transaction redeeming funds from P2SH address.
|
||||
*
|
||||
* @param params blockchain network parameters
|
||||
* @param redeemAmount redeem amount, should be total of input amounts, less miner fees
|
||||
* @param redeemKey key for signing transaction
|
||||
* @param fundingOutputs outputs from transaction that funded P2SH address
|
||||
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
||||
* @param secret actual 32-byte secret used when building redeemScript
|
||||
* @param receivingAccountInfo Bitcoin PKH used for output
|
||||
* @return Signed transaction for redeeming P2SH
|
||||
*/
|
||||
public static Transaction buildRedeemTransaction(NetworkParameters params, Coin redeemAmount, ECKey redeemKey,
|
||||
List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) {
|
||||
Function<byte[], Script> redeemSigScriptBuilder = (txSigBytes) -> {
|
||||
// Build scriptSig with...
|
||||
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
||||
|
||||
// secret
|
||||
scriptBuilder.addChunk(new ScriptChunk(secret.length, secret));
|
||||
|
||||
// transaction signature
|
||||
scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes));
|
||||
|
||||
// redeem public key
|
||||
byte[] redeemPubKey = redeemKey.getPubKey();
|
||||
scriptBuilder.addChunk(new ScriptChunk(redeemPubKey.length, redeemPubKey));
|
||||
|
||||
// redeem script
|
||||
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
|
||||
|
||||
return scriptBuilder.build();
|
||||
};
|
||||
|
||||
return buildP2shTransaction(params, redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns 'secret', if any, given HTLC's P2SH address.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException
|
||||
*/
|
||||
public static byte[] findHtlcSecret(Bitcoiny bitcoiny, String p2shAddress) throws ForeignBlockchainException {
|
||||
NetworkParameters params = bitcoiny.getNetworkParameters();
|
||||
String compoundKey = String.format("%s-%s-%d", params.getId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT);
|
||||
|
||||
byte[] secret = SECRET_CACHE.getOrDefault(compoundKey, NO_SECRET_CACHE_ENTRY);
|
||||
if (secret != NO_SECRET_CACHE_ENTRY)
|
||||
return secret;
|
||||
|
||||
List<byte[]> rawTransactions = bitcoiny.getAddressTransactions(p2shAddress);
|
||||
|
||||
for (byte[] rawTransaction : rawTransactions) {
|
||||
Transaction transaction = new Transaction(params, rawTransaction);
|
||||
|
||||
// Cycle through inputs, looking for one that spends our HTLC
|
||||
for (TransactionInput input : transaction.getInputs()) {
|
||||
Script scriptSig = input.getScriptSig();
|
||||
List<ScriptChunk> scriptChunks = scriptSig.getChunks();
|
||||
|
||||
// Expected number of script chunks for redeem. Refund might not have the same number.
|
||||
int expectedChunkCount = 1 /*secret*/ + 1 /*sig*/ + 1 /*pubkey*/ + 1 /*redeemScript*/;
|
||||
if (scriptChunks.size() != expectedChunkCount)
|
||||
continue;
|
||||
|
||||
// We're expecting last chunk to contain the actual redeemScript
|
||||
ScriptChunk lastChunk = scriptChunks.get(scriptChunks.size() - 1);
|
||||
byte[] redeemScriptBytes = lastChunk.data;
|
||||
|
||||
// If non-push scripts, redeemScript will be null
|
||||
if (redeemScriptBytes == null)
|
||||
continue;
|
||||
|
||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||
Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||
|
||||
if (!inputAddress.toString().equals(p2shAddress))
|
||||
// Input isn't spending our HTLC
|
||||
continue;
|
||||
|
||||
secret = scriptChunks.get(0).data;
|
||||
if (secret.length != BitcoinyHTLC.SECRET_LENGTH)
|
||||
continue;
|
||||
|
||||
// Cache secret for a while
|
||||
SECRET_CACHE.put(compoundKey, secret);
|
||||
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache negative result
|
||||
SECRET_CACHE.put(compoundKey, null);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTLC status, given P2SH address and expected redeem/refund amount
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
public static Status determineHtlcStatus(BitcoinyBlockchainProvider blockchain, String p2shAddress, long minimumAmount) throws ForeignBlockchainException {
|
||||
String compoundKey = String.format("%s-%s-%d", blockchain.getNetId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT);
|
||||
|
||||
Status cachedStatus = STATUS_CACHE.getOrDefault(compoundKey, null);
|
||||
if (cachedStatus != null)
|
||||
return cachedStatus;
|
||||
|
||||
byte[] ourScriptPubKey = addressToScriptPubKey(p2shAddress);
|
||||
List<TransactionHash> transactionHashes = blockchain.getAddressTransactions(ourScriptPubKey, BitcoinyBlockchainProvider.INCLUDE_UNCONFIRMED);
|
||||
|
||||
// Sort by confirmed first, followed by ascending height
|
||||
transactionHashes.sort(TransactionHash.CONFIRMED_FIRST.thenComparing(TransactionHash::getHeight));
|
||||
|
||||
// Transaction cache
|
||||
Map<String, BitcoinyTransaction> transactionsByHash = new HashMap<>();
|
||||
// HASH160(redeem script) for this p2shAddress
|
||||
byte[] ourRedeemScriptHash = addressToRedeemScriptHash(p2shAddress);
|
||||
|
||||
// Check for spends first, caching full transaction info as we progress just in case we don't return in this loop
|
||||
for (TransactionHash transactionInfo : transactionHashes) {
|
||||
BitcoinyTransaction bitcoinyTransaction = blockchain.getTransaction(transactionInfo.txHash);
|
||||
|
||||
// Cache for possible later reuse
|
||||
transactionsByHash.put(transactionInfo.txHash, bitcoinyTransaction);
|
||||
|
||||
// Acceptable funding is one transaction output, so we're expecting only one input
|
||||
if (bitcoinyTransaction.inputs.size() != 1)
|
||||
// Wrong number of inputs
|
||||
continue;
|
||||
|
||||
String scriptSig = bitcoinyTransaction.inputs.get(0).scriptSig;
|
||||
|
||||
List<byte[]> scriptSigChunks = extractScriptSigChunks(HashCode.fromString(scriptSig).asBytes());
|
||||
if (scriptSigChunks.size() < 3 || scriptSigChunks.size() > 4)
|
||||
// Not valid chunks for our form of HTLC
|
||||
continue;
|
||||
|
||||
// Last chunk is redeem script
|
||||
byte[] redeemScriptBytes = scriptSigChunks.get(scriptSigChunks.size() - 1);
|
||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||
if (!Arrays.equals(redeemScriptHash, ourRedeemScriptHash))
|
||||
// Not spending our specific HTLC redeem script
|
||||
continue;
|
||||
|
||||
if (scriptSigChunks.size() == 4)
|
||||
// If we have 4 chunks, then secret is present, hence redeem
|
||||
cachedStatus = transactionInfo.height == 0 ? Status.REDEEM_IN_PROGRESS : Status.REDEEMED;
|
||||
else
|
||||
cachedStatus = transactionInfo.height == 0 ? Status.REFUND_IN_PROGRESS : Status.REFUNDED;
|
||||
|
||||
STATUS_CACHE.put(compoundKey, cachedStatus);
|
||||
return cachedStatus;
|
||||
}
|
||||
|
||||
String ourScriptPubKeyHex = HashCode.fromBytes(ourScriptPubKey).toString();
|
||||
|
||||
// Check for funding
|
||||
for (TransactionHash transactionInfo : transactionHashes) {
|
||||
BitcoinyTransaction bitcoinyTransaction = transactionsByHash.get(transactionInfo.txHash);
|
||||
if (bitcoinyTransaction == null)
|
||||
// Should be present in map!
|
||||
throw new ForeignBlockchainException("Cached Bitcoin transaction now missing?");
|
||||
|
||||
// Check outputs for our specific P2SH
|
||||
for (BitcoinyTransaction.Output output : bitcoinyTransaction.outputs) {
|
||||
// Check amount
|
||||
if (output.value < minimumAmount)
|
||||
// Output amount too small (not taking fees into account)
|
||||
continue;
|
||||
|
||||
String scriptPubKeyHex = output.scriptPubKey;
|
||||
if (!scriptPubKeyHex.equals(ourScriptPubKeyHex))
|
||||
// Not funding our specific P2SH
|
||||
continue;
|
||||
|
||||
cachedStatus = transactionInfo.height == 0 ? Status.FUNDING_IN_PROGRESS : Status.FUNDED;
|
||||
STATUS_CACHE.put(compoundKey, cachedStatus);
|
||||
return cachedStatus;
|
||||
}
|
||||
}
|
||||
|
||||
cachedStatus = Status.UNFUNDED;
|
||||
STATUS_CACHE.put(compoundKey, cachedStatus);
|
||||
return cachedStatus;
|
||||
}
|
||||
|
||||
private static List<byte[]> extractScriptSigChunks(byte[] scriptSigBytes) {
|
||||
List<byte[]> chunks = new ArrayList<>();
|
||||
|
||||
int offset = 0;
|
||||
int previousOffset = 0;
|
||||
while (offset < scriptSigBytes.length) {
|
||||
byte pushOp = scriptSigBytes[offset++];
|
||||
|
||||
if (pushOp < 0 || pushOp > 0x4c)
|
||||
// Unacceptable OP
|
||||
return Collections.emptyList();
|
||||
|
||||
// Special treatment for OP_PUSHDATA1
|
||||
if (pushOp == 0x4c) {
|
||||
if (offset >= scriptSigBytes.length)
|
||||
// Run out of scriptSig bytes?
|
||||
return Collections.emptyList();
|
||||
|
||||
pushOp = scriptSigBytes[offset++];
|
||||
}
|
||||
|
||||
previousOffset = offset;
|
||||
offset += Byte.toUnsignedInt(pushOp);
|
||||
|
||||
byte[] chunk = Arrays.copyOfRange(scriptSigBytes, previousOffset, offset);
|
||||
chunks.add(chunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static byte[] addressToScriptPubKey(String p2shAddress) {
|
||||
// We want the HASH160 part of the P2SH address
|
||||
byte[] p2shAddressBytes = Base58.decode(p2shAddress);
|
||||
|
||||
byte[] scriptPubKey = new byte[1 + 1 + 20 + 1];
|
||||
scriptPubKey[0x00] = (byte) 0xa9; /* OP_HASH160 */
|
||||
scriptPubKey[0x01] = (byte) 0x14; /* PUSH 0x14 bytes */
|
||||
System.arraycopy(p2shAddressBytes, 1, scriptPubKey, 2, 0x14);
|
||||
scriptPubKey[0x16] = (byte) 0x87; /* OP_EQUAL */
|
||||
|
||||
return scriptPubKey;
|
||||
}
|
||||
|
||||
private static byte[] addressToRedeemScriptHash(String p2shAddress) {
|
||||
// We want the HASH160 part of the P2SH address
|
||||
byte[] p2shAddressBytes = Base58.decode(p2shAddress);
|
||||
|
||||
return Arrays.copyOfRange(p2shAddressBytes, 1, 1 + 20);
|
||||
}
|
||||
|
||||
}
|
||||
146
src/main/java/org/qortal/crosschain/BitcoinyTransaction.java
Normal file
146
src/main/java/org/qortal/crosschain/BitcoinyTransaction.java
Normal file
@@ -0,0 +1,146 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlTransient;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class BitcoinyTransaction {
|
||||
|
||||
public final String txHash;
|
||||
|
||||
@XmlTransient
|
||||
public final int size;
|
||||
|
||||
@XmlTransient
|
||||
public final int locktime;
|
||||
|
||||
// Not present if transaction is unconfirmed
|
||||
public final Integer timestamp;
|
||||
|
||||
public static class Input {
|
||||
@XmlTransient
|
||||
public final String scriptSig;
|
||||
|
||||
@XmlTransient
|
||||
public final int sequence;
|
||||
|
||||
public final String outputTxHash;
|
||||
|
||||
public final int outputVout;
|
||||
|
||||
// For JAXB
|
||||
protected Input() {
|
||||
this.scriptSig = null;
|
||||
this.sequence = 0;
|
||||
this.outputTxHash = null;
|
||||
this.outputVout = 0;
|
||||
}
|
||||
|
||||
public Input(String scriptSig, int sequence, String outputTxHash, int outputVout) {
|
||||
this.scriptSig = scriptSig;
|
||||
this.sequence = sequence;
|
||||
this.outputTxHash = outputTxHash;
|
||||
this.outputVout = outputVout;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return String.format("{output %s:%d, sequence %d, scriptSig %s}",
|
||||
this.outputTxHash, this.outputVout, this.sequence, this.scriptSig);
|
||||
}
|
||||
}
|
||||
@XmlTransient
|
||||
public final List<Input> inputs;
|
||||
|
||||
public static class Output {
|
||||
@XmlTransient
|
||||
public final String scriptPubKey;
|
||||
|
||||
public final long value;
|
||||
|
||||
public final List<String> addresses;
|
||||
|
||||
// For JAXB
|
||||
protected Output() {
|
||||
this.scriptPubKey = null;
|
||||
this.value = 0;
|
||||
this.addresses = null;
|
||||
}
|
||||
|
||||
public Output(String scriptPubKey, long value) {
|
||||
this.scriptPubKey = scriptPubKey;
|
||||
this.value = value;
|
||||
this.addresses = null;
|
||||
}
|
||||
|
||||
public Output(String scriptPubKey, long value, List<String> addresses) {
|
||||
this.scriptPubKey = scriptPubKey;
|
||||
this.value = value;
|
||||
this.addresses = addresses;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return String.format("{value %d, scriptPubKey %s}", this.value, this.scriptPubKey);
|
||||
}
|
||||
}
|
||||
public final List<Output> outputs;
|
||||
|
||||
public final long totalAmount;
|
||||
|
||||
// For JAXB
|
||||
protected BitcoinyTransaction() {
|
||||
this.txHash = null;
|
||||
this.size = 0;
|
||||
this.locktime = 0;
|
||||
this.timestamp = 0;
|
||||
this.inputs = null;
|
||||
this.outputs = null;
|
||||
this.totalAmount = 0;
|
||||
}
|
||||
|
||||
public BitcoinyTransaction(String txHash, int size, int locktime, Integer timestamp,
|
||||
List<Input> inputs, List<Output> outputs) {
|
||||
this.txHash = txHash;
|
||||
this.size = size;
|
||||
this.locktime = locktime;
|
||||
this.timestamp = timestamp;
|
||||
this.inputs = inputs;
|
||||
this.outputs = outputs;
|
||||
|
||||
this.totalAmount = outputs.stream().map(output -> output.value).reduce(0L, Long::sum);
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return String.format("txHash %s, size %d, locktime %d, timestamp %d\n"
|
||||
+ "\tinputs: [%s]\n"
|
||||
+ "\toutputs: [%s]\n",
|
||||
this.txHash,
|
||||
this.size,
|
||||
this.locktime,
|
||||
this.timestamp,
|
||||
this.inputs.stream().map(Input::toString).collect(Collectors.joining(",\n\t\t")),
|
||||
this.outputs.stream().map(Output::toString).collect(Collectors.joining(",\n\t\t")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other == this)
|
||||
return true;
|
||||
|
||||
if (!(other instanceof BitcoinyTransaction))
|
||||
return false;
|
||||
|
||||
BitcoinyTransaction otherTransaction = (BitcoinyTransaction) other;
|
||||
|
||||
return this.txHash.equals(otherTransaction.txHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.txHash.hashCode();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +1,26 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketAddress;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Random;
|
||||
import java.util.Scanner;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
@@ -29,23 +34,25 @@ import org.qortal.crypto.TrustlessSSLSocketFactory;
|
||||
import com.google.common.hash.HashCode;
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
/** ElectrumX network support for querying Bitcoin-related info like block headers, transaction outputs, etc. */
|
||||
public class ElectrumX {
|
||||
/** ElectrumX network support for querying Bitcoiny-related info like block headers, transaction outputs, etc. */
|
||||
public class ElectrumX extends BitcoinyBlockchainProvider {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class);
|
||||
private static final Random RANDOM = new Random();
|
||||
|
||||
private static final int DEFAULT_TCP_PORT = 50001;
|
||||
private static final int DEFAULT_SSL_PORT = 50002;
|
||||
|
||||
private static final double MIN_PROTOCOL_VERSION = 1.2;
|
||||
private static final int BLOCK_HEADER_LENGTH = 80;
|
||||
|
||||
private static final Map<String, ElectrumX> instances = new HashMap<>();
|
||||
// "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})"
|
||||
private static final Pattern DAEMON_ERROR_REGEX = Pattern.compile("DaemonError\\(\\{.*'code': ?(-?[0-9]+).*\\}\\)\\z"); // Capture 'code' inside curly-brace content
|
||||
|
||||
static class Server {
|
||||
/** Error message sent by some ElectrumX servers when they don't support returning verbose transactions. */
|
||||
private static final String VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE = "verbose transactions are currently unsupported";
|
||||
|
||||
public static class Server {
|
||||
String hostname;
|
||||
|
||||
enum ConnectionType { TCP, SSL };
|
||||
public enum ConnectionType { TCP, SSL }
|
||||
ConnectionType connectionType;
|
||||
|
||||
int port;
|
||||
@@ -82,96 +89,91 @@ public class ElectrumX {
|
||||
}
|
||||
}
|
||||
private Set<Server> servers = new HashSet<>();
|
||||
private List<Server> remainingServers = new ArrayList<>();
|
||||
private Set<Server> uselessServers = Collections.synchronizedSet(new HashSet<>());
|
||||
|
||||
private final String netId;
|
||||
private final String expectedGenesisHash;
|
||||
private final Map<Server.ConnectionType, Integer> defaultPorts = new EnumMap<>(Server.ConnectionType.class);
|
||||
|
||||
private final Object serverLock = new Object();
|
||||
private Server currentServer;
|
||||
private Socket socket;
|
||||
private Scanner scanner;
|
||||
private int nextId = 1;
|
||||
|
||||
private static final int TX_CACHE_SIZE = 200;
|
||||
@SuppressWarnings("serial")
|
||||
private final Map<String, BitcoinyTransaction> transactionCache = Collections.synchronizedMap(new LinkedHashMap<>(TX_CACHE_SIZE + 1, 0.75F, true) {
|
||||
// This method is called just after a new entry has been added
|
||||
@Override
|
||||
public boolean removeEldestEntry(Map.Entry<String, BitcoinyTransaction> eldest) {
|
||||
return size() > TX_CACHE_SIZE;
|
||||
}
|
||||
});
|
||||
|
||||
// Constructors
|
||||
|
||||
private ElectrumX(String bitcoinNetwork) {
|
||||
switch (bitcoinNetwork) {
|
||||
case "MAIN":
|
||||
servers.addAll(Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
new Server("tardis.bauerj.eu", Server.ConnectionType.SSL, 50002),
|
||||
new Server("rbx.curalle.ovh", Server.ConnectionType.SSL, 50002),
|
||||
new Server("quick.electumx.live", Server.ConnectionType.SSL, 50002),
|
||||
new Server("enode.duckdns.org", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrumx.ddns.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrumx.ml", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.eff.ro", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.bitkoins.nl", Server.ConnectionType.SSL, 50512),
|
||||
new Server("E-X.not.fyi", Server.ConnectionType.SSL, 50002),
|
||||
new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002),
|
||||
new Server("electrum.blockstream.info", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 50002),
|
||||
new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001)));
|
||||
break;
|
||||
|
||||
case "TEST3":
|
||||
servers.addAll(Arrays.asList(
|
||||
new Server("tn.not.fyi", Server.ConnectionType.TCP, 55001),
|
||||
new Server("tn.not.fyi", Server.ConnectionType.SSL, 55002),
|
||||
new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001),
|
||||
new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002),
|
||||
new Server("testnet.hsmiths.com", Server.ConnectionType.SSL, 53012)));
|
||||
break;
|
||||
|
||||
case "REGTEST":
|
||||
servers.addAll(Arrays.asList(
|
||||
new Server("localhost", Server.ConnectionType.TCP, DEFAULT_TCP_PORT),
|
||||
new Server("localhost", Server.ConnectionType.SSL, DEFAULT_SSL_PORT)));
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new IllegalArgumentException(String.format("Bitcoin network '%s' unknown", bitcoinNetwork));
|
||||
}
|
||||
|
||||
LOGGER.debug(() -> String.format("Starting ElectrumX support for %s Bitcoin network", bitcoinNetwork));
|
||||
rpc("server.banner");
|
||||
}
|
||||
|
||||
/** Returns ElectrumX instance linked to passed Bitcoin network, one of "MAIN", "TEST3" or "REGTEST". */
|
||||
public static synchronized ElectrumX getInstance(String bitcoinNetwork) {
|
||||
if (!instances.containsKey(bitcoinNetwork))
|
||||
instances.put(bitcoinNetwork, new ElectrumX(bitcoinNetwork));
|
||||
|
||||
return instances.get(bitcoinNetwork);
|
||||
public ElectrumX(String netId, String genesisHash, Collection<Server> initialServerList, Map<Server.ConnectionType, Integer> defaultPorts) {
|
||||
this.netId = netId;
|
||||
this.expectedGenesisHash = genesisHash;
|
||||
this.servers.addAll(initialServerList);
|
||||
this.defaultPorts.putAll(defaultPorts);
|
||||
}
|
||||
|
||||
// Methods for use by other classes
|
||||
|
||||
public Integer getCurrentHeight() {
|
||||
Object blockObj = this.rpc("blockchain.headers.subscribe");
|
||||
if (!(blockObj instanceof JSONObject))
|
||||
return null;
|
||||
|
||||
JSONObject blockJson = (JSONObject) blockObj;
|
||||
|
||||
if (!blockJson.containsKey("height"))
|
||||
return null;
|
||||
|
||||
return ((Long) blockJson.get("height")).intValue();
|
||||
@Override
|
||||
public String getNetId() {
|
||||
return this.netId;
|
||||
}
|
||||
|
||||
public List<byte[]> getBlockHeaders(int startHeight, long count) {
|
||||
Object blockObj = this.rpc("blockchain.block.headers", startHeight, count);
|
||||
/**
|
||||
* Returns current blockchain height.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
@Override
|
||||
public int getCurrentHeight() throws ForeignBlockchainException {
|
||||
Object blockObj = this.rpc("blockchain.headers.subscribe");
|
||||
if (!(blockObj instanceof JSONObject))
|
||||
return null;
|
||||
throw new ForeignBlockchainException.NetworkException("Unexpected output from ElectrumX blockchain.headers.subscribe RPC");
|
||||
|
||||
JSONObject blockJson = (JSONObject) blockObj;
|
||||
|
||||
if (!blockJson.containsKey("count") || !blockJson.containsKey("hex"))
|
||||
return null;
|
||||
Object heightObj = blockJson.get("height");
|
||||
|
||||
Long returnedCount = (Long) blockJson.get("count");
|
||||
String hex = (String) blockJson.get("hex");
|
||||
if (!(heightObj instanceof Long))
|
||||
throw new ForeignBlockchainException.NetworkException("Missing/invalid 'height' in JSON from ElectrumX blockchain.headers.subscribe RPC");
|
||||
|
||||
return ((Long) heightObj).intValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of raw block headers, starting from <tt>startHeight</tt> inclusive.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
@Override
|
||||
public List<byte[]> getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException {
|
||||
Object blockObj = this.rpc("blockchain.block.headers", startHeight, count);
|
||||
if (!(blockObj instanceof JSONObject))
|
||||
throw new ForeignBlockchainException.NetworkException("Unexpected output from ElectrumX blockchain.block.headers RPC");
|
||||
|
||||
JSONObject blockJson = (JSONObject) blockObj;
|
||||
|
||||
Object countObj = blockJson.get("count");
|
||||
Object hexObj = blockJson.get("hex");
|
||||
|
||||
if (!(countObj instanceof Long) || !(hexObj instanceof String))
|
||||
throw new ForeignBlockchainException.NetworkException("Missing/invalid 'count' or 'hex' entries in JSON from ElectrumX blockchain.block.headers RPC");
|
||||
|
||||
Long returnedCount = (Long) countObj;
|
||||
String hex = (String) hexObj;
|
||||
|
||||
byte[] raw = HashCode.fromString(hex).asBytes();
|
||||
if (raw.length != returnedCount * BLOCK_HEADER_LENGTH)
|
||||
return null;
|
||||
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)
|
||||
@@ -180,46 +182,45 @@ public class ElectrumX {
|
||||
return rawBlockHeaders;
|
||||
}
|
||||
|
||||
/** Returns confirmed balance, based on passed payment script, or null if there was an error or no known balance. */
|
||||
public Long getBalance(byte[] script) {
|
||||
/**
|
||||
* Returns confirmed balance, based on passed payment script.
|
||||
* <p>
|
||||
* @return confirmed balance, or zero if script unknown
|
||||
* @throws ForeignBlockchainException if there was an error
|
||||
*/
|
||||
@Override
|
||||
public long getConfirmedBalance(byte[] script) throws ForeignBlockchainException {
|
||||
byte[] scriptHash = Crypto.digest(script);
|
||||
Bytes.reverse(scriptHash);
|
||||
|
||||
Object balanceObj = this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString());
|
||||
if (!(balanceObj instanceof JSONObject))
|
||||
return null;
|
||||
throw new ForeignBlockchainException.NetworkException("Unexpected output from ElectrumX blockchain.scripthash.get_balance RPC");
|
||||
|
||||
JSONObject balanceJson = (JSONObject) balanceObj;
|
||||
|
||||
if (!balanceJson.containsKey("confirmed"))
|
||||
return null;
|
||||
Object confirmedBalanceObj = balanceJson.get("confirmed");
|
||||
|
||||
if (!(confirmedBalanceObj instanceof Long))
|
||||
throw new ForeignBlockchainException.NetworkException("Missing confirmed balance from ElectrumX blockchain.scripthash.get_balance RPC");
|
||||
|
||||
return (Long) balanceJson.get("confirmed");
|
||||
}
|
||||
|
||||
/** Unspent output info as returned by ElectrumX network. */
|
||||
public static class UnspentOutput {
|
||||
public final byte[] hash;
|
||||
public final int index;
|
||||
public final int height;
|
||||
public final long value;
|
||||
|
||||
public UnspentOutput(byte[] hash, int index, int height, long value) {
|
||||
this.hash = hash;
|
||||
this.index = index;
|
||||
this.height = height;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns list of unspent outputs pertaining to passed payment script, or null if there was an error. */
|
||||
public List<UnspentOutput> getUnspentOutputs(byte[] script) {
|
||||
/**
|
||||
* Returns list of unspent outputs pertaining to passed payment script.
|
||||
* <p>
|
||||
* @return list of unspent outputs, or empty list if script unknown
|
||||
* @throws ForeignBlockchainException if there was an error.
|
||||
*/
|
||||
@Override
|
||||
public List<UnspentOutput> getUnspentOutputs(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException {
|
||||
byte[] scriptHash = Crypto.digest(script);
|
||||
Bytes.reverse(scriptHash);
|
||||
|
||||
Object unspentJson = this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString());
|
||||
if (!(unspentJson instanceof JSONArray))
|
||||
return null;
|
||||
throw new ForeignBlockchainException("Expected array output from ElectrumX blockchain.scripthash.listunspent RPC");
|
||||
|
||||
List<UnspentOutput> unspentOutputs = new ArrayList<>();
|
||||
for (Object rawUnspent : (JSONArray) unspentJson) {
|
||||
@@ -227,7 +228,7 @@ public class ElectrumX {
|
||||
|
||||
int height = ((Long) unspent.get("height")).intValue();
|
||||
// We only want unspent outputs from confirmed transactions (and definitely not mempool duplicates with height 0)
|
||||
if (height <= 0)
|
||||
if (!includeUnconfirmed && height <= 0)
|
||||
continue;
|
||||
|
||||
byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes();
|
||||
@@ -240,68 +241,215 @@ public class ElectrumX {
|
||||
return unspentOutputs;
|
||||
}
|
||||
|
||||
/** Returns raw transaction for passed transaction hash, or null if not found. */
|
||||
public byte[] getRawTransaction(byte[] txHash) {
|
||||
Object rawTransactionHex = this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString());
|
||||
/**
|
||||
* Returns raw transaction for passed transaction hash.
|
||||
* <p>
|
||||
* NOTE: Do not mutate returned byte[]!
|
||||
*
|
||||
* @throws ForeignBlockchainException.NotFoundException if transaction not found
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
@Override
|
||||
public byte[] getRawTransaction(String txHash) throws ForeignBlockchainException {
|
||||
Object rawTransactionHex;
|
||||
try {
|
||||
rawTransactionHex = this.rpc("blockchain.transaction.get", txHash, false);
|
||||
} catch (ForeignBlockchainException.NetworkException e) {
|
||||
// DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})
|
||||
if (Integer.valueOf(-5).equals(e.getDaemonErrorCode()))
|
||||
throw new ForeignBlockchainException.NotFoundException(e.getMessage());
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!(rawTransactionHex instanceof String))
|
||||
return null;
|
||||
throw new ForeignBlockchainException.NetworkException("Expected hex string as raw transaction from ElectrumX blockchain.transaction.get RPC");
|
||||
|
||||
return HashCode.fromString((String) rawTransactionHex).asBytes();
|
||||
}
|
||||
|
||||
/** Returns list of raw transactions, relating to passed payment script, if null if there's an error. */
|
||||
public List<byte[]> getAddressTransactions(byte[] script) {
|
||||
/**
|
||||
* Returns raw transaction for passed transaction hash.
|
||||
* <p>
|
||||
* NOTE: Do not mutate returned byte[]!
|
||||
*
|
||||
* @throws ForeignBlockchainException.NotFoundException if transaction not found
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
@Override
|
||||
public byte[] getRawTransaction(byte[] txHash) throws ForeignBlockchainException {
|
||||
return getRawTransaction(HashCode.fromBytes(txHash).toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns transaction info for passed transaction hash.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException.NotFoundException if transaction not found
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
@Override
|
||||
public BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException {
|
||||
// Check cache first
|
||||
BitcoinyTransaction transaction = transactionCache.get(txHash);
|
||||
if (transaction != null)
|
||||
return transaction;
|
||||
|
||||
Object transactionObj = null;
|
||||
|
||||
do {
|
||||
try {
|
||||
transactionObj = this.rpc("blockchain.transaction.get", txHash, true);
|
||||
} catch (ForeignBlockchainException.NetworkException e) {
|
||||
// DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})
|
||||
if (Integer.valueOf(-5).equals(e.getDaemonErrorCode()))
|
||||
throw new ForeignBlockchainException.NotFoundException(e.getMessage());
|
||||
|
||||
// Some servers also return non-standard responses like this:
|
||||
// {"error":"verbose transactions are currently unsupported","id":3,"jsonrpc":"2.0"}
|
||||
// We should probably not use this server any more
|
||||
if (e.getServer() != null && e.getMessage() != null && e.getMessage().contains(VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE)) {
|
||||
Server uselessServer = (Server) e.getServer();
|
||||
LOGGER.trace(() -> String.format("Server %s doesn't support verbose transactions - barring use of that server", uselessServer));
|
||||
this.uselessServers.add(uselessServer);
|
||||
this.closeServer(uselessServer);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
} while (transactionObj == null);
|
||||
|
||||
if (!(transactionObj instanceof JSONObject))
|
||||
throw new ForeignBlockchainException.NetworkException("Expected JSONObject as response from ElectrumX blockchain.transaction.get RPC");
|
||||
|
||||
JSONObject transactionJson = (JSONObject) transactionObj;
|
||||
|
||||
Object inputsObj = transactionJson.get("vin");
|
||||
if (!(inputsObj instanceof JSONArray))
|
||||
throw new ForeignBlockchainException.NetworkException("Expected JSONArray for 'vin' from ElectrumX blockchain.transaction.get RPC");
|
||||
|
||||
Object outputsObj = transactionJson.get("vout");
|
||||
if (!(outputsObj instanceof JSONArray))
|
||||
throw new ForeignBlockchainException.NetworkException("Expected JSONArray for 'vout' from ElectrumX blockchain.transaction.get RPC");
|
||||
|
||||
try {
|
||||
int size = ((Long) transactionJson.get("size")).intValue();
|
||||
int locktime = ((Long) transactionJson.get("locktime")).intValue();
|
||||
|
||||
// Timestamp might not be present, e.g. for unconfirmed transaction
|
||||
Object timeObj = transactionJson.get("time");
|
||||
Integer timestamp = timeObj != null
|
||||
? ((Long) timeObj).intValue()
|
||||
: null;
|
||||
|
||||
List<BitcoinyTransaction.Input> inputs = new ArrayList<>();
|
||||
for (Object inputObj : (JSONArray) inputsObj) {
|
||||
JSONObject inputJson = (JSONObject) inputObj;
|
||||
|
||||
String scriptSig = (String) ((JSONObject) inputJson.get("scriptSig")).get("hex");
|
||||
int sequence = ((Long) inputJson.get("sequence")).intValue();
|
||||
String outputTxHash = (String) inputJson.get("txid");
|
||||
int outputVout = ((Long) inputJson.get("vout")).intValue();
|
||||
|
||||
inputs.add(new BitcoinyTransaction.Input(scriptSig, sequence, outputTxHash, outputVout));
|
||||
}
|
||||
|
||||
List<BitcoinyTransaction.Output> outputs = new ArrayList<>();
|
||||
for (Object outputObj : (JSONArray) outputsObj) {
|
||||
JSONObject outputJson = (JSONObject) outputObj;
|
||||
|
||||
String scriptPubKey = (String) ((JSONObject) outputJson.get("scriptPubKey")).get("hex");
|
||||
long value = BigDecimal.valueOf((Double) outputJson.get("value")).setScale(8).unscaledValue().longValue();
|
||||
|
||||
// address too, if present
|
||||
List<String> addresses = null;
|
||||
Object addressesObj = ((JSONObject) outputJson.get("scriptPubKey")).get("addresses");
|
||||
if (addressesObj instanceof JSONArray) {
|
||||
addresses = new ArrayList<>();
|
||||
for (Object addressObj : (JSONArray) addressesObj)
|
||||
addresses.add((String) addressObj);
|
||||
}
|
||||
|
||||
outputs.add(new BitcoinyTransaction.Output(scriptPubKey, value, addresses));
|
||||
}
|
||||
|
||||
transaction = new BitcoinyTransaction(txHash, size, locktime, timestamp, inputs, outputs);
|
||||
|
||||
// Save into cache
|
||||
transactionCache.put(txHash, transaction);
|
||||
|
||||
return transaction;
|
||||
} catch (NullPointerException | ClassCastException e) {
|
||||
// Unexpected / invalid response from ElectrumX server
|
||||
}
|
||||
|
||||
throw new ForeignBlockchainException.NetworkException("Unexpected JSON format from ElectrumX blockchain.transaction.get RPC");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of transactions, relating to passed payment script.
|
||||
* <p>
|
||||
* @return list of related transactions, or empty list if script unknown
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
@Override
|
||||
public List<TransactionHash> getAddressTransactions(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException {
|
||||
byte[] scriptHash = Crypto.digest(script);
|
||||
Bytes.reverse(scriptHash);
|
||||
|
||||
Object transactionsJson = this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString());
|
||||
if (!(transactionsJson instanceof JSONArray))
|
||||
return null;
|
||||
throw new ForeignBlockchainException.NetworkException("Expected array output from ElectrumX blockchain.scripthash.get_history RPC");
|
||||
|
||||
List<byte[]> rawTransactions = new ArrayList<>();
|
||||
List<TransactionHash> transactionHashes = new ArrayList<>();
|
||||
|
||||
for (Object rawTransactionInfo : (JSONArray) transactionsJson) {
|
||||
JSONObject transactionInfo = (JSONObject) rawTransactionInfo;
|
||||
|
||||
// We only want confirmed transactions
|
||||
if (!transactionInfo.containsKey("height"))
|
||||
Long height = (Long) transactionInfo.get("height");
|
||||
if (!includeUnconfirmed && (height == null || height == 0))
|
||||
// We only want confirmed transactions
|
||||
continue;
|
||||
|
||||
String txHash = (String) transactionInfo.get("tx_hash");
|
||||
String rawTransactionHex = (String) this.rpc("blockchain.transaction.get", txHash);
|
||||
if (rawTransactionHex == null)
|
||||
return null;
|
||||
|
||||
rawTransactions.add(HashCode.fromString(rawTransactionHex).asBytes());
|
||||
transactionHashes.add(new TransactionHash(height.intValue(), txHash));
|
||||
}
|
||||
|
||||
return rawTransactions;
|
||||
return transactionHashes;
|
||||
}
|
||||
|
||||
/** Returns true if raw transaction successfully broadcast. */
|
||||
public boolean broadcastTransaction(byte[] transactionBytes) {
|
||||
/**
|
||||
* Broadcasts raw transaction to network.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
@Override
|
||||
public void broadcastTransaction(byte[] transactionBytes) throws ForeignBlockchainException {
|
||||
Object rawBroadcastResult = this.rpc("blockchain.transaction.broadcast", HashCode.fromBytes(transactionBytes).toString());
|
||||
if (rawBroadcastResult == null)
|
||||
return false;
|
||||
|
||||
// If result is a String, then it is simply transaction hash.
|
||||
// Otherwise result is JSON and probably contains error info instead.
|
||||
return rawBroadcastResult instanceof String;
|
||||
// We're expecting a simple string that is the transaction hash
|
||||
if (!(rawBroadcastResult instanceof String))
|
||||
throw new ForeignBlockchainException.NetworkException("Unexpected response from ElectrumX blockchain.transaction.broadcast RPC");
|
||||
}
|
||||
|
||||
// Class-private utility methods
|
||||
|
||||
/** Query current server for its list of peer servers, and return those we can parse. */
|
||||
private Set<Server> serverPeersSubscribe() {
|
||||
/**
|
||||
* Query current server for its list of peer servers, and return those we can parse.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException
|
||||
* @throws ClassCastException to be handled by caller
|
||||
*/
|
||||
private Set<Server> serverPeersSubscribe() throws ForeignBlockchainException {
|
||||
Set<Server> newServers = new HashSet<>();
|
||||
|
||||
Object peers = this.connectedRpc("server.peers.subscribe");
|
||||
if (!(peers instanceof JSONArray))
|
||||
return newServers;
|
||||
|
||||
for (Object rawPeer : (JSONArray) peers) {
|
||||
JSONArray peer = (JSONArray) rawPeer;
|
||||
if (peer.size() < 3)
|
||||
// We're expecting at least 3 fields for each peer entry: IP, hostname, features
|
||||
continue;
|
||||
|
||||
String hostname = (String) peer.get(1);
|
||||
@@ -310,21 +458,26 @@ public class ElectrumX {
|
||||
for (Object rawFeature : features) {
|
||||
String feature = (String) rawFeature;
|
||||
Server.ConnectionType connectionType = null;
|
||||
int port = -1;
|
||||
Integer port = null;
|
||||
|
||||
switch (feature.charAt(0)) {
|
||||
case 's':
|
||||
connectionType = Server.ConnectionType.SSL;
|
||||
port = DEFAULT_SSL_PORT;
|
||||
port = this.defaultPorts.get(connectionType);
|
||||
break;
|
||||
|
||||
case 't':
|
||||
connectionType = Server.ConnectionType.TCP;
|
||||
port = DEFAULT_TCP_PORT;
|
||||
port = this.defaultPorts.get(connectionType);
|
||||
break;
|
||||
|
||||
default:
|
||||
// e.g. could be 'v' for protocol version, or 'p' for pruning limit
|
||||
break;
|
||||
}
|
||||
|
||||
if (connectionType == null)
|
||||
if (connectionType == null || port == null)
|
||||
// We couldn't extract any peer connection info?
|
||||
continue;
|
||||
|
||||
// Possible non-default port?
|
||||
@@ -344,34 +497,38 @@ public class ElectrumX {
|
||||
return newServers;
|
||||
}
|
||||
|
||||
/** Return output from RPC call, with automatic reconnection to different server if needed. */
|
||||
private synchronized Object rpc(String method, Object...params) {
|
||||
while (haveConnection()) {
|
||||
Object response = connectedRpc(method, params);
|
||||
if (response != null)
|
||||
return response;
|
||||
/**
|
||||
* Performs RPC call, with automatic reconnection to different server if needed.
|
||||
* <p>
|
||||
* @return "result" object from within JSON output
|
||||
* @throws ForeignBlockchainException if server returns error or something goes wrong
|
||||
*/
|
||||
private Object rpc(String method, Object...params) throws ForeignBlockchainException {
|
||||
synchronized (this.serverLock) {
|
||||
if (this.remainingServers.isEmpty())
|
||||
this.remainingServers.addAll(this.servers);
|
||||
|
||||
this.currentServer = null;
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch (IOException e) {
|
||||
/* ignore */
|
||||
while (haveConnection()) {
|
||||
Object response = connectedRpc(method, params);
|
||||
if (response != null)
|
||||
return response;
|
||||
|
||||
// Didn't work, try another server...
|
||||
this.closeServer();
|
||||
}
|
||||
this.scanner = null;
|
||||
}
|
||||
|
||||
return null;
|
||||
// Failed to perform RPC - maybe lack of servers?
|
||||
throw new ForeignBlockchainException.NetworkException(String.format("Failed to perform ElectrumX RPC %s", method));
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if we have, or create, a connection to an ElectrumX server. */
|
||||
private boolean haveConnection() {
|
||||
private boolean haveConnection() throws ForeignBlockchainException {
|
||||
if (this.currentServer != null)
|
||||
return true;
|
||||
|
||||
List<Server> remainingServers = new ArrayList<>(this.servers);
|
||||
|
||||
while (!remainingServers.isEmpty()) {
|
||||
Server server = remainingServers.remove(RANDOM.nextInt(remainingServers.size()));
|
||||
while (!this.remainingServers.isEmpty()) {
|
||||
Server server = this.remainingServers.remove(RANDOM.nextInt(this.remainingServers.size()));
|
||||
LOGGER.trace(() -> String.format("Connecting to %s", server));
|
||||
|
||||
try {
|
||||
@@ -384,36 +541,55 @@ public class ElectrumX {
|
||||
|
||||
if (server.connectionType == Server.ConnectionType.SSL) {
|
||||
SSLSocketFactory factory = TrustlessSSLSocketFactory.getSocketFactory();
|
||||
this.socket = (SSLSocket) factory.createSocket(this.socket, server.hostname, server.port, true);
|
||||
this.socket = factory.createSocket(this.socket, server.hostname, server.port, true);
|
||||
}
|
||||
|
||||
this.scanner = new Scanner(this.socket.getInputStream());
|
||||
this.scanner.useDelimiter("\n");
|
||||
|
||||
// Check connection works by asking for more servers
|
||||
// Check connection is suitable by asking for server features, including genesis block hash
|
||||
JSONObject featuresJson = (JSONObject) this.connectedRpc("server.features");
|
||||
|
||||
if (featuresJson == null || Double.valueOf((String) featuresJson.get("protocol_min")) < MIN_PROTOCOL_VERSION)
|
||||
continue;
|
||||
|
||||
if (this.expectedGenesisHash != null && !((String) featuresJson.get("genesis_hash")).equals(this.expectedGenesisHash))
|
||||
continue;
|
||||
|
||||
// Ask for more servers
|
||||
Set<Server> moreServers = serverPeersSubscribe();
|
||||
// Discard duplicate servers we already know
|
||||
moreServers.removeAll(this.servers);
|
||||
remainingServers.addAll(moreServers);
|
||||
// Add to both lists
|
||||
this.remainingServers.addAll(moreServers);
|
||||
this.servers.addAll(moreServers);
|
||||
|
||||
LOGGER.debug(() -> String.format("Connected to %s", server));
|
||||
this.currentServer = server;
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
// Try another server...
|
||||
this.socket = null;
|
||||
this.scanner = null;
|
||||
} catch (IOException | ForeignBlockchainException | ClassCastException | NullPointerException e) {
|
||||
// Didn't work, try another server...
|
||||
closeServer();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform RPC using currently connected server.
|
||||
* <p>
|
||||
* @param method
|
||||
* @param params
|
||||
* @return response Object, or null if server fails to respond
|
||||
* @throws ForeignBlockchainException if server returns error
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Object connectedRpc(String method, Object...params) {
|
||||
private Object connectedRpc(String method, Object...params) throws ForeignBlockchainException {
|
||||
JSONObject requestJson = new JSONObject();
|
||||
requestJson.put("id", this.nextId++);
|
||||
requestJson.put("method", method);
|
||||
requestJson.put("jsonrpc", "2.0");
|
||||
|
||||
JSONArray requestParams = new JSONArray();
|
||||
requestParams.addAll(Arrays.asList(params));
|
||||
@@ -428,21 +604,85 @@ public class ElectrumX {
|
||||
this.socket.getOutputStream().write(request.getBytes());
|
||||
response = scanner.next();
|
||||
} catch (IOException | NoSuchElementException e) {
|
||||
// Unable to send, or receive -- try another server?
|
||||
return null;
|
||||
}
|
||||
|
||||
LOGGER.trace(() -> String.format("Response: %s", response));
|
||||
|
||||
if (response.isEmpty())
|
||||
// Empty response - try another server?
|
||||
return null;
|
||||
|
||||
Object responseObj = JSONValue.parse(response);
|
||||
if (!(responseObj instanceof JSONObject))
|
||||
// Unexpected response - try another server?
|
||||
return null;
|
||||
|
||||
JSONObject responseJson = (JSONObject) responseObj;
|
||||
|
||||
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 JSONObject))
|
||||
throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error response from ElectrumX RPC %s", method), this.currentServer);
|
||||
|
||||
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);
|
||||
|
||||
String message = (String) messageObj;
|
||||
|
||||
// Some error 'messages' are actually wrapped upstream bitcoind errors:
|
||||
// "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})"
|
||||
// We want to detect these and extract the upstream error code for caller's use
|
||||
Matcher messageMatcher = DAEMON_ERROR_REGEX.matcher(message);
|
||||
if (messageMatcher.find())
|
||||
try {
|
||||
int daemonErrorCode = Integer.parseInt(messageMatcher.group(1));
|
||||
throw new ForeignBlockchainException.NetworkException(daemonErrorCode, message, this.currentServer);
|
||||
} catch (NumberFormatException e) {
|
||||
// We couldn't parse the error code integer? Fall-through to generic exception...
|
||||
}
|
||||
|
||||
throw new ForeignBlockchainException.NetworkException(message, this.currentServer);
|
||||
}
|
||||
|
||||
return responseJson.get("result");
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes connection to <tt>server</tt> if it is currently connected server.
|
||||
* @param server
|
||||
*/
|
||||
private void closeServer(Server server) {
|
||||
synchronized (this.serverLock) {
|
||||
if (this.currentServer == null || !this.currentServer.equals(server))
|
||||
return;
|
||||
|
||||
if (this.socket != null && !this.socket.isClosed())
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch (IOException e) {
|
||||
// We did try...
|
||||
}
|
||||
|
||||
this.socket = null;
|
||||
this.scanner = null;
|
||||
this.currentServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes connection to currently connected server (if any). */
|
||||
private void closeServer() {
|
||||
synchronized (this.serverLock) {
|
||||
this.closeServer(this.currentServer);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
public interface ForeignBlockchain {
|
||||
|
||||
public boolean isValidAddress(String address);
|
||||
|
||||
public boolean isValidWalletKey(String walletKey);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
public class ForeignBlockchainException extends Exception {
|
||||
|
||||
public ForeignBlockchainException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public ForeignBlockchainException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public static class NetworkException extends ForeignBlockchainException {
|
||||
private final Integer daemonErrorCode;
|
||||
private final transient Object server;
|
||||
|
||||
public NetworkException() {
|
||||
super();
|
||||
this.daemonErrorCode = null;
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
public NetworkException(String message) {
|
||||
super(message);
|
||||
this.daemonErrorCode = null;
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
public NetworkException(int errorCode, String message) {
|
||||
super(message);
|
||||
this.daemonErrorCode = errorCode;
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
public NetworkException(String message, Object server) {
|
||||
super(message);
|
||||
this.daemonErrorCode = null;
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public NetworkException(int errorCode, String message, Object server) {
|
||||
super(message);
|
||||
this.daemonErrorCode = errorCode;
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public Integer getDaemonErrorCode() {
|
||||
return this.daemonErrorCode;
|
||||
}
|
||||
|
||||
public Object getServer() {
|
||||
return this.server;
|
||||
}
|
||||
}
|
||||
|
||||
public static class NotFoundException extends ForeignBlockchainException {
|
||||
public NotFoundException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public NotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class InsufficientFundsException extends ForeignBlockchainException {
|
||||
public InsufficientFundsException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public InsufficientFundsException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
175
src/main/java/org/qortal/crosschain/Litecoin.java
Normal file
175
src/main/java/org/qortal/crosschain/Litecoin.java
Normal file
@@ -0,0 +1,175 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.bitcoinj.core.Coin;
|
||||
import org.bitcoinj.core.Context;
|
||||
import org.bitcoinj.core.NetworkParameters;
|
||||
import org.libdohj.params.LitecoinMainNetParams;
|
||||
import org.libdohj.params.LitecoinRegTestParams;
|
||||
import org.libdohj.params.LitecoinTestNet3Params;
|
||||
import org.qortal.crosschain.ElectrumX.Server;
|
||||
import org.qortal.crosschain.ElectrumX.Server.ConnectionType;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
public class Litecoin extends Bitcoiny {
|
||||
|
||||
public static final String CURRENCY_CODE = "LTC";
|
||||
|
||||
private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(10000); // 0.0001 LTC per 1000 bytes
|
||||
|
||||
// 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
|
||||
|
||||
private static final Map<ElectrumX.Server.ConnectionType, Integer> DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ElectrumX.Server.ConnectionType.class);
|
||||
static {
|
||||
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001);
|
||||
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002);
|
||||
}
|
||||
|
||||
public enum LitecoinNet {
|
||||
MAIN {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return LitecoinMainNetParams.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ElectrumX.Server> getServers() {
|
||||
return Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
new Server("electrum-ltc.someguy123.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("backup.electrum-ltc.org", Server.ConnectionType.TCP, 50001),
|
||||
new Server("backup.electrum-ltc.org", Server.ConnectionType.SSL, 443),
|
||||
new Server("electrum.ltc.xurious.com", Server.ConnectionType.TCP, 50001),
|
||||
new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 50002),
|
||||
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));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGenesisHash() {
|
||||
return "12a765e31ffd4059bada1e25190f6e98c99d9714d334efa41a195a7e7e04bfe2";
|
||||
}
|
||||
|
||||
@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 LitecoinTestNet3Params.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ElectrumX.Server> getServers() {
|
||||
return Arrays.asList(
|
||||
new Server("electrum-ltc.bysh.me", Server.ConnectionType.TCP, 51001),
|
||||
new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 51002),
|
||||
new Server("electrum.ltc.xurious.com", Server.ConnectionType.TCP, 51001),
|
||||
new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 51002));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGenesisHash() {
|
||||
return "4966625a4b2851d9fdee139e56211a0d88575f59ed816ff5e6a63deb4e3e29a0";
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getP2shFee(Long timestamp) {
|
||||
return NON_MAINNET_FEE;
|
||||
}
|
||||
},
|
||||
REGTEST {
|
||||
@Override
|
||||
public NetworkParameters getParams() {
|
||||
return LitecoinRegTestParams.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ElectrumX.Server> getServers() {
|
||||
return Arrays.asList(
|
||||
new Server("localhost", Server.ConnectionType.TCP, 50001),
|
||||
new Server("localhost", Server.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<ElectrumX.Server> getServers();
|
||||
public abstract String getGenesisHash();
|
||||
public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException;
|
||||
}
|
||||
|
||||
private static Litecoin instance;
|
||||
|
||||
private final LitecoinNet litecoinNet;
|
||||
|
||||
// Constructors and instance
|
||||
|
||||
private Litecoin(LitecoinNet litecoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) {
|
||||
super(blockchain, bitcoinjContext, currencyCode);
|
||||
this.litecoinNet = litecoinNet;
|
||||
|
||||
LOGGER.info(() -> String.format("Starting Litecoin support using %s", this.litecoinNet.name()));
|
||||
}
|
||||
|
||||
public static synchronized Litecoin getInstance() {
|
||||
if (instance == null) {
|
||||
LitecoinNet litecoinNet = Settings.getInstance().getLitecoinNet();
|
||||
|
||||
BitcoinyBlockchainProvider electrumX = new ElectrumX("Litecoin-" + litecoinNet.name(), litecoinNet.getGenesisHash(), litecoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS);
|
||||
Context bitcoinjContext = new Context(litecoinNet.getParams());
|
||||
|
||||
instance = new Litecoin(litecoinNet, electrumX, bitcoinjContext, CURRENCY_CODE);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Getters & setters
|
||||
|
||||
public static synchronized void resetForTesting() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
// Actual useful methods for use by other classes
|
||||
|
||||
/** Default Litecoin fee is lower than Bitcoin: only 10sats/byte. */
|
||||
@Override
|
||||
public Coin getFeePerKb() {
|
||||
return DEFAULT_FEE_PER_KB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns estimated LTC 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.litecoinNet.getP2shFee(timestamp);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user