mirror of
https://github.com/Qortal/qortal.git
synced 2025-11-15 03:07:04 +00:00
Compare commits
397 Commits
v1.2.0
...
ignore-old
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c1793d85 | ||
|
|
af9b536dd9 | ||
|
|
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 | ||
|
|
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 | ||
|
|
7bb060781e | ||
|
|
a1ab0b7c31 | ||
|
|
fae2afd010 | ||
|
|
76c0a5a4fa | ||
|
|
cdb65657b6 | ||
|
|
9007dfe779 | ||
|
|
99d09a9877 | ||
|
|
afcf51399e | ||
|
|
47679b7f6c | ||
|
|
8f2985862d | ||
|
|
23a524b464 | ||
|
|
ce8992867d | ||
|
|
c89de7adfb | ||
|
|
cac68ccc14 | ||
|
|
d507383487 | ||
|
|
ce5cf87094 | ||
|
|
ec2c9d2a44 | ||
|
|
36d0abe635 | ||
|
|
615381ca5a | ||
|
|
6b83499216 | ||
|
|
faa2e9502b | ||
|
|
cd07240ce7 | ||
|
|
91518464c2 | ||
|
|
25bf315e23 | ||
|
|
a8743b1bd3 | ||
|
|
f90bd6ee45 | ||
|
|
a351756883 | ||
|
|
ea9b0d4588 | ||
|
|
e9c85c946e | ||
|
|
876bfb525b | ||
|
|
6be67d0d92 | ||
|
|
16581766c6 | ||
|
|
7fd7104f46 | ||
|
|
d2cae7c8b5 | ||
|
|
83955acd22 | ||
|
|
d85b746021 | ||
|
|
e2dc91c1ea | ||
|
|
098e2623d6 | ||
|
|
2df045396d | ||
|
|
6c182a3567 | ||
|
|
340d6dfc8d | ||
|
|
eb27b0d3e2 | ||
|
|
7377893050 | ||
|
|
21d7a4eed1 | ||
|
|
fb2c2b1d09 | ||
|
|
6f2dd6c8d0 | ||
|
|
4cc0e7845f | ||
|
|
ca8eabc425 | ||
|
|
94c83d6a93 | ||
|
|
dea2f34c52 | ||
|
|
b294f5e333 | ||
|
|
f9b726a75d | ||
|
|
579645d6b7 | ||
|
|
e729571a21 | ||
|
|
f179139967 | ||
|
|
ee5119e4dd | ||
|
|
11bf5ac6fc | ||
|
|
c3eb385066 | ||
|
|
886c9156a5 | ||
|
|
23062c59cd | ||
|
|
da254058c5 | ||
|
|
a6fa4fc613 | ||
|
|
593b61ea4b | ||
|
|
04d691991a | ||
|
|
faa6e82bef | ||
|
|
65ccb80aa4 | ||
|
|
cc13d1d0f1 | ||
|
|
ead84d70d1 | ||
|
|
275146fb55 | ||
|
|
d81729d9f7 | ||
|
|
e74a249388 | ||
|
|
d8c5e557d8 | ||
|
|
984e8b5227 | ||
|
|
469bf2a63e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,3 +16,6 @@
|
||||
/settings*.json
|
||||
/testchain.json
|
||||
/run-testnet.sh
|
||||
/.idea
|
||||
/qortal.iml
|
||||
*.DS_Store
|
||||
|
||||
@@ -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
|
||||
|
||||
3
WindowsInstaller/Install Files/AppData/settings.json
Executable file
3
WindowsInstaller/Install Files/AppData/settings.json
Executable file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"apiDocumentationEnabled": true
|
||||
}
|
||||
70
WindowsInstaller/Install Files/log4j2.properties
Executable file
70
WindowsInstaller/Install Files/log4j2.properties
Executable file
@@ -0,0 +1,70 @@
|
||||
rootLogger.level = info
|
||||
# On Windows, uncomment next line to set dirname:
|
||||
# property.dirname = ${sys:user.home}\\AppData\\Local\\qortal\\
|
||||
property.filename = ${sys:log4j2.filenameTemplate:-log.txt}
|
||||
|
||||
rootLogger.appenderRef.console.ref = stdout
|
||||
rootLogger.appenderRef.rolling.ref = FILE
|
||||
|
||||
# Suppress extraneous bitcoinj library output
|
||||
logger.bitcoinj.name = org.bitcoinj
|
||||
logger.bitcoinj.level = error
|
||||
|
||||
# Override HSQLDB logging level to "warn" as too much is logged at "info"
|
||||
logger.hsqldb.name = hsqldb.db
|
||||
logger.hsqldb.level = warn
|
||||
|
||||
# Support optional, per-session HSQLDB debugging
|
||||
logger.hsqldbRepository.name = org.qortal.repository.hsqldb
|
||||
logger.hsqldbRepository.level = debug
|
||||
|
||||
# Suppress extraneous Jersey warning
|
||||
logger.jerseyInject.name = org.glassfish.jersey.internal.inject.Providers
|
||||
logger.jerseyInject.level = off
|
||||
|
||||
# Suppress extraneous Jersey EOF 'errors' (actually remote peers disconnecting early)
|
||||
logger.jerseyEOF.name = org.glassfish.jersey.server.internal
|
||||
logger.jerseyEOF.level = off
|
||||
|
||||
# Suppress extraneous Jetty entries
|
||||
# 2019-02-14 11:46:27 INFO ContextHandler:851 - Started o.e.j.s.ServletContextHandler@6949e948{/,null,AVAILABLE}
|
||||
# 2019-02-14 11:46:27 INFO AbstractConnector:289 - Started ServerConnector@50ad322b{HTTP/1.1,[http/1.1]}{0.0.0.0:9085}
|
||||
# 2019-02-14 11:46:27 INFO Server:374 - jetty-9.4.11.v20180605; built: 2018-06-05T18:24:03.829Z; git: d5fc0523cfa96bfebfbda19606cad384d772f04c; jvm 1.8.0_181-b13
|
||||
# 2019-02-14 11:46:27 INFO Server:411 - Started @2539ms
|
||||
logger.jetty.name = org.eclipse.jetty
|
||||
logger.jetty.level = warn
|
||||
# Even more extraneous Jetty output
|
||||
# 2019-01-26 02:18:10 WARN ResourceService:718 - java.util.concurrent.TimeoutException: Idle timeout expired: 30000/30000 ms
|
||||
logger.jettyRS.name = org.eclipse.jetty.server.ResourceService
|
||||
logger.jettyRS.level = error
|
||||
|
||||
# Suppress extraneous slf4j entries
|
||||
# 2019-02-14 11:46:27 INFO log:193 - Logging initialized @1636ms to org.eclipse.jetty.util.log.Slf4jLog
|
||||
logger.slf4j.name = org.slf4j
|
||||
logger.slf4j.level = warn
|
||||
|
||||
# Suppress extraneous Reflections entry
|
||||
# 2019-02-27 10:45:25 WARN Reflections:179 - given scan urls are empty. set urls in the configuration
|
||||
logger.orgReflections.name = org.reflections.Reflections
|
||||
logger.orgReflections.level = off
|
||||
logger.sunReflections.name = sun.reflect.Reflection
|
||||
logger.sunReflections.level = off
|
||||
|
||||
appender.console.type = Console
|
||||
appender.console.name = stdout
|
||||
appender.console.layout.type = PatternLayout
|
||||
appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n
|
||||
appender.console.filter.threshold.type = ThresholdFilter
|
||||
appender.console.filter.threshold.level = error
|
||||
|
||||
appender.rolling.type = RollingFile
|
||||
appender.rolling.name = FILE
|
||||
appender.rolling.layout.type = PatternLayout
|
||||
appender.rolling.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n
|
||||
appender.rolling.filePattern = ${dirname:-}${filename}.%i
|
||||
appender.rolling.policy.type = SizeBasedTriggeringPolicy
|
||||
appender.rolling.policy.size = 4MB
|
||||
# Set the immediate flush to true (default)
|
||||
# appender.rolling.immediateFlush = true
|
||||
# Set the append to true (default), should not overwrite
|
||||
# appender.rolling.append=true
|
||||
33
WindowsInstaller/Install Files/ntpcfg.bat
Executable file
33
WindowsInstaller/Install Files/ntpcfg.bat
Executable file
@@ -0,0 +1,33 @@
|
||||
@echo off
|
||||
|
||||
:: BatchGotAdmin
|
||||
:-------------------------------------
|
||||
REM --> Check for permissions
|
||||
>nul 2>&1 "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system"
|
||||
|
||||
REM --> If error flag set, we do not have admin.
|
||||
if '%errorlevel%' NEQ '0' (
|
||||
echo Requesting administrative privileges...
|
||||
goto UACPrompt
|
||||
) else ( goto gotAdmin )
|
||||
|
||||
:UACPrompt
|
||||
echo Set UAC = CreateObject^("Shell.Application"^) > "%temp%\getadmin.vbs"
|
||||
echo UAC.ShellExecute "%~s0", "", "", "runas", 1 >> "%temp%\getadmin.vbs"
|
||||
|
||||
"%temp%\getadmin.vbs"
|
||||
exit /B
|
||||
|
||||
:gotAdmin
|
||||
if exist "%temp%\getadmin.vbs" ( del "%temp%\getadmin.vbs" )
|
||||
pushd "%CD%"
|
||||
CD /D "%~dp0"
|
||||
:--------------------------------------
|
||||
|
||||
net stop "Windows Time"
|
||||
|
||||
w32tm /config "/manualpeerlist:pool.ntp.org 0.pool.ntp.org 1.pool.ntp.org 2.pool.ntp.org 3.pool.ntp.org cn.pool.ntp.org 0.cn.pool.ntp.org 1.cn.pool.ntp.org 2.cn.pool.ntp.org 3.cn.pool.ntp.org"
|
||||
|
||||
net start "Windows Time"
|
||||
|
||||
sc config w32time start= auto
|
||||
BIN
WindowsInstaller/Install Files/qortal.jar
Executable file
BIN
WindowsInstaller/Install Files/qortal.jar
Executable file
Binary file not shown.
1569
WindowsInstaller/Qortal.aip
Executable file
1569
WindowsInstaller/Qortal.aip
Executable file
File diff suppressed because it is too large
Load Diff
26
WindowsInstaller/README.md
Normal file
26
WindowsInstaller/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Windows installer
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* AdvancedInstaller v16 or better, and enterprise licence if translations are required
|
||||
* Installed AdoptOpenJDK v11 64bit, full JDK *not* JRE
|
||||
|
||||
## General build instructions
|
||||
|
||||
If this is your first time opening the `qortal.aip` file then you might need to adjust
|
||||
configured paths, or create a dummy `D:` drive with the expected layout.
|
||||
|
||||
Typical build procedure:
|
||||
|
||||
* Overwrite the `qortal.jar` file in `Install-Files\`
|
||||
* Open AdvancedInstaller with qortal.aip file
|
||||
* If releasing a new version, change version number in:
|
||||
+ "Product Information" side menu
|
||||
+ "Product Details" side menu entry
|
||||
+ "Product Details" tab in "Product Details" pane
|
||||
+ "Product Version" entry box
|
||||
* Click away to a different side menu entry, e.g. "Resources" -> "Files and Folders"
|
||||
* You should be prompted whether to generate a new product key, click "Generate New"
|
||||
* Click "Build" button
|
||||
* New EXE should be generated in `Qortal-SetupFiles\` folder with correct version number
|
||||
|
||||
117
WindowsInstaller/dictionary.ail
Normal file
117
WindowsInstaller/dictionary.ail
Normal file
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<DICTIONARY type="multilanguage">
|
||||
<!-- 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>
|
||||
BIN
WindowsInstaller/qortal.ico
Executable file
BIN
WindowsInstaller/qortal.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
Binary file not shown.
@@ -4,6 +4,6 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.ciyam</groupId>
|
||||
<artifactId>AT</artifactId>
|
||||
<version>1.3.4</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,10 +3,14 @@
|
||||
<groupId>org.ciyam</groupId>
|
||||
<artifactId>AT</artifactId>
|
||||
<versioning>
|
||||
<release>1.3.4</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>20200414162728</lastUpdated>
|
||||
<lastUpdated>20200925114415</lastUpdated>
|
||||
</versioning>
|
||||
</metadata>
|
||||
|
||||
42
pom.xml
42
pom.xml
@@ -3,21 +3,22 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.qortal</groupId>
|
||||
<artifactId>qortal</artifactId>
|
||||
<version>1.2.0</version>
|
||||
<version>1.5.1</version>
|
||||
<packaging>jar</packaging>
|
||||
<properties>
|
||||
<bitcoinj.version>0.15.5</bitcoinj.version>
|
||||
<skipTests>true</skipTests>
|
||||
<altcoinj.version>bf9fb80</altcoinj.version>
|
||||
<bitcoinj.version>0.15.6</bitcoinj.version>
|
||||
<bouncycastle.version>1.64</bouncycastle.version>
|
||||
<build.timestamp>${maven.build.timestamp}</build.timestamp>
|
||||
<ciyam-at.version>1.3.4</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.22.v20191022</jetty.version>
|
||||
<jetty.version>9.4.29.v20200521</jetty.version>
|
||||
<log4j.version>2.12.1</log4j.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<slf4j.version>1.7.12</slf4j.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,6 +427,12 @@
|
||||
<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>
|
||||
|
||||
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 $!
|
||||
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
src/main/.DS_Store
vendored
Normal file
BIN
src/main/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -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, 422),
|
||||
BTC_TOO_SOON(1203, 422);
|
||||
// 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));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.qortal.api;
|
||||
import java.io.IOException;
|
||||
|
||||
import javax.servlet.RequestDispatcher;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
@@ -17,7 +18,7 @@ public class ApiErrorHandler extends ErrorHandler {
|
||||
private static final Logger LOGGER = LogManager.getLogger(ApiErrorHandler.class);
|
||||
|
||||
@Override
|
||||
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
|
||||
if (Settings.getInstance().isApiLoggingEnabled()) {
|
||||
String requestURI = request.getRequestURI();
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ import org.eclipse.jetty.http.HttpVersion;
|
||||
import org.eclipse.jetty.rewrite.handler.RedirectPatternRule;
|
||||
import org.eclipse.jetty.rewrite.handler.RewriteHandler;
|
||||
import org.eclipse.jetty.server.CustomRequestLog;
|
||||
import org.eclipse.jetty.server.DetectorConnectionFactory;
|
||||
import org.eclipse.jetty.server.HttpConfiguration;
|
||||
import org.eclipse.jetty.server.HttpConnectionFactory;
|
||||
import org.eclipse.jetty.server.OptionalSslConnectionFactory;
|
||||
import org.eclipse.jetty.server.RequestLog;
|
||||
import org.eclipse.jetty.server.RequestLogWriter;
|
||||
import org.eclipse.jetty.server.SecureRequestCustomizer;
|
||||
@@ -43,6 +43,9 @@ 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;
|
||||
|
||||
public class ApiService {
|
||||
@@ -113,8 +116,7 @@ public class ApiService {
|
||||
SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString());
|
||||
|
||||
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
|
||||
new OptionalSslConnectionFactory(sslConnectionFactory, HttpVersion.HTTP_1_1.asString()),
|
||||
sslConnectionFactory,
|
||||
new DetectorConnectionFactory(sslConnectionFactory),
|
||||
httpConnectionFactory);
|
||||
portUnifiedConnector.setHost(Settings.getInstance().getBindAddress());
|
||||
portUnifiedConnector.setPort(Settings.getInstance().getApiPort());
|
||||
@@ -197,6 +199,9 @@ public class ApiService {
|
||||
context.addServlet(BlocksWebSocket.class, "/websockets/blocks");
|
||||
context.addServlet(ActiveChatsWebSocket.class, "/websockets/chat/active/*");
|
||||
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();
|
||||
|
||||
@@ -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,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() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,9 @@ public class CrossChainBitcoinRedeemRequest {
|
||||
@Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG")
|
||||
public byte[] secret;
|
||||
|
||||
@Schema(description = "Bitcoin HASH160(public key) for receiving funds, or omit to derive from private key", example = "u17kBVKkKSp12oUzaxFwNnq1JZf")
|
||||
public byte[] receivingAccountInfo;
|
||||
|
||||
public CrossChainBitcoinRedeemRequest() {
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,20 +12,19 @@ public class CrossChainBuildRequest {
|
||||
@Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] creatorPublicKey;
|
||||
|
||||
@Schema(description = "Initial QORT amount paid when trade agreed", example = "0.00100000")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long initialQortAmount;
|
||||
|
||||
@Schema(description = "Final QORT amount paid out on successful trade", example = "80.40200000")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long finalQortAmount;
|
||||
public long qortAmount;
|
||||
|
||||
@Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "123.45670000")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long fundingQortAmount;
|
||||
|
||||
@Schema(description = "HASH160 of creator's Bitcoin public key", example = "2daMveGc5pdjRyFacbxBzMksCbyC")
|
||||
public byte[] bitcoinPublicKeyHash;
|
||||
|
||||
@Schema(description = "HASH160 of secret", example = "43vnftqkjxrhb5kJdkU1ZFQLEnWV")
|
||||
public byte[] secretHash;
|
||||
public byte[] hashOfSecretB;
|
||||
|
||||
@Schema(description = "Bitcoin P2SH BTC balance for release of secret", example = "0.00864200")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
|
||||
@@ -8,10 +8,10 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainCancelRequest {
|
||||
|
||||
@Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
@Schema(description = "AT creator's public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] creatorPublicKey;
|
||||
|
||||
@Schema(description = "Qortal AT address")
|
||||
@Schema(description = "Qortal trade AT address")
|
||||
public String atAddress;
|
||||
|
||||
public CrossChainCancelRequest() {
|
||||
|
||||
127
src/main/java/org/qortal/api/model/CrossChainOfferSummary.java
Normal file
127
src/main/java/org/qortal/api/model/CrossChainOfferSummary.java
Normal file
@@ -0,0 +1,127 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
import org.qortal.crosschain.AcctMode;
|
||||
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 CrossChainOfferSummary {
|
||||
|
||||
// Properties
|
||||
|
||||
@Schema(description = "AT's Qortal address")
|
||||
private String qortalAtAddress;
|
||||
|
||||
@Schema(description = "AT creator's Qortal address")
|
||||
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;
|
||||
|
||||
@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 */
|
||||
}
|
||||
|
||||
public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
|
||||
this.qortalAtAddress = crossChainTradeData.qortalAtAddress;
|
||||
this.qortalCreator = crossChainTradeData.qortalCreator;
|
||||
this.qortalCreatorTradeAddress = crossChainTradeData.qortalCreatorTradeAddress;
|
||||
this.qortAmount = crossChainTradeData.qortAmount;
|
||||
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() {
|
||||
return this.qortalAtAddress;
|
||||
}
|
||||
|
||||
public String getQortalCreator() {
|
||||
return this.qortalCreator;
|
||||
}
|
||||
|
||||
public String getQortalCreatorTradeAddress() {
|
||||
return this.qortalCreatorTradeAddress;
|
||||
}
|
||||
|
||||
public long getQortAmount() {
|
||||
return this.qortAmount;
|
||||
}
|
||||
|
||||
public long getBtcAmount() {
|
||||
return this.btcAmount;
|
||||
}
|
||||
|
||||
public long getForeignAmount() {
|
||||
return this.foreignAmount;
|
||||
}
|
||||
|
||||
public int getTradeTimeout() {
|
||||
return this.tradeTimeout;
|
||||
}
|
||||
|
||||
public AcctMode getMode() {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return this.timestamp;
|
||||
}
|
||||
|
||||
public String getPartnerQortalReceivingAddress() {
|
||||
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,14 +8,20 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainSecretRequest {
|
||||
|
||||
@Schema(description = "Public key to match AT's 'recipient'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] recipientPublicKey;
|
||||
@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 = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG")
|
||||
public byte[] secret;
|
||||
@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 CrossChainSecretRequest() {
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainTradeRequest {
|
||||
|
||||
@Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] creatorPublicKey;
|
||||
@Schema(description = "AT creator's 'trade' public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] tradePublicKey;
|
||||
|
||||
@Schema(description = "Qortal AT address")
|
||||
public String atAddress;
|
||||
|
||||
@Schema(description = "Qortal address for trade partner/recipient")
|
||||
public String recipient;
|
||||
@Schema(description = "Signature of trading partner's 'offer' MESSAGE transaction")
|
||||
public byte[] messageTransactionSignature;
|
||||
|
||||
public CrossChainTradeRequest() {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
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 {
|
||||
|
||||
private long tradeTimestamp;
|
||||
|
||||
@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 */
|
||||
}
|
||||
|
||||
public CrossChainTradeSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
|
||||
this.tradeTimestamp = timestamp;
|
||||
this.qortAmount = crossChainTradeData.qortAmount;
|
||||
this.foreignAmount = crossChainTradeData.expectedForeignAmount;
|
||||
this.btcAmount = this.foreignAmount;
|
||||
}
|
||||
|
||||
public long getTradeTimestamp() {
|
||||
return this.tradeTimestamp;
|
||||
}
|
||||
|
||||
public long getQortAmount() {
|
||||
return this.qortAmount;
|
||||
}
|
||||
|
||||
public long getBtcAmount() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 BitcoinSendRequest {
|
||||
|
||||
@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", 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() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
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.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 = "80.50000000", type = "number")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long fundingQortAmount;
|
||||
|
||||
@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;
|
||||
|
||||
@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 = "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);
|
||||
|
||||
@@ -302,13 +332,13 @@ public class AdminResource {
|
||||
@DELETE
|
||||
@Path("/mintingaccounts")
|
||||
@Operation(
|
||||
summary = "Remove account/reward-share from use by BlockMinter, using private key",
|
||||
summary = "Remove account/reward-share from use by BlockMinter, using public or private key",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string", example = "private key"
|
||||
type = "string", example = "public or private key"
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -319,13 +349,14 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE})
|
||||
public String deleteMintingAccount(String seed58) {
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String deleteMintingAccount(String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
byte[] seed = Base58.decode(seed58.trim());
|
||||
byte[] key = Base58.decode(key58.trim());
|
||||
|
||||
if (repository.getAccountRepository().delete(seed) == 0)
|
||||
if (repository.getAccountRepository().delete(key) == 0)
|
||||
return "false";
|
||||
|
||||
repository.saveChanges();
|
||||
@@ -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,172 @@ 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()) {
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
repository.exportNodeLocalData(true);
|
||||
return "true";
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// We couldn't lock blockchain to perform export
|
||||
return "false";
|
||||
} 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.script' 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.script";
|
||||
|
||||
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) {
|
||||
|
||||
@@ -480,4 +480,44 @@ public class BlocksResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/summaries")
|
||||
@Operation(
|
||||
summary = "Fetch only summary info about a range of blocks",
|
||||
description = "Specify up to 2 out 3 of: start, end and count. If neither start nor end are specified, then end is assumed to be latest block. Where necessary, count is assumed to be 50.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "blocks",
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = BlockSummaryData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({
|
||||
ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public List<BlockSummaryData> getBlockSummaries(
|
||||
@QueryParam("start") Integer startHeight,
|
||||
@QueryParam("end") Integer endHeight,
|
||||
@Parameter(ref = "count") @QueryParam("count") Integer count) {
|
||||
// Check up to 2 out of 3 params
|
||||
if (startHeight != null && endHeight != null && count != null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Check values
|
||||
if ((startHeight != null && startHeight < 1) || (endHeight != null && endHeight < 1) || (count != null && count < 1))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
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.CrossChainSecretRequest;
|
||||
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 = 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 String buildRedeemMessage(CrossChainSecretRequest 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.BitcoinyTransaction;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
|
||||
@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 = BitcoinyTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public List<BitcoinyTransaction> 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,175 @@
|
||||
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.bitcoinj.core.TransactionOutput;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.CrossChainBitcoinyHTLCStatus;
|
||||
import org.qortal.crosschain.Bitcoiny;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
import org.qortal.crosschain.BitcoinyHTLC;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
|
||||
@Path("/crosschain/htlc")
|
||||
@Tag(name = "Cross-Chain (Hash time-locked contracts)")
|
||||
public class CrossChainHtlcResource {
|
||||
|
||||
@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 (hex). 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 refundHex,
|
||||
@PathParam("locktime") int lockTime,
|
||||
@PathParam("redeemPKH") String redeemHex,
|
||||
@PathParam("hashOfSecret") String hashOfSecretHex) {
|
||||
SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName);
|
||||
if (blockchain == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] refunderPubKeyHash;
|
||||
byte[] redeemerPubKeyHash;
|
||||
byte[] hashOfSecret;
|
||||
|
||||
try {
|
||||
refunderPubKeyHash = HashCode.fromString(refundHex).asBytes();
|
||||
redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes();
|
||||
|
||||
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 {
|
||||
hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes();
|
||||
if (hashOfSecret.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret);
|
||||
|
||||
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 (hex). 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 refundHex,
|
||||
@PathParam("locktime") int lockTime,
|
||||
@PathParam("redeemPKH") String redeemHex,
|
||||
@PathParam("hashOfSecret") String hashOfSecretHex) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName);
|
||||
if (blockchain == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] refunderPubKeyHash;
|
||||
byte[] redeemerPubKeyHash;
|
||||
byte[] hashOfSecret;
|
||||
|
||||
try {
|
||||
refunderPubKeyHash = HashCode.fromString(refundHex).asBytes();
|
||||
redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes();
|
||||
|
||||
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 {
|
||||
hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes();
|
||||
if (hashOfSecret.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
}
|
||||
|
||||
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: refund
|
||||
|
||||
// TODO: redeem
|
||||
|
||||
}
|
||||
@@ -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.BitcoinyTransaction;
|
||||
import org.qortal.crosschain.ForeignBlockchainException;
|
||||
import org.qortal.crosschain.Litecoin;
|
||||
|
||||
@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 = BitcoinyTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public List<BitcoinyTransaction> 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,69 +7,56 @@ 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;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
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.PathParam;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
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.TransactionOutput;
|
||||
import org.qortal.account.PublicKeyAccount;
|
||||
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.CrossChainCancelRequest;
|
||||
import org.qortal.api.model.CrossChainSecretRequest;
|
||||
import org.qortal.api.model.CrossChainTradeRequest;
|
||||
import org.qortal.api.model.CrossChainBitcoinP2SHStatus;
|
||||
import org.qortal.api.model.CrossChainBitcoinRedeemRequest;
|
||||
import org.qortal.api.model.CrossChainBitcoinRefundRequest;
|
||||
import org.qortal.api.model.CrossChainBitcoinTemplateRequest;
|
||||
import org.qortal.api.model.CrossChainBuildRequest;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.crosschain.BTC;
|
||||
import org.qortal.crosschain.BTCACCT;
|
||||
import org.qortal.api.model.CrossChainTradeSummary;
|
||||
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.at.ATStateData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData.Mode;
|
||||
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.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.Amounts;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.ByteArray;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
@Path("/crosschain")
|
||||
@Tag(name = "Cross-Chain")
|
||||
public class CrossChainResource {
|
||||
@@ -83,7 +70,6 @@ public class CrossChainResource {
|
||||
summary = "Find cross-chain trade offers",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "automated transactions",
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
@@ -96,6 +82,11 @@ public class CrossChainResource {
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public List<CrossChainTradeData> getTradeOffers(
|
||||
@Parameter(
|
||||
description = "Limit to specific blockchain",
|
||||
example = "LITECOIN",
|
||||
schema = @Schema(implementation = SupportedBlockchain.class)
|
||||
) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain,
|
||||
@Parameter( ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter( ref = "offset" ) @QueryParam("offset") Integer offset,
|
||||
@Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) {
|
||||
@@ -103,230 +94,199 @@ public class CrossChainResource {
|
||||
if (limit != null && limit > 100)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] codeHash = BTCACCT.CODE_BYTES_HASH;
|
||||
boolean isExecutable = true;
|
||||
final boolean isExecutable = true;
|
||||
List<CrossChainTradeData> crossChainTradesData = new ArrayList<>();
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<ATData> atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse);
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain);
|
||||
|
||||
List<CrossChainTradeData> crossChainTradesData = new ArrayList<>();
|
||||
for (ATData atData : atsData) {
|
||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
|
||||
crossChainTradesData.add(crossChainTradeData);
|
||||
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||
byte[] codeHash = acctInfo.getKey().value;
|
||||
ACCT acct = acctInfo.getValue().get();
|
||||
|
||||
List<ATData> atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse);
|
||||
|
||||
for (ATData atData : atsData) {
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
crossChainTradesData.add(crossChainTradeData);
|
||||
}
|
||||
}
|
||||
|
||||
return crossChainTradesData;
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/build")
|
||||
@GET
|
||||
@Path("/trade/{ataddress}")
|
||||
@Operation(
|
||||
summary = "Build 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) {
|
||||
byte[] creatorPublicKey = tradeRequest.creatorPublicKey;
|
||||
|
||||
if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (tradeRequest.secretHash == null || tradeRequest.secretHash.length != BTC.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.initialQortAmount < 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (tradeRequest.finalQortAmount <= 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.initialQortAmount + tradeRequest.finalQortAmount)
|
||||
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 = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.secretHash, tradeRequest.tradeTimeout, tradeRequest.initialQortAmount, tradeRequest.finalQortAmount, tradeRequest.bitcoinAmount);
|
||||
|
||||
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("/tradeoffer/recipient")
|
||||
@Operation(
|
||||
summary = "Builds raw, unsigned 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 address of Qortal recipient.<br>"
|
||||
+ "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
|
||||
+ "You need to sign output with same account as the AT creator otherwise the MESSAGE transaction will be invalid.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainTradeRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
summary = "Show detailed trade info",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
implementation = CrossChainTradeData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) {
|
||||
byte[] creatorPublicKey = tradeRequest.creatorPublicKey;
|
||||
|
||||
if (creatorPublicKey == null || creatorPublicKey.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.recipient == null || !Crypto.isValidAddress(tradeRequest.recipient))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
@ApiErrors({ApiError.ADDRESS_UNKNOWN, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public CrossChainTradeData getTrade(@PathParam("ataddress") String atAddress) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, tradeRequest.atAddress);
|
||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
|
||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE)
|
||||
ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
|
||||
if (acct == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Good to make MESSAGE
|
||||
|
||||
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(tradeRequest.recipient), 32, 0);
|
||||
byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, tradeRequest.atAddress, recipientAddressBytes);
|
||||
|
||||
return Base58.encode(messageTransactionBytes);
|
||||
return acct.populateTradeData(repository, atData);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/tradeoffer/secret")
|
||||
@GET
|
||||
@Path("/trades")
|
||||
@Operation(
|
||||
summary = "Builds raw, unsigned MESSAGE transaction that sends secret to AT, releasing funds to recipient",
|
||||
description = "Specify address of cross-chain AT that needs to be messaged, and 32-byte secret.<br>"
|
||||
+ "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
|
||||
+ "You need to sign output with account the AT considers the 'recipient' otherwise the MESSAGE transaction will be invalid.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainSecretRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
summary = "Find completed cross-chain trades",
|
||||
description = "Returns summary info about successfully completed cross-chain trades",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = CrossChainTradeSummary.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public String sendSecret(CrossChainSecretRequest secretRequest) {
|
||||
byte[] recipientPublicKey = secretRequest.recipientPublicKey;
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public List<CrossChainTradeSummary> getCompletedTrades(
|
||||
@Parameter(
|
||||
description = "Limit to specific blockchain",
|
||||
example = "LITECOIN",
|
||||
schema = @Schema(implementation = SupportedBlockchain.class)
|
||||
) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain,
|
||||
@Parameter(
|
||||
description = "Only return trades that completed on/after this timestamp (milliseconds since epoch)",
|
||||
example = "1597310000000"
|
||||
) @QueryParam("minimumTimestamp") Long minimumTimestamp,
|
||||
@Parameter( ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter( ref = "offset" ) @QueryParam("offset") Integer offset,
|
||||
@Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) {
|
||||
// Impose a limit on 'limit'
|
||||
if (limit != null && limit > 100)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (recipientPublicKey == null || recipientPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
// minimumTimestamp (if given) needs to be positive
|
||||
if (minimumTimestamp != null && minimumTimestamp <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (secretRequest.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
final Boolean isFinished = Boolean.TRUE;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = fetchAtDataWithChecking(repository, null, secretRequest.atAddress); // null to skip creator check
|
||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
|
||||
Integer minimumFinalHeight = null;
|
||||
|
||||
if (crossChainTradeData.mode == CrossChainTradeData.Mode.OFFER)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
if (minimumTimestamp != null) {
|
||||
minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(minimumTimestamp);
|
||||
|
||||
PublicKeyAccount recipientAccount = new PublicKeyAccount(repository, recipientPublicKey);
|
||||
String recipientAddress = recipientAccount.getAddress();
|
||||
if (minimumFinalHeight == 0)
|
||||
// We don't have any blocks since minimumTimestamp, let alone trades, so nothing to return
|
||||
return Collections.emptyList();
|
||||
|
||||
// MESSAGE must come from address that AT considers trade partner / 'recipient'
|
||||
if (!crossChainTradeData.qortalRecipient.equals(recipientAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
// height returned from repository is for block BEFORE timestamp
|
||||
// but we want trades AFTER timestamp so bump height accordingly
|
||||
minimumFinalHeight++;
|
||||
}
|
||||
|
||||
// Good to make MESSAGE
|
||||
List<CrossChainTradeSummary> crossChainTrades = new ArrayList<>();
|
||||
|
||||
byte[] messageTransactionBytes = buildAtMessage(repository, recipientPublicKey, secretRequest.atAddress, secretRequest.secret);
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain);
|
||||
|
||||
return Base58.encode(messageTransactionBytes);
|
||||
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, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumFinalHeight,
|
||||
limit, offset, reverse);
|
||||
|
||||
for (ATStateData atState : atStates) {
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState);
|
||||
|
||||
// We also need block timestamp for use as trade timestamp
|
||||
long timestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight());
|
||||
|
||||
CrossChainTradeSummary crossChainTradeSummary = new CrossChainTradeSummary(crossChainTradeData, timestamp);
|
||||
crossChainTrades.add(crossChainTradeSummary);
|
||||
}
|
||||
}
|
||||
|
||||
return crossChainTrades;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/price/{blockchain}")
|
||||
@Operation(
|
||||
summary = "Request current estimated trading price",
|
||||
description = "Returns price based on most recent completed trades. Price is expressed in terms of QORT per unit foreign currency.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "number"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public long getTradePriceEstimate(
|
||||
@Parameter(
|
||||
description = "foreign blockchain",
|
||||
example = "LITECOIN",
|
||||
schema = @Schema(implementation = SupportedBlockchain.class)
|
||||
) @PathParam("blockchain") SupportedBlockchain foreignBlockchain) {
|
||||
// foreignBlockchain is required
|
||||
if (foreignBlockchain == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// We want both a minimum of 5 trades and enough trades to span at least 4 hours
|
||||
int minimumCount = 5;
|
||||
int maximumCount = 10;
|
||||
long minimumPeriod = 4 * 60 * 60 * 1000L; // ms
|
||||
Boolean isFinished = Boolean.TRUE;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain);
|
||||
|
||||
long totalForeign = 0;
|
||||
long totalQort = 0;
|
||||
|
||||
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||
byte[] codeHash = acctInfo.getKey().value;
|
||||
ACCT acct = acctInfo.getValue().get();
|
||||
|
||||
List<ATStateData> atStates = repository.getATRepository().getMatchingFinalATStatesQuorum(codeHash,
|
||||
isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumCount, maximumCount, minimumPeriod);
|
||||
|
||||
for (ATStateData atState : atStates) {
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState);
|
||||
totalForeign += crossChainTradeData.expectedForeignAmount;
|
||||
totalQort += crossChainTradeData.qortAmount;
|
||||
}
|
||||
}
|
||||
|
||||
return Amounts.scaledDivide(totalQort, totalForeign);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -335,10 +295,11 @@ public class CrossChainResource {
|
||||
@DELETE
|
||||
@Path("/tradeoffer")
|
||||
@Operation(
|
||||
summary = "Builds raw, unsigned MESSAGE transaction that cancels cross-chain trade offer",
|
||||
summary = "Builds raw, unsigned 'cancel' MESSAGE transaction that cancels cross-chain trade offer",
|
||||
description = "Specify address of cross-chain AT that needs to be cancelled.<br>"
|
||||
+ "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
|
||||
+ "You need to sign output with same account as the AT creator otherwise the MESSAGE transaction will be invalid.",
|
||||
+ "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored.<br>"
|
||||
+ "Performs MESSAGE proof-of-work.<br>"
|
||||
+ "You need to sign output with AT creator's private key otherwise the MESSAGE transaction will be invalid.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
@@ -359,7 +320,10 @@ public class CrossChainResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) {
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String cancelTrade(CrossChainCancelRequest cancelRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
byte[] creatorPublicKey = cancelRequest.creatorPublicKey;
|
||||
|
||||
if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
|
||||
@@ -369,19 +333,27 @@ public class CrossChainResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, cancelRequest.atAddress);
|
||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
|
||||
ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress);
|
||||
|
||||
if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE)
|
||||
ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
|
||||
if (acct == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
|
||||
if (crossChainTradeData.mode != AcctMode.OFFERING)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Does supplied public key match AT creator's public key?
|
||||
if (!Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey()))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
// Good to make MESSAGE
|
||||
|
||||
PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey);
|
||||
String creatorAddress = creatorAccount.getAddress();
|
||||
byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(creatorAddress), 32, 0);
|
||||
String atCreatorAddress = Crypto.toAddress(creatorPublicKey);
|
||||
byte[] messageData = acct.buildCancelMessage(atCreatorAddress);
|
||||
|
||||
byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, recipientAddressBytes);
|
||||
byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, messageData);
|
||||
|
||||
return Base58.encode(messageTransactionBytes);
|
||||
} catch (DataException e) {
|
||||
@@ -389,347 +361,11 @@ public class CrossChainResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/p2sh")
|
||||
@Operation(
|
||||
summary = "Returns Bitcoin P2SH address based on trade info",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainBitcoinTemplateRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
public String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest) {
|
||||
BTC btc = BTC.getInstance();
|
||||
NetworkParameters params = btc.getNetworkParameters();
|
||||
|
||||
if (templateRequest.refundPublicKeyHash == null || templateRequest.refundPublicKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (templateRequest.redeemPublicKeyHash == null || templateRequest.redeemPublicKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (templateRequest.atAddress == null || !Crypto.isValidAtAddress(templateRequest.atAddress))
|
||||
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, null, templateRequest.atAddress); // null to skip creator check
|
||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
|
||||
|
||||
if (crossChainTradeData.mode == Mode.OFFER)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] redeemScriptBytes = BTCACCT.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash);
|
||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||
|
||||
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||
return p2shAddress.toString();
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/p2sh/check")
|
||||
@Operation(
|
||||
summary = "Checks Bitcoin P2SH address based on trade info",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainBitcoinTemplateRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinP2SHStatus.class))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest) {
|
||||
BTC btc = BTC.getInstance();
|
||||
NetworkParameters params = btc.getNetworkParameters();
|
||||
|
||||
if (templateRequest.refundPublicKeyHash == null || templateRequest.refundPublicKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (templateRequest.redeemPublicKeyHash == null || templateRequest.redeemPublicKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (templateRequest.atAddress == null || !Crypto.isValidAtAddress(templateRequest.atAddress))
|
||||
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, null, templateRequest.atAddress); // null to skip creator check
|
||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
|
||||
|
||||
if (crossChainTradeData.mode == Mode.OFFER)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] redeemScriptBytes = BTCACCT.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash);
|
||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||
|
||||
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||
|
||||
Integer medianBlockTime = BTC.getInstance().getMedianBlockTime();
|
||||
if (medianBlockTime == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE);
|
||||
|
||||
long now = NTP.getTime();
|
||||
|
||||
// Check P2SH is funded
|
||||
|
||||
Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString());
|
||||
if (p2shBalance == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
CrossChainBitcoinP2SHStatus p2shStatus = new CrossChainBitcoinP2SHStatus();
|
||||
p2shStatus.bitcoinP2shAddress = p2shAddress.toString();
|
||||
p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance.value, 8);
|
||||
|
||||
List<TransactionOutput> fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString());
|
||||
|
||||
if (p2shBalance.value >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) {
|
||||
p2shStatus.canRedeem = now >= medianBlockTime * 1000L;
|
||||
p2shStatus.canRefund = now >= crossChainTradeData.lockTime * 1000L;
|
||||
}
|
||||
|
||||
if (now >= medianBlockTime * 1000L) {
|
||||
// See if we can extract secret
|
||||
List<byte[]> rawTransactions = BTC.getInstance().getAddressTransactions(p2shStatus.bitcoinP2shAddress);
|
||||
p2shStatus.secret = BTCACCT.findP2shSecret(p2shStatus.bitcoinP2shAddress, rawTransactions);
|
||||
}
|
||||
|
||||
return p2shStatus;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/p2sh/refund")
|
||||
@Operation(
|
||||
summary = "Returns serialized Bitcoin transaction attempting refund from P2SH address",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainBitcoinRefundRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
|
||||
ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
|
||||
public String refundP2sh(CrossChainBitcoinRefundRequest refundRequest) {
|
||||
BTC btc = BTC.getInstance();
|
||||
NetworkParameters params = btc.getNetworkParameters();
|
||||
|
||||
byte[] refundPrivateKey = refundRequest.refundPrivateKey;
|
||||
if (refundPrivateKey == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
ECKey refundKey = null;
|
||||
|
||||
try {
|
||||
// Auto-trim
|
||||
if (refundPrivateKey.length >= 37 && refundPrivateKey.length <= 38)
|
||||
refundPrivateKey = Arrays.copyOfRange(refundPrivateKey, 1, 33);
|
||||
if (refundPrivateKey.length != 32)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
refundKey = ECKey.fromPrivate(refundPrivateKey);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
}
|
||||
|
||||
if (refundRequest.redeemPublicKeyHash == null || refundRequest.redeemPublicKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (refundRequest.atAddress == null || !Crypto.isValidAtAddress(refundRequest.atAddress))
|
||||
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, null, refundRequest.atAddress); // null to skip creator check
|
||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
|
||||
|
||||
if (crossChainTradeData.mode == Mode.OFFER)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] redeemScriptBytes = BTCACCT.buildScript(refundKey.getPubKeyHash(), crossChainTradeData.lockTime, refundRequest.redeemPublicKeyHash, crossChainTradeData.secretHash);
|
||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||
|
||||
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||
|
||||
long now = NTP.getTime();
|
||||
|
||||
// Check P2SH is funded
|
||||
|
||||
Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString());
|
||||
if (p2shBalance == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
List<TransactionOutput> fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString());
|
||||
if (fundingOutputs.isEmpty())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
boolean canRefund = now >= crossChainTradeData.lockTime * 1000L;
|
||||
if (!canRefund)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON);
|
||||
|
||||
if (p2shBalance.value < crossChainTradeData.expectedBitcoin)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE);
|
||||
|
||||
Coin refundAmount = p2shBalance.subtract(Coin.valueOf(refundRequest.bitcoinMinerFee.unscaledValue().longValue()));
|
||||
|
||||
org.bitcoinj.core.Transaction refundTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTime);
|
||||
boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction);
|
||||
|
||||
if (!wasBroadcast)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE);
|
||||
|
||||
return refundTransaction.getTxId().toString();
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/p2sh/redeem")
|
||||
@Operation(
|
||||
summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH address",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainBitcoinRedeemRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
|
||||
ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
|
||||
public String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest) {
|
||||
BTC btc = BTC.getInstance();
|
||||
NetworkParameters params = btc.getNetworkParameters();
|
||||
|
||||
byte[] redeemPrivateKey = redeemRequest.redeemPrivateKey;
|
||||
if (redeemPrivateKey == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
ECKey redeemKey = null;
|
||||
|
||||
try {
|
||||
// Auto-trim
|
||||
if (redeemPrivateKey.length >= 37 && redeemPrivateKey.length <= 38)
|
||||
redeemPrivateKey = Arrays.copyOfRange(redeemPrivateKey, 1, 33);
|
||||
if (redeemPrivateKey.length != 32)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
redeemKey = ECKey.fromPrivate(redeemPrivateKey);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
}
|
||||
|
||||
if (redeemRequest.refundPublicKeyHash == null || redeemRequest.refundPublicKeyHash.length != 20)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (redeemRequest.atAddress == null || !Crypto.isValidAtAddress(redeemRequest.atAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (redeemRequest.secret == null || redeemRequest.secret.length != BTCACCT.SECRET_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
// Extract data from cross-chain trading AT
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = fetchAtDataWithChecking(repository, null, redeemRequest.atAddress); // null to skip creator check
|
||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
|
||||
|
||||
if (crossChainTradeData.mode == Mode.OFFER)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
byte[] redeemScriptBytes = BTCACCT.buildScript(redeemRequest.refundPublicKeyHash, crossChainTradeData.lockTime, redeemKey.getPubKeyHash(), crossChainTradeData.secretHash);
|
||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||
|
||||
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||
|
||||
Integer medianBlockTime = BTC.getInstance().getMedianBlockTime();
|
||||
if (medianBlockTime == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE);
|
||||
|
||||
long now = NTP.getTime();
|
||||
|
||||
// Check P2SH is funded
|
||||
Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString());
|
||||
if (p2shBalance == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
if (p2shBalance.value < crossChainTradeData.expectedBitcoin)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE);
|
||||
|
||||
List<TransactionOutput> fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString());
|
||||
if (fundingOutputs.isEmpty())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
boolean canRedeem = now >= medianBlockTime * 1000L;
|
||||
if (!canRedeem)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON);
|
||||
|
||||
Coin redeemAmount = p2shBalance.subtract(Coin.valueOf(redeemRequest.bitcoinMinerFee.unscaledValue().longValue()));
|
||||
|
||||
org.bitcoinj.core.Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret);
|
||||
boolean wasBroadcast = BTC.getInstance().broadcastTransaction(redeemTransaction);
|
||||
|
||||
if (!wasBroadcast)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE);
|
||||
|
||||
return redeemTransaction.getTxId().toString();
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
private ATData fetchAtDataWithChecking(Repository repository, byte[] creatorPublicKey, String atAddress) throws DataException {
|
||||
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);
|
||||
|
||||
// Does supplied public key match that of AT?
|
||||
if (creatorPublicKey != null && !Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey()))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
// Must be correct AT - check functionality using code hash
|
||||
if (!Arrays.equals(atData.getCodeHash(), BTCACCT.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);
|
||||
@@ -738,27 +374,36 @@ public class CrossChainResource {
|
||||
}
|
||||
|
||||
private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException {
|
||||
PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, senderPublicKey);
|
||||
|
||||
long txTimestamp = NTP.getTime();
|
||||
byte[] lastReference = creatorAccount.getLastReference();
|
||||
|
||||
if (lastReference == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE);
|
||||
// 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 = null;
|
||||
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);
|
||||
|
||||
fee = messageTransaction.calcRecommendedFee();
|
||||
messageTransactionData.setFee(fee);
|
||||
if (requiresPoW) {
|
||||
messageTransaction.computeNonce();
|
||||
} else {
|
||||
fee = messageTransaction.calcRecommendedFee();
|
||||
messageTransactionData.setFee(fee);
|
||||
}
|
||||
|
||||
ValidationResult result = messageTransaction.isValidUnconfirmed();
|
||||
if (result != ValidationResult.OK)
|
||||
|
||||
@@ -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);
|
||||
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) {
|
||||
|
||||
@@ -6,11 +6,12 @@ import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.WebSocketException;
|
||||
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.WebSocketServlet;
|
||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||
import org.qortal.controller.ChatNotifier;
|
||||
import org.qortal.crypto.Crypto;
|
||||
@@ -22,7 +23,7 @@ import org.qortal.repository.RepositoryManager;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSocket {
|
||||
public class ActiveChatsWebSocket extends ApiWebSocket {
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
@@ -30,8 +31,9 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, String> pathParams = this.getPathParams(session, "/{address}");
|
||||
Map<String, String> pathParams = getPathParams(session, "/{address}");
|
||||
|
||||
String address = pathParams.get("address");
|
||||
if (address == null || !Crypto.isValidAddress(address)) {
|
||||
@@ -48,12 +50,19 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock
|
||||
}
|
||||
|
||||
@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) {
|
||||
@@ -70,7 +79,7 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock
|
||||
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
|
||||
this.marshall(stringWriter, activeChats);
|
||||
marshall(stringWriter, activeChats);
|
||||
|
||||
// Only output if something has changed
|
||||
String output = stringWriter.toString();
|
||||
@@ -78,8 +87,8 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock
|
||||
return;
|
||||
|
||||
previousOutput.set(output);
|
||||
session.getRemote().sendString(output);
|
||||
} catch (DataException | IOException e) {
|
||||
session.getRemote().sendStringByFuture(output);
|
||||
} catch (DataException | IOException | WebSocketException e) {
|
||||
// No output this time?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,62 +5,95 @@ import java.io.StringWriter;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.WebSocketException;
|
||||
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.WebSocketServlet;
|
||||
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 WebSocketServlet implements 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();
|
||||
|
||||
this.marshall(stringWriter, nodeStatus);
|
||||
|
||||
// Only output if something has changed
|
||||
String output = stringWriter.toString();
|
||||
if (output.equals(previousOutput.get()))
|
||||
return;
|
||||
|
||||
previousOutput.set(output);
|
||||
session.getRemote().sendString(output);
|
||||
} catch (DataException | IOException e) {
|
||||
private void sendStatus(Session session, String status) {
|
||||
try {
|
||||
session.getRemote().sendStringByFuture(status);
|
||||
} catch (WebSocketException e) {
|
||||
// No output this time?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ package org.qortal.api.websocket;
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.io.Writer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.xml.bind.JAXBContext;
|
||||
@@ -13,24 +16,28 @@ import javax.xml.bind.Marshaller;
|
||||
import org.eclipse.jetty.http.pathmap.UriTemplatePathSpec;
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
|
||||
import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
|
||||
import org.eclipse.persistence.jaxb.JAXBContextFactory;
|
||||
import org.eclipse.persistence.jaxb.MarshallerProperties;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrorRoot;
|
||||
|
||||
interface ApiWebSocket {
|
||||
@SuppressWarnings("serial")
|
||||
abstract class ApiWebSocket extends WebSocketServlet {
|
||||
|
||||
default String getPathInfo(Session session) {
|
||||
private static final Map<Class<? extends ApiWebSocket>, List<Session>> SESSIONS_BY_CLASS = new HashMap<>();
|
||||
|
||||
protected static String getPathInfo(Session session) {
|
||||
ServletUpgradeRequest upgradeRequest = (ServletUpgradeRequest) session.getUpgradeRequest();
|
||||
return upgradeRequest.getHttpServletRequest().getPathInfo();
|
||||
}
|
||||
|
||||
default Map<String, String> getPathParams(Session session, String pathSpec) {
|
||||
protected static Map<String, String> getPathParams(Session session, String pathSpec) {
|
||||
UriTemplatePathSpec uriTemplatePathSpec = new UriTemplatePathSpec(pathSpec);
|
||||
return uriTemplatePathSpec.getPathParams(this.getPathInfo(session));
|
||||
return uriTemplatePathSpec.getPathParams(getPathInfo(session));
|
||||
}
|
||||
|
||||
default void sendError(Session session, ApiError apiError) {
|
||||
protected static void sendError(Session session, ApiError apiError) {
|
||||
ApiErrorRoot apiErrorRoot = new ApiErrorRoot();
|
||||
apiErrorRoot.setApiError(apiError);
|
||||
|
||||
@@ -43,7 +50,7 @@ interface ApiWebSocket {
|
||||
}
|
||||
}
|
||||
|
||||
default void marshall(Writer writer, Object object) throws IOException {
|
||||
protected static void marshall(Writer writer, Object object) throws IOException {
|
||||
Marshaller marshaller = createMarshaller(object.getClass());
|
||||
|
||||
try {
|
||||
@@ -53,7 +60,7 @@ interface ApiWebSocket {
|
||||
}
|
||||
}
|
||||
|
||||
default void marshall(Writer writer, Collection<?> collection) throws IOException {
|
||||
protected static void marshall(Writer writer, Collection<?> collection) throws IOException {
|
||||
// If collection is empty then we're returning "[]" anyway
|
||||
if (collection.isEmpty()) {
|
||||
writer.append("[]");
|
||||
@@ -92,4 +99,24 @@ interface ApiWebSocket {
|
||||
}
|
||||
}
|
||||
|
||||
public void onWebSocketConnect(Session session) {
|
||||
synchronized (SESSIONS_BY_CLASS) {
|
||||
SESSIONS_BY_CLASS.computeIfAbsent(this.getClass(), clazz -> new ArrayList<>()).add(session);
|
||||
}
|
||||
}
|
||||
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
synchronized (SESSIONS_BY_CLASS) {
|
||||
List<Session> sessions = SESSIONS_BY_CLASS.get(this.getClass());
|
||||
if (sessions != null)
|
||||
sessions.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
protected List<Session> getSessions() {
|
||||
synchronized (SESSIONS_BY_CLASS) {
|
||||
return new ArrayList<>(SESSIONS_BY_CLASS.get(this.getClass()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,17 +2,23 @@ package org.qortal.api.websocket;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.WebSocketException;
|
||||
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.WebSocketServlet;
|
||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||
import org.qortal.api.ApiError;
|
||||
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;
|
||||
@@ -20,22 +26,42 @@ import org.qortal.utils.Base58;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class BlocksWebSocket extends WebSocketServlet implements 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 = blockData -> onNotify(session, blockData);
|
||||
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
|
||||
@@ -53,13 +79,19 @@ public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
BlockData blockData = repository.getBlockRepository().fromSignature(signature);
|
||||
if (blockData == null) {
|
||||
int height = repository.getBlockRepository().getHeightFromSignature(signature);
|
||||
if (height == 0) {
|
||||
sendError(session, ApiError.BLOCK_UNKNOWN);
|
||||
return;
|
||||
}
|
||||
|
||||
onNotify(session, blockData);
|
||||
List<BlockSummaryData> blockSummaries = repository.getBlockRepository().getBlockSummaries(height, height);
|
||||
if (blockSummaries == null || blockSummaries.isEmpty()) {
|
||||
sendError(session, ApiError.BLOCK_UNKNOWN);
|
||||
return;
|
||||
}
|
||||
|
||||
sendBlockSummary(session, blockSummaries.get(0));
|
||||
} catch (DataException e) {
|
||||
sendError(session, ApiError.REPOSITORY_ISSUE);
|
||||
}
|
||||
@@ -82,26 +114,26 @@ public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
if (blockData == null) {
|
||||
List<BlockSummaryData> blockSummaries = repository.getBlockRepository().getBlockSummaries(height, height);
|
||||
if (blockSummaries == null || blockSummaries.isEmpty()) {
|
||||
sendError(session, ApiError.BLOCK_UNKNOWN);
|
||||
return;
|
||||
}
|
||||
|
||||
onNotify(session, blockData);
|
||||
sendBlockSummary(session, blockSummaries.get(0));
|
||||
} catch (DataException e) {
|
||||
sendError(session, ApiError.REPOSITORY_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
private void onNotify(Session session, BlockData blockData) {
|
||||
private void sendBlockSummary(Session session, BlockSummaryData blockSummary) {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
|
||||
try {
|
||||
this.marshall(stringWriter, blockData);
|
||||
marshall(stringWriter, blockSummary);
|
||||
|
||||
session.getRemote().sendString(stringWriter.toString());
|
||||
} catch (IOException e) {
|
||||
session.getRemote().sendStringByFuture(stringWriter.toString());
|
||||
} catch (IOException | WebSocketException e) {
|
||||
// No output this time
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.WebSocketException;
|
||||
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.WebSocketServlet;
|
||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||
import org.qortal.controller.ChatNotifier;
|
||||
import org.qortal.data.chat.ChatMessage;
|
||||
@@ -23,7 +24,7 @@ import org.qortal.repository.RepositoryManager;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSocket {
|
||||
public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
@@ -31,6 +32,7 @@ public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSoc
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
|
||||
@@ -85,12 +87,19 @@ public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSoc
|
||||
}
|
||||
|
||||
@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) {
|
||||
@@ -123,10 +132,10 @@ public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSoc
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
|
||||
try {
|
||||
this.marshall(stringWriter, chatMessages);
|
||||
marshall(stringWriter, chatMessages);
|
||||
|
||||
session.getRemote().sendString(stringWriter.toString());
|
||||
} catch (IOException e) {
|
||||
session.getRemote().sendStringByFuture(stringWriter.toString());
|
||||
} catch (IOException | WebSocketException e) {
|
||||
// No output this time?
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
157
src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java
Normal file
157
src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java
Normal file
@@ -0,0 +1,157 @@
|
||||
package org.qortal.api.websocket;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.TradeBot;
|
||||
import org.qortal.crosschain.SupportedBlockchain;
|
||||
import org.qortal.data.crosschain.TradeBotData;
|
||||
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.Base58;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
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, Integer> PREVIOUS_STATES = new HashMap<>();
|
||||
|
||||
private static final Map<Session, String> sessionBlockchain = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
factory.register(TradeBotWebSocket.class);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<TradeBotData> tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
if (tradeBotEntries == null)
|
||||
// How do we properly fail here?
|
||||
return;
|
||||
|
||||
PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getStateValue)));
|
||||
} catch (DataException e) {
|
||||
// No output this time
|
||||
}
|
||||
|
||||
EventBus.INSTANCE.addListener(this::listen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listen(Event event) {
|
||||
if (!(event instanceof TradeBot.StateChangeEvent))
|
||||
return;
|
||||
|
||||
TradeBotData tradeBotData = ((TradeBot.StateChangeEvent) event).getTradeBotData();
|
||||
String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey());
|
||||
|
||||
synchronized (PREVIOUS_STATES) {
|
||||
Integer previousStateValue = PREVIOUS_STATES.get(tradePrivateKey58);
|
||||
if (previousStateValue != null && previousStateValue == tradeBotData.getStateValue())
|
||||
// Not changed
|
||||
return;
|
||||
|
||||
PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getStateValue());
|
||||
}
|
||||
|
||||
List<TradeBotData> tradeBotEntries = Collections.singletonList(tradeBotData);
|
||||
|
||||
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();
|
||||
|
||||
// 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) {
|
||||
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 */
|
||||
}
|
||||
|
||||
private boolean sendEntries(Session session, List<TradeBotData> tradeBotEntries) {
|
||||
try {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
marshall(stringWriter, tradeBotEntries);
|
||||
|
||||
String output = stringWriter.toString();
|
||||
session.getRemote().sendStringByFuture(output);
|
||||
} catch (IOException e) {
|
||||
// No output this time?
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
351
src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java
Normal file
351
src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java
Normal file
@@ -0,0 +1,351 @@
|
||||
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.CrossChainOfferSummary;
|
||||
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 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);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
populateCurrentSummaries(repository);
|
||||
|
||||
populateHistoricSummaries(repository);
|
||||
} catch (DataException e) {
|
||||
// How to fail properly?
|
||||
return;
|
||||
}
|
||||
|
||||
EventBus.INSTANCE.addListener(this::listen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listen(Event event) {
|
||||
if (!(event instanceof Controller.NewBlockEvent))
|
||||
return;
|
||||
|
||||
BlockData blockData = ((Controller.NewBlockEvent) event).getBlockData();
|
||||
|
||||
// Process any new info
|
||||
|
||||
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();
|
||||
|
||||
for (SupportedBlockchain blockchain : SupportedBlockchain.values()) {
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain);
|
||||
|
||||
List<CrossChainOfferSummary> crossChainOfferSummaries = new ArrayList<>();
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
case REDEEMED:
|
||||
case REFUNDED:
|
||||
case CANCELLED:
|
||||
cachedInfo.currentSummaries.remove(offerAtAddress);
|
||||
cachedInfo.historicSummaries.put(offerAtAddress, offerSummary);
|
||||
break;
|
||||
|
||||
case TRADING:
|
||||
cachedInfo.currentSummaries.remove(offerAtAddress);
|
||||
cachedInfo.historicSummaries.remove(offerAtAddress);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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(4002, "websocket issue");
|
||||
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 */
|
||||
}
|
||||
|
||||
private boolean sendOfferSummaries(Session session, List<CrossChainOfferSummary> crossChainOfferSummaries) {
|
||||
try {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
marshall(stringWriter, crossChainOfferSummaries);
|
||||
|
||||
String output = stringWriter.toString();
|
||||
session.getRemote().sendStringByFuture(output);
|
||||
} catch (IOException e) {
|
||||
// No output this time?
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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 == AcctMode.OFFERING)
|
||||
// We want when trade was created, not when it was last updated
|
||||
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, ACCT acct, List<ATStateData> atStates, Long timestamp) throws DataException {
|
||||
List<CrossChainOfferSummary> offerSummaries = new ArrayList<>();
|
||||
|
||||
for (ATStateData atState : atStates)
|
||||
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());
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.qortal.account.Account;
|
||||
import org.qortal.account.NullAccount;
|
||||
import org.qortal.account.PublicKeyAccount;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.block.BlockChain.CiyamAtSettings;
|
||||
import org.qortal.crypto.Crypto;
|
||||
@@ -30,13 +29,13 @@ import org.qortal.data.transaction.MessageTransactionData;
|
||||
import org.qortal.data.transaction.PaymentTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.group.Group;
|
||||
import org.qortal.repository.BlockRepository;
|
||||
import org.qortal.repository.ATRepository;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.transaction.AtTransaction;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
@@ -133,9 +132,9 @@ public class QortalATAPI extends API {
|
||||
|
||||
byte[] signature = blockSummaries.get(0).getSignature();
|
||||
// Save some of minter's signature and transactions signature, so middle 24 bytes of the full 128 byte signature.
|
||||
this.setA2(state, fromBytes(signature, 52));
|
||||
this.setA3(state, fromBytes(signature, 60));
|
||||
this.setA4(state, fromBytes(signature, 68));
|
||||
this.setA2(state, BitTwiddling.longFromBEBytes(signature, 52));
|
||||
this.setA3(state, BitTwiddling.longFromBEBytes(signature, 60));
|
||||
this.setA4(state, BitTwiddling.longFromBEBytes(signature, 68));
|
||||
} catch (DataException e) {
|
||||
throw new RuntimeException("AT API unable to fetch previous block?", e);
|
||||
}
|
||||
@@ -147,61 +146,33 @@ public class QortalATAPI extends API {
|
||||
String atAddress = this.atData.getATAddress();
|
||||
|
||||
int height = timestamp.blockHeight;
|
||||
int sequence = timestamp.transactionSequence + 1;
|
||||
int sequence = timestamp.transactionSequence;
|
||||
|
||||
BlockRepository blockRepository = this.getRepository().getBlockRepository();
|
||||
if (state.getCurrentBlockHeight() < BlockChain.getInstance().getAtFindNextTransactionFixHeight())
|
||||
// Off-by-one bug still in effect
|
||||
sequence += 1;
|
||||
|
||||
ATRepository.NextTransactionInfo nextTransactionInfo;
|
||||
try {
|
||||
int currentHeight = blockRepository.getBlockchainHeight();
|
||||
List<Transaction> blockTransactions = null;
|
||||
|
||||
while (height <= currentHeight) {
|
||||
if (blockTransactions == null) {
|
||||
BlockData blockData = blockRepository.fromHeight(height);
|
||||
|
||||
if (blockData == null)
|
||||
throw new DataException("Unable to fetch block " + height + " from repository?");
|
||||
|
||||
Block block = new Block(this.getRepository(), blockData);
|
||||
|
||||
blockTransactions = block.getTransactions();
|
||||
}
|
||||
|
||||
// No more transactions in this block? Try next block
|
||||
if (sequence >= blockTransactions.size()) {
|
||||
++height;
|
||||
sequence = 0;
|
||||
blockTransactions = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
Transaction transaction = blockTransactions.get(sequence);
|
||||
|
||||
// Transaction needs to be sent to specified recipient
|
||||
List<String> recipientAddresses = transaction.getRecipientAddresses();
|
||||
if (recipientAddresses.contains(atAddress)) {
|
||||
// Found a transaction
|
||||
|
||||
this.setA1(state, new Timestamp(height, timestamp.blockchainId, sequence).longValue());
|
||||
|
||||
// Copy transaction's partial signature into the other three A fields for future verification that it's the same transaction
|
||||
byte[] signature = transaction.getTransactionData().getSignature();
|
||||
this.setA2(state, fromBytes(signature, 8));
|
||||
this.setA3(state, fromBytes(signature, 16));
|
||||
this.setA4(state, fromBytes(signature, 24));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Transaction wasn't for us - keep going
|
||||
++sequence;
|
||||
}
|
||||
|
||||
// No more transactions - zero A and exit
|
||||
this.zeroA(state);
|
||||
nextTransactionInfo = this.getRepository().getATRepository().findNextTransaction(atAddress, height, sequence);
|
||||
} catch (DataException e) {
|
||||
throw new RuntimeException("AT API unable to fetch next transaction?", e);
|
||||
}
|
||||
|
||||
if (nextTransactionInfo == null) {
|
||||
// No more transactions for AT at this time - zero A and exit
|
||||
this.zeroA(state);
|
||||
return;
|
||||
}
|
||||
|
||||
// Found a transaction
|
||||
|
||||
this.setA1(state, new Timestamp(nextTransactionInfo.height, timestamp.blockchainId, nextTransactionInfo.sequence).longValue());
|
||||
|
||||
// Copy transaction's partial signature into the other three A fields for future verification that it's the same transaction
|
||||
this.setA2(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 8));
|
||||
this.setA3(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 16));
|
||||
this.setA4(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 24));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -282,7 +253,7 @@ public class QortalATAPI extends API {
|
||||
|
||||
byte[] hash = Crypto.digest(input);
|
||||
|
||||
return fromBytes(hash, 0);
|
||||
return BitTwiddling.longFromBEBytes(hash, 0);
|
||||
} catch (DataException e) {
|
||||
throw new RuntimeException("AT API unable to fetch latest block from repository?", e);
|
||||
}
|
||||
@@ -296,30 +267,14 @@ public class QortalATAPI extends API {
|
||||
|
||||
TransactionData transactionData = this.getTransactionFromA(state);
|
||||
|
||||
byte[] messageData = null;
|
||||
|
||||
switch (transactionData.getType()) {
|
||||
case MESSAGE:
|
||||
messageData = ((MessageTransactionData) transactionData).getData();
|
||||
break;
|
||||
|
||||
case AT:
|
||||
messageData = ((ATTransactionData) transactionData).getMessage();
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// Check data length is appropriate, i.e. not larger than B
|
||||
if (messageData.length > 4 * 8)
|
||||
return;
|
||||
byte[] messageData = this.getMessageFromTransaction(transactionData);
|
||||
|
||||
// Pad messageData to fit B
|
||||
byte[] paddedMessageData = Bytes.ensureCapacity(messageData, 4 * 8, 0);
|
||||
if (messageData.length < 4 * 8)
|
||||
messageData = Bytes.ensureCapacity(messageData, 4 * 8, 0);
|
||||
|
||||
// Endian must be correct here so that (for example) a SHA256 message can be compared to one generated locally
|
||||
this.setB(state, paddedMessageData);
|
||||
this.setB(state, messageData);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -457,12 +412,6 @@ public class QortalATAPI extends API {
|
||||
|
||||
// Utility methods
|
||||
|
||||
/** Convert part of little-endian byte[] to long */
|
||||
/* package */ static long fromBytes(byte[] bytes, int start) {
|
||||
return (bytes[start] & 0xffL) | (bytes[start + 1] & 0xffL) << 8 | (bytes[start + 2] & 0xffL) << 16 | (bytes[start + 3] & 0xffL) << 24
|
||||
| (bytes[start + 4] & 0xffL) << 32 | (bytes[start + 5] & 0xffL) << 40 | (bytes[start + 6] & 0xffL) << 48 | (bytes[start + 7] & 0xffL) << 56;
|
||||
}
|
||||
|
||||
/** Returns partial transaction signature, used to verify we're operating on the same transaction and not naively using block height & sequence. */
|
||||
public static byte[] partialSignature(byte[] fullSignature) {
|
||||
return Arrays.copyOfRange(fullSignature, 8, 32);
|
||||
@@ -473,7 +422,7 @@ public class QortalATAPI extends API {
|
||||
// Compare end of transaction's signature against A2 thru A4
|
||||
byte[] sig = transactionData.getSignature();
|
||||
|
||||
if (this.getA2(state) != fromBytes(sig, 8) || this.getA3(state) != fromBytes(sig, 16) || this.getA4(state) != fromBytes(sig, 24))
|
||||
if (this.getA2(state) != BitTwiddling.longFromBEBytes(sig, 8) || this.getA3(state) != BitTwiddling.longFromBEBytes(sig, 16) || this.getA4(state) != BitTwiddling.longFromBEBytes(sig, 24))
|
||||
throw new IllegalStateException("Transaction signature in A no longer matches signature from repository");
|
||||
}
|
||||
|
||||
@@ -497,6 +446,20 @@ public class QortalATAPI extends API {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns message data from transaction. */
|
||||
/*package*/ byte[] getMessageFromTransaction(TransactionData transactionData) {
|
||||
switch (transactionData.getType()) {
|
||||
case MESSAGE:
|
||||
return ((MessageTransactionData) transactionData).getData();
|
||||
|
||||
case AT:
|
||||
return ((ATTransactionData) transactionData).getMessage();
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns AT's account */
|
||||
/* package */ Account getATAccount() {
|
||||
return new Account(this.repository, this.atData.getATAddress());
|
||||
@@ -563,4 +526,8 @@ public class QortalATAPI extends API {
|
||||
super.setB(state, bBytes);
|
||||
}
|
||||
|
||||
protected void zeroB(MachineState state) {
|
||||
super.zeroB(state);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,8 +10,9 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -22,8 +23,70 @@ import org.qortal.settings.Settings;
|
||||
*/
|
||||
public enum QortalFunctionCode {
|
||||
/**
|
||||
* <tt>0x0510</tt><br>
|
||||
* Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.
|
||||
* Returns length of message data from transaction in A.<br>
|
||||
* <tt>0x0501</tt><br>
|
||||
* If transaction has no 'message', returns -1.
|
||||
*/
|
||||
GET_MESSAGE_LENGTH_FROM_TX_IN_A(0x0501, 0, true) {
|
||||
@Override
|
||||
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
|
||||
QortalATAPI api = (QortalATAPI) state.getAPI();
|
||||
|
||||
TransactionData transactionData = api.getTransactionFromA(state);
|
||||
|
||||
byte[] messageData = api.getMessageFromTransaction(transactionData);
|
||||
|
||||
if (messageData == null)
|
||||
functionData.returnValue = -1L;
|
||||
else
|
||||
functionData.returnValue = (long) messageData.length;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Put offset 'message' from transaction in A into B<br>
|
||||
* <tt>0x0502 start-offset</tt><br>
|
||||
* Copies up to 32 bytes of message data, starting at <tt>start-offset</tt> into B.<br>
|
||||
* If transaction has no 'message', or <tt>start-offset</tt> out of bounds, then zero B<br>
|
||||
* Example 'message' could be 256-bit shared secret
|
||||
*/
|
||||
PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B(0x0502, 1, false) {
|
||||
@Override
|
||||
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
|
||||
QortalATAPI api = (QortalATAPI) state.getAPI();
|
||||
|
||||
// In case something goes wrong, or we don't have enough message data.
|
||||
api.zeroB(state);
|
||||
|
||||
if (functionData.value1 < 0 || functionData.value1 > Integer.MAX_VALUE)
|
||||
return;
|
||||
|
||||
int startOffset = functionData.value1.intValue();
|
||||
|
||||
TransactionData transactionData = api.getTransactionFromA(state);
|
||||
|
||||
byte[] messageData = api.getMessageFromTransaction(transactionData);
|
||||
|
||||
if (messageData == null || startOffset > messageData.length)
|
||||
return;
|
||||
|
||||
/*
|
||||
* Copy up to 32 bytes of message data into B,
|
||||
* retain order but pad with zeros in lower bytes.
|
||||
*
|
||||
* So a 4-byte message "a b c d" would copy thusly:
|
||||
* a b c d 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
*/
|
||||
int byteCount = Math.min(32, messageData.length - startOffset);
|
||||
byte[] bBytes = new byte[32];
|
||||
|
||||
System.arraycopy(messageData, startOffset, bBytes, 0, byteCount);
|
||||
|
||||
api.setB(state, bBytes);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.<br>
|
||||
* <tt>0x0510</tt>
|
||||
*/
|
||||
CONVERT_B_TO_PKH(0x0510, 0, false) {
|
||||
@Override
|
||||
@@ -38,21 +101,21 @@ public enum QortalFunctionCode {
|
||||
}
|
||||
},
|
||||
/**
|
||||
* <tt>0x0511</tt><br>
|
||||
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to P2SH.<br>
|
||||
* <tt>0x0511</tt><br>
|
||||
* P2SH stored in lower 25 bytes of B.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* <tt>0x0512</tt><br>
|
||||
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to Qortal address.<br>
|
||||
* <tt>0x0512</tt><br>
|
||||
* Qortal address stored in lower 25 bytes of B.
|
||||
*/
|
||||
CONVERT_B_TO_QORTAL(0x0512, 0, false) {
|
||||
|
||||
@@ -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,6 +225,11 @@ 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;
|
||||
@@ -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.debug(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,37 @@ 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()));
|
||||
|
||||
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,43 +0,0 @@
|
||||
package org.qortal.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.qortal.data.block.BlockData;
|
||||
|
||||
public class BlockNotifier {
|
||||
|
||||
private static BlockNotifier instance;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Listener {
|
||||
void notify(BlockData blockData);
|
||||
}
|
||||
|
||||
private Map<Session, Listener> listenersBySession = new HashMap<>();
|
||||
|
||||
private BlockNotifier() {
|
||||
}
|
||||
|
||||
public static synchronized BlockNotifier getInstance() {
|
||||
if (instance == null)
|
||||
instance = new BlockNotifier();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public synchronized void register(Session session, Listener listener) {
|
||||
this.listenersBySession.put(session, listener);
|
||||
}
|
||||
|
||||
public synchronized void deregister(Session session) {
|
||||
this.listenersBySession.remove(session);
|
||||
}
|
||||
|
||||
public synchronized void onNewBlock(BlockData blockData) {
|
||||
for (Listener listener : this.listenersBySession.values())
|
||||
listener.notify(blockData);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.qortal.controller;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -27,22 +29,34 @@ public class ChatNotifier {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public synchronized void register(Session session, Listener listener) {
|
||||
this.listenersBySession.put(session, listener);
|
||||
public void register(Session session, Listener listener) {
|
||||
synchronized (this.listenersBySession) {
|
||||
this.listenersBySession.put(session, listener);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void deregister(Session session) {
|
||||
this.listenersBySession.remove(session);
|
||||
public void deregister(Session session) {
|
||||
synchronized (this.listenersBySession) {
|
||||
this.listenersBySession.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void onNewChatTransaction(ChatTransactionData chatTransactionData) {
|
||||
for (Listener listener : this.listenersBySession.values())
|
||||
public void onNewChatTransaction(ChatTransactionData chatTransactionData) {
|
||||
for (Listener listener : getAllListeners())
|
||||
listener.notify(chatTransactionData);
|
||||
}
|
||||
|
||||
public synchronized void onGroupMembershipChange() {
|
||||
for (Listener listener : this.listenersBySession.values())
|
||||
public void onGroupMembershipChange() {
|
||||
for (Listener listener : getAllListeners())
|
||||
listener.notify(null);
|
||||
}
|
||||
|
||||
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
@@ -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,42 +0,0 @@
|
||||
package org.qortal.controller;
|
||||
|
||||
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 synchronized void register(Session session, Listener listener) {
|
||||
this.listenersBySession.put(session, listener);
|
||||
}
|
||||
|
||||
public synchronized void deregister(Session session) {
|
||||
this.listenersBySession.remove(session);
|
||||
}
|
||||
|
||||
public synchronized void onStatusChange(long now) {
|
||||
for (Listener listener : this.listenersBySession.values())
|
||||
listener.notify(now);
|
||||
}
|
||||
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
379
src/main/java/org/qortal/controller/tradebot/TradeBot.java
Normal file
379
src/main/java/org/qortal/controller/tradebot/TradeBot.java
Normal file
@@ -0,0 +1,379 @@
|
||||
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...");
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
blockchainLock.lockInterruptibly();
|
||||
try {
|
||||
repository.exportNodeLocalData(true);
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException | DataException e) {
|
||||
LOGGER.info(String.format("Failed to obtain blockchain lock 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,159 +0,0 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.NetworkParameters;
|
||||
import org.bitcoinj.core.Transaction;
|
||||
import org.bitcoinj.core.TransactionOutput;
|
||||
import org.bitcoinj.params.MainNetParams;
|
||||
import org.bitcoinj.params.RegTestParams;
|
||||
import org.bitcoinj.params.TestNet3Params;
|
||||
import org.bitcoinj.script.ScriptBuilder;
|
||||
import org.bitcoinj.utils.MonetaryFormat;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
import org.qortal.utils.Pair;
|
||||
|
||||
public class BTC {
|
||||
|
||||
public static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode();
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
// 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
|
||||
|
||||
/** 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.fromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
|
||||
|
||||
// Descending, but order shouldn't matter as we're picking median...
|
||||
blockTimestamps.sort((a, b) -> Integer.compare(b, a));
|
||||
|
||||
return blockTimestamps.get(5);
|
||||
}
|
||||
|
||||
public Coin getBalance(String base58Address) {
|
||||
Long balance = this.electrumX.getBalance(addressToScript(base58Address));
|
||||
if (balance == null)
|
||||
return null;
|
||||
|
||||
return Coin.valueOf(balance);
|
||||
}
|
||||
|
||||
public List<TransactionOutput> getUnspentOutputs(String base58Address) {
|
||||
List<Pair<byte[], Integer>> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address));
|
||||
if (unspentOutputs == null)
|
||||
return null;
|
||||
|
||||
List<TransactionOutput> unspentTransactionOutputs = new ArrayList<>();
|
||||
for (Pair<byte[], Integer> unspentOutput : unspentOutputs) {
|
||||
List<TransactionOutput> transactionOutputs = getOutputs(unspentOutput.getA());
|
||||
if (transactionOutputs == null)
|
||||
return null;
|
||||
|
||||
unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.getB()));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public List<byte[]> getAddressTransactions(String base58Address) {
|
||||
return this.electrumX.getAddressTransactions(addressToScript(base58Address));
|
||||
}
|
||||
|
||||
public boolean broadcastTransaction(Transaction transaction) {
|
||||
return this.electrumX.broadcastTransaction(transaction.bitcoinSerialize());
|
||||
}
|
||||
|
||||
// Utility methods for us
|
||||
|
||||
private byte[] addressToScript(String base58Address) {
|
||||
Address address = Address.fromString(this.params, base58Address);
|
||||
return ScriptBuilder.createOutputScript(address).getProgram();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import static org.ciyam.at.OpCode.calcOffset;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
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.ciyam.at.API;
|
||||
import org.ciyam.at.CompilationException;
|
||||
import org.ciyam.at.FunctionCode;
|
||||
import org.ciyam.at.MachineState;
|
||||
import org.ciyam.at.OpCode;
|
||||
import org.ciyam.at.Timestamp;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.at.QortalAtLoggerFactory;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.block.BlockChain.CiyamAtSettings;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
/*
|
||||
* Bob generates Bitcoin private key
|
||||
* private key required to sign P2SH redeem tx
|
||||
* private key can be used to create 'secret' (e.g. double-SHA256)
|
||||
* encrypted private key could be stored in Qortal AT for access by Bob from any node
|
||||
* Bob creates Qortal AT
|
||||
* Alice finds Qortal AT and wants to trade
|
||||
* Alice generates Bitcoin private key
|
||||
* Alice will need to send Bob her Qortal address and Bitcoin refund address
|
||||
* Bob sends Alice's Qortal address to Qortal AT
|
||||
* Qortal AT sends initial QORT payment to Alice (so she has QORT to send message to AT and claim funds)
|
||||
* Alice receives funds and checks Qortal AT to confirm it's locked to her
|
||||
* Alice creates/funds Bitcoin P2SH
|
||||
* Alice requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime
|
||||
* Bob checks P2SH is funded
|
||||
* Bob requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime
|
||||
* Bob uses secret to redeem P2SH
|
||||
* Qortal core/UI will need to create, and sign, this transaction
|
||||
* Alice scans P2SH redeem tx and uses secret to redeem Qortal AT
|
||||
*/
|
||||
|
||||
public class BTCACCT {
|
||||
|
||||
public static final int SECRET_LENGTH = 32;
|
||||
public static final int MIN_LOCKTIME = 1500000000;
|
||||
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("edcdb1feb36e079c5f956faff2f24219b12e5fbaaa05654335e615e33218282f").asBytes(); // SHA256 of AT code bytes
|
||||
|
||||
/*
|
||||
* 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 BTCACCT} 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
|
||||
* @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) {
|
||||
NetworkParameters params = BTC.getInstance().getNetworkParameters();
|
||||
|
||||
Transaction transaction = new Transaction(params);
|
||||
transaction.setVersion(2);
|
||||
|
||||
// Output is back to P2SH funder
|
||||
transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(spendKey.getPubKeyHash()));
|
||||
|
||||
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, so no lockTime and no RBF
|
||||
else
|
||||
input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not 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();
|
||||
};
|
||||
|
||||
return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return Signed Bitcoin transaction for redeeming P2SH
|
||||
*/
|
||||
public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, byte[] secret) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Qortal AT creation bytes for cross-chain trading AT.
|
||||
* <p>
|
||||
* <tt>tradeTimeout</tt> (minutes) is the time window for the recipient to send the
|
||||
* 32-byte secret to the AT, before the AT automatically refunds the AT's creator.
|
||||
*
|
||||
* @param qortalCreator Qortal address for AT creator, also used for refunds
|
||||
* @param secretHash 20-byte HASH160 of 32-byte secret
|
||||
* @param tradeTimeout how many minutes, from start of 'trade mode' until AT auto-refunds AT creator
|
||||
* @param initialPayout how much QORT to pay trade partner upon switch to 'trade mode'
|
||||
* @param redeemPayout how much QORT to pay trade partner if they send correct 32-byte secret to AT
|
||||
* @param bitcoinAmount how much BTC the AT creator is expecting to trade
|
||||
* @return
|
||||
*/
|
||||
public static byte[] buildQortalAT(String qortalCreator, byte[] secretHash, int tradeTimeout, long initialPayout, long redeemPayout, long bitcoinAmount) {
|
||||
// Labels for data segment addresses
|
||||
int addrCounter = 0;
|
||||
|
||||
// Constants (with corresponding dataByteBuffer.put*() calls below)
|
||||
|
||||
final int addrQortalCreator1 = addrCounter++;
|
||||
final int addrQortalCreator2 = addrCounter++;
|
||||
final int addrQortalCreator3 = addrCounter++;
|
||||
final int addrQortalCreator4 = addrCounter++;
|
||||
|
||||
final int addrSecretHash = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrTradeTimeout = addrCounter++;
|
||||
final int addrInitialPayoutAmount = addrCounter++;
|
||||
final int addrRedeemPayoutAmount = addrCounter++;
|
||||
final int addrBitcoinAmount = addrCounter++;
|
||||
|
||||
final int addrMessageTxType = addrCounter++;
|
||||
|
||||
final int addrSecretHashPointer = addrCounter++;
|
||||
final int addrQortalRecipientPointer = addrCounter++;
|
||||
final int addrMessageSenderPointer = addrCounter++;
|
||||
|
||||
final int addrMessageDataPointer = addrCounter++;
|
||||
final int addrMessageDataLength = addrCounter++;
|
||||
|
||||
final int addrEndOfConstants = addrCounter;
|
||||
|
||||
// Variables
|
||||
|
||||
final int addrQortalRecipient1 = addrCounter++;
|
||||
final int addrQortalRecipient2 = addrCounter++;
|
||||
final int addrQortalRecipient3 = addrCounter++;
|
||||
final int addrQortalRecipient4 = addrCounter++;
|
||||
|
||||
final int addrTradeRefundTimestamp = addrCounter++;
|
||||
final int addrLastTxTimestamp = addrCounter++;
|
||||
final int addrBlockTimestamp = addrCounter++;
|
||||
final int addrTxType = addrCounter++;
|
||||
final int addrResult = addrCounter++;
|
||||
|
||||
final int addrMessageSender1 = addrCounter++;
|
||||
final int addrMessageSender2 = addrCounter++;
|
||||
final int addrMessageSender3 = addrCounter++;
|
||||
final int addrMessageSender4 = addrCounter++;
|
||||
|
||||
final int addrMessageData = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
// Data segment
|
||||
ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE);
|
||||
|
||||
// AT creator's Qortal address, decoded from Base58
|
||||
assert dataByteBuffer.position() == addrQortalCreator1 * MachineState.VALUE_SIZE : "addrQortalCreator1 incorrect";
|
||||
byte[] qortalCreatorBytes = Base58.decode(qortalCreator);
|
||||
dataByteBuffer.put(Bytes.ensureCapacity(qortalCreatorBytes, 32, 0));
|
||||
|
||||
// Hash of secret
|
||||
assert dataByteBuffer.position() == addrSecretHash * MachineState.VALUE_SIZE : "addrSecretHash incorrect";
|
||||
dataByteBuffer.put(Bytes.ensureCapacity(secretHash, 32, 0));
|
||||
|
||||
// Trade timeout in minutes
|
||||
assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect";
|
||||
dataByteBuffer.putLong(tradeTimeout);
|
||||
|
||||
// Initial payout amount
|
||||
assert dataByteBuffer.position() == addrInitialPayoutAmount * MachineState.VALUE_SIZE : "addrInitialPayoutAmount incorrect";
|
||||
dataByteBuffer.putLong(initialPayout);
|
||||
|
||||
// Redeem payout amount
|
||||
assert dataByteBuffer.position() == addrRedeemPayoutAmount * MachineState.VALUE_SIZE : "addrRedeemPayoutAmount incorrect";
|
||||
dataByteBuffer.putLong(redeemPayout);
|
||||
|
||||
// Expected Bitcoin amount
|
||||
assert dataByteBuffer.position() == addrBitcoinAmount * MachineState.VALUE_SIZE : "addrBitcoinAmount incorrect";
|
||||
dataByteBuffer.putLong(bitcoinAmount);
|
||||
|
||||
// We're only interested in MESSAGE transactions
|
||||
assert dataByteBuffer.position() == addrMessageTxType * MachineState.VALUE_SIZE : "addrMessageTxType incorrect";
|
||||
dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value);
|
||||
|
||||
// Index into data segment of hash, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrSecretHashPointer * MachineState.VALUE_SIZE : "addrSecretHashPointer incorrect";
|
||||
dataByteBuffer.putLong(addrSecretHash);
|
||||
|
||||
// Index into data segment of recipient address, used by SET_B_IND
|
||||
assert dataByteBuffer.position() == addrQortalRecipientPointer * MachineState.VALUE_SIZE : "addrQortalRecipientPointer incorrect";
|
||||
dataByteBuffer.putLong(addrQortalRecipient1);
|
||||
|
||||
// Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect";
|
||||
dataByteBuffer.putLong(addrMessageSender1);
|
||||
|
||||
// Source location and length for hashing any passed secret
|
||||
assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect";
|
||||
dataByteBuffer.putLong(addrMessageData);
|
||||
assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect";
|
||||
dataByteBuffer.putLong(32L);
|
||||
|
||||
assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants";
|
||||
|
||||
// Code labels
|
||||
Integer labelRefund = null;
|
||||
|
||||
Integer labelOfferTxLoop = null;
|
||||
Integer labelCheckOfferTx = null;
|
||||
|
||||
Integer labelTradeMode = null;
|
||||
Integer labelTradeTxLoop = null;
|
||||
Integer labelCheckTradeTx = null;
|
||||
|
||||
ByteBuffer codeByteBuffer = ByteBuffer.allocate(512);
|
||||
|
||||
// Two-pass version
|
||||
for (int pass = 0; pass < 2; ++pass) {
|
||||
codeByteBuffer.clear();
|
||||
|
||||
try {
|
||||
/* Initialization */
|
||||
|
||||
// Use AT creation 'timestamp' as starting point for finding transactions sent to AT
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp));
|
||||
|
||||
// Set restart position to after this opcode
|
||||
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
||||
|
||||
/* Loop, waiting for message from AT owner containing trade partner details, or AT owner's address to cancel offer */
|
||||
|
||||
/* Transaction processing loop */
|
||||
labelOfferTxLoop = codeByteBuffer.position();
|
||||
|
||||
// Find next transaction to this AT since the last one (if any)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp));
|
||||
// If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
|
||||
// If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
|
||||
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckOfferTx)));
|
||||
// Stop and wait for next block
|
||||
codeByteBuffer.put(OpCode.STP_IMD.compile());
|
||||
|
||||
/* Check transaction */
|
||||
labelCheckOfferTx = codeByteBuffer.position();
|
||||
|
||||
// Update our 'last found transaction's timestamp' using 'timestamp' from transaction
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp));
|
||||
// Extract transaction type (message/payment) from transaction and save type in addrTxType
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType));
|
||||
// If transaction type is not MESSAGE type then go look for another transaction
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelOfferTxLoop)));
|
||||
|
||||
/* Check transaction's sender */
|
||||
|
||||
// Extract sender address from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
|
||||
// Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction.
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalCreator1, calcOffset(codeByteBuffer, labelOfferTxLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalCreator2, calcOffset(codeByteBuffer, labelOfferTxLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalCreator3, calcOffset(codeByteBuffer, labelOfferTxLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalCreator4, calcOffset(codeByteBuffer, labelOfferTxLoop)));
|
||||
|
||||
/* Extract trade partner info from message */
|
||||
|
||||
// Extract message from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrQortalRecipient1 (as pointed to by addrQortalRecipientPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalRecipientPointer));
|
||||
// Compare each of recipient address with creator's address (for offer-cancel scenario). If they don't match, assume recipient is trade partner.
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient1, addrQortalCreator1, calcOffset(codeByteBuffer, labelTradeMode)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient2, addrQortalCreator2, calcOffset(codeByteBuffer, labelTradeMode)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient3, addrQortalCreator3, calcOffset(codeByteBuffer, labelTradeMode)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient4, addrQortalCreator4, calcOffset(codeByteBuffer, labelTradeMode)));
|
||||
// Recipient address is AT creator's address, so cancel offer and finish.
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund));
|
||||
|
||||
/* Switch to 'trade mode' */
|
||||
labelTradeMode = codeByteBuffer.position();
|
||||
|
||||
// Send initial payment to recipient so they have enough funds to message AT if all goes well
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrInitialPayoutAmount));
|
||||
|
||||
// Calculate trade timeout refund 'timestamp' by adding addrTradeTimeout minutes to above message's 'timestamp', then save into addrTradeRefundTimestamp
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrTradeRefundTimestamp, addrLastTxTimestamp, addrTradeTimeout));
|
||||
|
||||
// Set restart position to after this opcode
|
||||
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
||||
|
||||
/* Loop, waiting for trade timeout or message from Qortal trade recipient containing secret */
|
||||
|
||||
// Fetch current block 'timestamp'
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp));
|
||||
// If we're not past refund 'timestamp' then look for next transaction
|
||||
codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrTradeRefundTimestamp, calcOffset(codeByteBuffer, labelTradeTxLoop)));
|
||||
// We're past refund 'timestamp' so go refund everything back to AT creator
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund));
|
||||
|
||||
/* Transaction processing loop */
|
||||
labelTradeTxLoop = codeByteBuffer.position();
|
||||
|
||||
// Find next transaction to this AT since the last one (if any)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp));
|
||||
// If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
|
||||
// If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
|
||||
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTx)));
|
||||
// Stop and wait for next block
|
||||
codeByteBuffer.put(OpCode.STP_IMD.compile());
|
||||
|
||||
/* Check transaction */
|
||||
labelCheckTradeTx = codeByteBuffer.position();
|
||||
|
||||
// Update our 'last found transaction's timestamp' using 'timestamp' from transaction
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp));
|
||||
// Extract transaction type (message/payment) from transaction and save type in addrTxType
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType));
|
||||
// If transaction type is not MESSAGE type then go look for another transaction
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelTradeTxLoop)));
|
||||
|
||||
/* Check transaction's sender */
|
||||
|
||||
// Extract sender address from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
|
||||
// Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction.
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalRecipient1, calcOffset(codeByteBuffer, labelTradeTxLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalRecipient2, calcOffset(codeByteBuffer, labelTradeTxLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalRecipient3, calcOffset(codeByteBuffer, labelTradeTxLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalRecipient4, calcOffset(codeByteBuffer, labelTradeTxLoop)));
|
||||
|
||||
/* Check 'secret' in transaction's message */
|
||||
|
||||
// Extract message from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer));
|
||||
// Load B register with expected hash result (as pointed to by addrSecretHashPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrSecretHashPointer));
|
||||
// Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength).
|
||||
// Save the equality result (1 if they match, 0 otherwise) into addrResult.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength));
|
||||
// If hashes don't match, addrResult will be zero so go find another transaction
|
||||
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelTradeTxLoop)));
|
||||
|
||||
/* Success! Pay arranged amount to intended recipient */
|
||||
|
||||
// Load B register with intended recipient address (as pointed to by addrQortalRecipientPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrQortalRecipientPointer));
|
||||
// Pay AT's balance to recipient
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrRedeemPayoutAmount));
|
||||
// Fall-through to refunding any remaining balance back to AT creator
|
||||
|
||||
/* Refund balance back to AT creator */
|
||||
labelRefund = codeByteBuffer.position();
|
||||
|
||||
// Load B register with AT creator's address.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B));
|
||||
// Pay AT's balance back to AT's creator.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PAY_ALL_TO_ADDRESS_IN_B));
|
||||
// We're finished forever
|
||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||
} catch (CompilationException e) {
|
||||
throw new IllegalStateException("Unable to compile BTC-QORT ACCT?", e);
|
||||
}
|
||||
}
|
||||
|
||||
codeByteBuffer.flip();
|
||||
|
||||
byte[] codeBytes = new byte[codeByteBuffer.limit()];
|
||||
codeByteBuffer.get(codeBytes);
|
||||
|
||||
assert Arrays.equals(Crypto.digest(codeBytes), BTCACCT.CODE_BYTES_HASH)
|
||||
: String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes)));
|
||||
|
||||
final short ciyamAtVersion = 2;
|
||||
final short numCallStackPages = 0;
|
||||
final short numUserStackPages = 0;
|
||||
final long minActivationAmount = 0L;
|
||||
|
||||
return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||
*
|
||||
* @param repository
|
||||
* @param atAddress
|
||||
* @throws DataException
|
||||
*/
|
||||
public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
||||
String atAddress = atData.getATAddress();
|
||||
|
||||
ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress);
|
||||
byte[] stateData = atStateData.getStateData();
|
||||
|
||||
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
|
||||
byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData);
|
||||
|
||||
CrossChainTradeData tradeData = new CrossChainTradeData();
|
||||
tradeData.qortalAtAddress = atAddress;
|
||||
tradeData.qortalCreator = Crypto.toAddress(atData.getCreatorPublicKey());
|
||||
tradeData.creationTimestamp = atData.getCreation();
|
||||
|
||||
Account atAccount = new Account(repository, atAddress);
|
||||
tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT);
|
||||
|
||||
ByteBuffer dataByteBuffer = ByteBuffer.wrap(dataBytes);
|
||||
byte[] addressBytes = new byte[32];
|
||||
|
||||
// Skip AT creator address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32);
|
||||
|
||||
// Hash of secret
|
||||
tradeData.secretHash = new byte[20];
|
||||
dataByteBuffer.get(tradeData.secretHash);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - 20); // skip to 32 bytes
|
||||
|
||||
// Trade timeout
|
||||
tradeData.tradeRefundTimeout = dataByteBuffer.getLong();
|
||||
|
||||
// Initial payout
|
||||
tradeData.initialPayout = dataByteBuffer.getLong();
|
||||
|
||||
// Redeem payout
|
||||
tradeData.redeemPayout = dataByteBuffer.getLong();
|
||||
|
||||
// Expected BTC amount
|
||||
tradeData.expectedBitcoin = dataByteBuffer.getLong();
|
||||
|
||||
// Skip MESSAGE transaction type
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to secretHash
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to Qortal recipient
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to message sender
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to message data
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip message data length
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Qortal recipient (if any)
|
||||
dataByteBuffer.get(addressBytes);
|
||||
|
||||
// Trade offer timeout (AT 'timestamp' converted to Qortal block height)
|
||||
long tradeRefundTimestamp = dataByteBuffer.getLong();
|
||||
|
||||
if (tradeRefundTimestamp != 0) {
|
||||
tradeData.mode = CrossChainTradeData.Mode.TRADE;
|
||||
tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
|
||||
|
||||
if (addressBytes[0] != 0)
|
||||
tradeData.qortalRecipient = Base58.encode(Arrays.copyOf(addressBytes, Account.ADDRESS_LENGTH));
|
||||
|
||||
// We'll suggest half of trade timeout
|
||||
CiyamAtSettings ciyamAtSettings = BlockChain.getInstance().getCiyamAtSettings();
|
||||
|
||||
int tradeModeSwitchHeight = (int) (tradeData.tradeRefundHeight - tradeData.tradeRefundTimeout / ciyamAtSettings.minutesPerBlock);
|
||||
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(tradeModeSwitchHeight);
|
||||
if (blockData != null) {
|
||||
tradeData.tradeModeTimestamp = blockData.getTimestamp(); // NOTE: milliseconds from epoch
|
||||
tradeData.lockTime = (int) (tradeData.tradeModeTimestamp / 1000L + tradeData.tradeRefundTimeout / 2 * 60);
|
||||
}
|
||||
} else {
|
||||
tradeData.mode = CrossChainTradeData.Mode.OFFER;
|
||||
}
|
||||
|
||||
return tradeData;
|
||||
}
|
||||
|
||||
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 != BTCACCT.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);
|
||||
}
|
||||
|
||||
}
|
||||
921
src/main/java/org/qortal/crosschain/BitcoinACCTv1.java
Normal file
921
src/main/java/org/qortal/crosschain/BitcoinACCTv1.java
Normal file
@@ -0,0 +1,921 @@
|
||||
package org.qortal.crosschain;
|
||||
|
||||
import static org.ciyam.at.OpCode.calcOffset;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.ciyam.at.API;
|
||||
import org.ciyam.at.CompilationException;
|
||||
import org.ciyam.at.FunctionCode;
|
||||
import org.ciyam.at.MachineState;
|
||||
import org.ciyam.at.OpCode;
|
||||
import org.ciyam.at.Timestamp;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.qortal.at.QortalFunctionCode;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.data.transaction.MessageTransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
import com.google.common.hash.HashCode;
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
/**
|
||||
* Cross-chain trade AT
|
||||
*
|
||||
* <p>
|
||||
* <ul>
|
||||
* <li>Bob generates Bitcoin & Qortal 'trade' keys, and secret-b
|
||||
* <ul>
|
||||
* <li>private key required to sign P2SH redeem tx</li>
|
||||
* <li>private key could be used to create 'secret' (e.g. double-SHA256)</li>
|
||||
* <li>encrypted private key could be stored in Qortal AT for access by Bob from any node</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Bob deploys Qortal AT
|
||||
* <ul>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Alice finds Qortal AT and wants to trade
|
||||
* <ul>
|
||||
* <li>Alice generates Bitcoin & Qortal 'trade' keys</li>
|
||||
* <li>Alice funds Bitcoin P2SH-A</li>
|
||||
* <li>Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing:
|
||||
* <ul>
|
||||
* <li>hash-of-secret-A</li>
|
||||
* <li>her 'trade' Bitcoin PKH</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Bob receives "offer" MESSAGE
|
||||
* <ul>
|
||||
* <li>Checks Alice's P2SH-A</li>
|
||||
* <li>Sends 'trade' MESSAGE to Qortal AT from his trade address, containing:
|
||||
* <ul>
|
||||
* <li>Alice's trade Qortal address</li>
|
||||
* <li>Alice's trade Bitcoin PKH</li>
|
||||
* <li>hash-of-secret-A</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Alice checks Qortal AT to confirm it's locked to her
|
||||
* <ul>
|
||||
* <li>Alice creates/funds Bitcoin P2SH-B</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Bob checks P2SH-B is funded
|
||||
* <ul>
|
||||
* <li>Bob redeems P2SH-B using his Bitcoin trade key and secret-B</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Alice scans P2SH-B redeem transaction to extract secret-B
|
||||
* <ul>
|
||||
* <li>Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing:
|
||||
* <ul>
|
||||
* <li>secret-A</li>
|
||||
* <li>secret-B</li>
|
||||
* <li>Qortal receiving address of her chosing</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>AT's QORT funds are sent to Qortal receiving address</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Bob checks AT, extracts secret-A
|
||||
* <ul>
|
||||
* <li>Bob redeems P2SH-A using his Bitcoin trade key and secret-A</li>
|
||||
* <li>P2SH-A BTC funds end up at Bitcoin address determined by redeem transaction output(s)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
*/
|
||||
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;
|
||||
|
||||
/** <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;
|
||||
/** <b>Byte</b> offset into AT state data where 'mode' variable (long) is stored. */
|
||||
public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE);
|
||||
|
||||
public static class OfferMessageData {
|
||||
public byte[] partnerBitcoinPKH;
|
||||
public byte[] hashOfSecretA;
|
||||
public long lockTimeA;
|
||||
}
|
||||
public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerBitcoinPKH*/ + 20 /*hashOfSecretA*/ + 8 /*lockTimeA*/;
|
||||
public static final int TRADE_MESSAGE_LENGTH = 32 /*partner's Qortal trade address (padded from 25 to 32)*/
|
||||
+ 24 /*partner's Bitcoin PKH (padded from 20 to 24)*/
|
||||
+ 8 /*lockTimeB*/
|
||||
+ 24 /*hash of secret-A (padded from 20 to 24)*/
|
||||
+ 8 /*lockTimeA*/;
|
||||
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*/;
|
||||
|
||||
private static BitcoinACCTv1 instance;
|
||||
|
||||
private BitcoinACCTv1() {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Qortal AT creation bytes for cross-chain trading AT.
|
||||
* <p>
|
||||
* <tt>tradeTimeout</tt> (minutes) is the time window for the trade partner to send the
|
||||
* 32-byte secret to the AT, before the AT automatically refunds the AT's creator.
|
||||
*
|
||||
* @param creatorTradeAddress AT creator's trade Qortal address, also used for refunds
|
||||
* @param bitcoinPublicKeyHash 20-byte HASH160 of creator's trade Bitcoin public key
|
||||
* @param hashOfSecretB 20-byte HASH160 of 32-byte secret-B
|
||||
* @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
|
||||
*/
|
||||
public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout) {
|
||||
// Labels for data segment addresses
|
||||
int addrCounter = 0;
|
||||
|
||||
// Constants (with corresponding dataByteBuffer.put*() calls below)
|
||||
|
||||
final int addrCreatorTradeAddress1 = addrCounter++;
|
||||
final int addrCreatorTradeAddress2 = addrCounter++;
|
||||
final int addrCreatorTradeAddress3 = addrCounter++;
|
||||
final int addrCreatorTradeAddress4 = addrCounter++;
|
||||
|
||||
final int addrBitcoinPublicKeyHash = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrHashOfSecretB = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrQortAmount = addrCounter++;
|
||||
final int addrBitcoinAmount = addrCounter++;
|
||||
final int addrTradeTimeout = addrCounter++;
|
||||
|
||||
final int addrMessageTxnType = addrCounter++;
|
||||
final int addrExpectedTradeMessageLength = addrCounter++;
|
||||
final int addrExpectedRedeemMessageLength = addrCounter++;
|
||||
|
||||
final int addrCreatorAddressPointer = addrCounter++;
|
||||
final int addrHashOfSecretBPointer = addrCounter++;
|
||||
final int addrQortalPartnerAddressPointer = addrCounter++;
|
||||
final int addrMessageSenderPointer = addrCounter++;
|
||||
|
||||
final int addrTradeMessagePartnerBitcoinPKHOffset = addrCounter++;
|
||||
final int addrPartnerBitcoinPKHPointer = addrCounter++;
|
||||
final int addrTradeMessageHashOfSecretAOffset = addrCounter++;
|
||||
final int addrHashOfSecretAPointer = addrCounter++;
|
||||
|
||||
final int addrRedeemMessageSecretBOffset = addrCounter++;
|
||||
final int addrRedeemMessageReceivingAddressOffset = addrCounter++;
|
||||
|
||||
final int addrMessageDataPointer = addrCounter++;
|
||||
final int addrMessageDataLength = addrCounter++;
|
||||
|
||||
final int addrPartnerReceivingAddressPointer = addrCounter++;
|
||||
|
||||
final int addrEndOfConstants = addrCounter;
|
||||
|
||||
// Variables
|
||||
|
||||
final int addrCreatorAddress1 = addrCounter++;
|
||||
final int addrCreatorAddress2 = addrCounter++;
|
||||
final int addrCreatorAddress3 = addrCounter++;
|
||||
final int addrCreatorAddress4 = addrCounter++;
|
||||
|
||||
final int addrQortalPartnerAddress1 = addrCounter++;
|
||||
final int addrQortalPartnerAddress2 = addrCounter++;
|
||||
final int addrQortalPartnerAddress3 = addrCounter++;
|
||||
final int addrQortalPartnerAddress4 = addrCounter++;
|
||||
|
||||
final int addrLockTimeA = addrCounter++;
|
||||
final int addrLockTimeB = addrCounter++;
|
||||
final int addrRefundTimeout = addrCounter++;
|
||||
final int addrRefundTimestamp = addrCounter++;
|
||||
final int addrLastTxnTimestamp = addrCounter++;
|
||||
final int addrBlockTimestamp = addrCounter++;
|
||||
final int addrTxnType = addrCounter++;
|
||||
final int addrResult = addrCounter++;
|
||||
|
||||
final int addrMessageSender1 = addrCounter++;
|
||||
final int addrMessageSender2 = addrCounter++;
|
||||
final int addrMessageSender3 = addrCounter++;
|
||||
final int addrMessageSender4 = addrCounter++;
|
||||
|
||||
final int addrMessageLength = addrCounter++;
|
||||
|
||||
final int addrMessageData = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrHashOfSecretA = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrPartnerBitcoinPKH = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrPartnerReceivingAddress = addrCounter;
|
||||
addrCounter += 4;
|
||||
|
||||
final int addrMode = addrCounter++;
|
||||
assert addrMode == MODE_VALUE_OFFSET : "MODE_VALUE_OFFSET does not match addrMode";
|
||||
|
||||
// Data segment
|
||||
ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE);
|
||||
|
||||
// AT creator's trade Qortal address, decoded from Base58
|
||||
assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect";
|
||||
byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress);
|
||||
dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0));
|
||||
|
||||
// Bitcoin public key hash
|
||||
assert dataByteBuffer.position() == addrBitcoinPublicKeyHash * MachineState.VALUE_SIZE : "addrBitcoinPublicKeyHash incorrect";
|
||||
dataByteBuffer.put(Bytes.ensureCapacity(bitcoinPublicKeyHash, 32, 0));
|
||||
|
||||
// Hash of secret-B
|
||||
assert dataByteBuffer.position() == addrHashOfSecretB * MachineState.VALUE_SIZE : "addrHashOfSecretB incorrect";
|
||||
dataByteBuffer.put(Bytes.ensureCapacity(hashOfSecretB, 32, 0));
|
||||
|
||||
// Redeem Qort amount
|
||||
assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect";
|
||||
dataByteBuffer.putLong(qortAmount);
|
||||
|
||||
// Expected Bitcoin amount
|
||||
assert dataByteBuffer.position() == addrBitcoinAmount * MachineState.VALUE_SIZE : "addrBitcoinAmount incorrect";
|
||||
dataByteBuffer.putLong(bitcoinAmount);
|
||||
|
||||
// Suggested trade timeout (minutes)
|
||||
assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect";
|
||||
dataByteBuffer.putLong(tradeTimeout);
|
||||
|
||||
// We're only interested in MESSAGE transactions
|
||||
assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect";
|
||||
dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value);
|
||||
|
||||
// Expected length of 'trade' MESSAGE data from AT creator
|
||||
assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect";
|
||||
dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH);
|
||||
|
||||
// Expected length of 'redeem' MESSAGE data from trade partner
|
||||
assert dataByteBuffer.position() == addrExpectedRedeemMessageLength * MachineState.VALUE_SIZE : "addrExpectedRedeemMessageLength incorrect";
|
||||
dataByteBuffer.putLong(REDEEM_MESSAGE_LENGTH);
|
||||
|
||||
// Index into data segment of AT creator's address, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect";
|
||||
dataByteBuffer.putLong(addrCreatorAddress1);
|
||||
|
||||
// Index into data segment of hash of secret B, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrHashOfSecretBPointer * MachineState.VALUE_SIZE : "addrHashOfSecretBPointer incorrect";
|
||||
dataByteBuffer.putLong(addrHashOfSecretB);
|
||||
|
||||
// Index into data segment of partner's Qortal address, used by SET_B_IND
|
||||
assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect";
|
||||
dataByteBuffer.putLong(addrQortalPartnerAddress1);
|
||||
|
||||
// Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect";
|
||||
dataByteBuffer.putLong(addrMessageSender1);
|
||||
|
||||
// Offset into 'trade' MESSAGE data payload for extracting partner's Bitcoin PKH
|
||||
assert dataByteBuffer.position() == addrTradeMessagePartnerBitcoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerBitcoinPKHOffset incorrect";
|
||||
dataByteBuffer.putLong(32L);
|
||||
|
||||
// Index into data segment of partner's Bitcoin PKH, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrPartnerBitcoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerBitcoinPKHPointer incorrect";
|
||||
dataByteBuffer.putLong(addrPartnerBitcoinPKH);
|
||||
|
||||
// Offset into 'trade' MESSAGE data payload for extracting hash-of-secret-A
|
||||
assert dataByteBuffer.position() == addrTradeMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrTradeMessageHashOfSecretAOffset incorrect";
|
||||
dataByteBuffer.putLong(64L);
|
||||
|
||||
// Index into data segment to hash of secret A, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect";
|
||||
dataByteBuffer.putLong(addrHashOfSecretA);
|
||||
|
||||
// Offset into 'redeem' MESSAGE data payload for extracting secret-B
|
||||
assert dataByteBuffer.position() == addrRedeemMessageSecretBOffset * MachineState.VALUE_SIZE : "addrRedeemMessageSecretBOffset incorrect";
|
||||
dataByteBuffer.putLong(32L);
|
||||
|
||||
// Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address
|
||||
assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect";
|
||||
dataByteBuffer.putLong(64L);
|
||||
|
||||
// Source location and length for hashing any passed secret
|
||||
assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect";
|
||||
dataByteBuffer.putLong(addrMessageData);
|
||||
assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect";
|
||||
dataByteBuffer.putLong(32L);
|
||||
|
||||
// Pointer into data segment of where to save partner's receiving Qortal address, used by GET_B_IND
|
||||
assert dataByteBuffer.position() == addrPartnerReceivingAddressPointer * MachineState.VALUE_SIZE : "addrPartnerReceivingAddressPointer incorrect";
|
||||
dataByteBuffer.putLong(addrPartnerReceivingAddress);
|
||||
|
||||
assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants";
|
||||
|
||||
// Code labels
|
||||
Integer labelRefund = null;
|
||||
|
||||
Integer labelTradeTxnLoop = null;
|
||||
Integer labelCheckTradeTxn = null;
|
||||
Integer labelCheckCancelTxn = null;
|
||||
Integer labelNotTradeNorCancelTxn = null;
|
||||
Integer labelCheckNonRefundTradeTxn = null;
|
||||
Integer labelTradeTxnExtract = null;
|
||||
Integer labelRedeemTxnLoop = null;
|
||||
Integer labelCheckRedeemTxn = null;
|
||||
Integer labelCheckRedeemTxnSender = null;
|
||||
Integer labelCheckSecretB = null;
|
||||
Integer labelPayout = null;
|
||||
|
||||
ByteBuffer codeByteBuffer = ByteBuffer.allocate(768);
|
||||
|
||||
// Two-pass version
|
||||
for (int pass = 0; pass < 2; ++pass) {
|
||||
codeByteBuffer.clear();
|
||||
|
||||
try {
|
||||
/* Initialization */
|
||||
|
||||
// Use AT creation 'timestamp' as starting point for finding transactions sent to AT
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxnTimestamp));
|
||||
|
||||
// Load B register with AT creator's address so we can save it into addrCreatorAddress1-4
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B));
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer));
|
||||
|
||||
// Set restart position to after this opcode
|
||||
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
||||
|
||||
/* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */
|
||||
|
||||
/* Transaction processing loop */
|
||||
labelTradeTxnLoop = codeByteBuffer.position();
|
||||
|
||||
// Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp));
|
||||
// If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
|
||||
// If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
|
||||
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTxn)));
|
||||
// Stop and wait for next block
|
||||
codeByteBuffer.put(OpCode.STP_IMD.compile());
|
||||
|
||||
/* Check transaction */
|
||||
labelCheckTradeTxn = codeByteBuffer.position();
|
||||
|
||||
// Update our 'last found transaction's timestamp' using 'timestamp' from transaction
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp));
|
||||
// Extract transaction type (message/payment) from transaction and save type in addrTxnType
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType));
|
||||
// If transaction type is not MESSAGE type then go look for another transaction
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop)));
|
||||
|
||||
/* Check transaction's sender. We're expecting AT creator's trade address for 'trade' message, or AT creator's own address for 'cancel' message. */
|
||||
|
||||
// Extract sender address from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
|
||||
// Compare each part of message sender's address with AT creator's trade address. If they don't match, check for cancel situation.
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelCheckCancelTxn)));
|
||||
// Message sender's address matches AT creator's trade address so go process 'trade' message
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelCheckNonRefundTradeTxn == null ? 0 : labelCheckNonRefundTradeTxn));
|
||||
|
||||
/* Checking message sender for possible cancel message */
|
||||
labelCheckCancelTxn = codeByteBuffer.position();
|
||||
|
||||
// Compare each part of message sender's address with AT creator's address. If they don't match, look for another transaction.
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
|
||||
// Partner address is AT creator's address, so cancel offer and finish.
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.CANCELLED.value));
|
||||
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||
|
||||
/* Not trade nor cancel message */
|
||||
labelNotTradeNorCancelTxn = codeByteBuffer.position();
|
||||
|
||||
// Loop to find another transaction
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop));
|
||||
|
||||
/* Possible switch-to-trade-mode message */
|
||||
labelCheckNonRefundTradeTxn = codeByteBuffer.position();
|
||||
|
||||
// Check 'trade' message we received has expected number of message bytes
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength));
|
||||
// If message length matches, branch to info extraction code
|
||||
codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract)));
|
||||
// Message length didn't match - go back to finding another 'trade' MESSAGE transaction
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop));
|
||||
|
||||
/* Extracting info from 'trade' MESSAGE transaction */
|
||||
labelTradeTxnExtract = codeByteBuffer.position();
|
||||
|
||||
// Extract message from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer));
|
||||
|
||||
// Extract trade partner's Bitcoin public key hash (PKH)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerBitcoinPKHOffset));
|
||||
// Extract partner's Bitcoin PKH (we only really use values from B1-B3)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerBitcoinPKHPointer));
|
||||
// Also extract lockTimeB
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeB));
|
||||
|
||||
// Grab next 32 bytes
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset));
|
||||
|
||||
// Extract hash-of-secret-a (we only really use values from B1-B3)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer));
|
||||
// Extract lockTimeA (from B4)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA));
|
||||
|
||||
// Calculate trade refund timeout: (lockTimeA - lockTimeB) / 2 / 60
|
||||
codeByteBuffer.put(OpCode.SET_DAT.compile(addrRefundTimeout, addrLockTimeA)); // refundTimeout = lockTimeA
|
||||
codeByteBuffer.put(OpCode.SUB_DAT.compile(addrRefundTimeout, addrLockTimeB)); // refundTimeout -= lockTimeB
|
||||
codeByteBuffer.put(OpCode.DIV_VAL.compile(addrRefundTimeout, 2L * 60L)); // refundTimeout /= 2 * 60
|
||||
// Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this transaction's 'timestamp', then save into addrRefundTimestamp
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout));
|
||||
|
||||
/* We are in 'trade mode' */
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value));
|
||||
|
||||
// Set restart position to after this opcode
|
||||
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
||||
|
||||
/* Loop, waiting for trade timeout or 'redeem' MESSAGE from Qortal trade partner */
|
||||
|
||||
// Fetch current block 'timestamp'
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp));
|
||||
// If we're not past refund 'timestamp' then look for next transaction
|
||||
codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
// We're past refund 'timestamp' so go refund everything back to AT creator
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund));
|
||||
|
||||
/* Transaction processing loop */
|
||||
labelRedeemTxnLoop = codeByteBuffer.position();
|
||||
|
||||
// Find next transaction to this AT since the last one (if any)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp));
|
||||
// If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
|
||||
// If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction
|
||||
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckRedeemTxn)));
|
||||
// Stop and wait for next block
|
||||
codeByteBuffer.put(OpCode.STP_IMD.compile());
|
||||
|
||||
/* Check transaction */
|
||||
labelCheckRedeemTxn = codeByteBuffer.position();
|
||||
|
||||
// Update our 'last found transaction's timestamp' using 'timestamp' from transaction
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp));
|
||||
// Extract transaction type (message/payment) from transaction and save type in addrTxnType
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType));
|
||||
// If transaction type is not MESSAGE type then go look for another transaction
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
|
||||
/* Check message payload length */
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength));
|
||||
// If message length matches, branch to sender checking code
|
||||
codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedRedeemMessageLength, calcOffset(codeByteBuffer, labelCheckRedeemTxnSender)));
|
||||
// Message length didn't match - go back to finding another 'redeem' MESSAGE transaction
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop));
|
||||
|
||||
/* Check transaction's sender */
|
||||
labelCheckRedeemTxnSender = codeByteBuffer.position();
|
||||
|
||||
// Extract sender address from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer));
|
||||
// Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction.
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalPartnerAddress1, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalPartnerAddress2, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalPartnerAddress3, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalPartnerAddress4, calcOffset(codeByteBuffer, labelRedeemTxnLoop)));
|
||||
|
||||
/* Check 'secret-A' in transaction's message */
|
||||
|
||||
// Extract secret-A from first 32 bytes of message from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B));
|
||||
// Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer));
|
||||
// Load B register with expected hash result (as pointed to by addrHashOfSecretAPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretAPointer));
|
||||
// Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength).
|
||||
// Save the equality result (1 if they match, 0 otherwise) into addrResult.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength));
|
||||
// If hashes don't match, addrResult will be zero so go find another transaction
|
||||
codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckSecretB)));
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop));
|
||||
|
||||
/* Check 'secret-B' in transaction's message */
|
||||
|
||||
labelCheckSecretB = codeByteBuffer.position();
|
||||
|
||||
// Extract secret-B from next 32 bytes of message from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageSecretBOffset));
|
||||
// Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer));
|
||||
// Load B register with expected hash result (as pointed to by addrHashOfSecretBPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretBPointer));
|
||||
// Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength).
|
||||
// Save the equality result (1 if they match, 0 otherwise) into addrResult.
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength));
|
||||
// If hashes don't match, addrResult will be zero so go find another transaction
|
||||
codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout)));
|
||||
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop));
|
||||
|
||||
/* Success! Pay arranged amount to receiving address */
|
||||
labelPayout = codeByteBuffer.position();
|
||||
|
||||
// Extract Qortal receiving address from next 32 bytes of message from transaction into B register
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset));
|
||||
// Save B register into data segment starting at addrPartnerReceivingAddress (as pointed to by addrPartnerReceivingAddressPointer)
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerReceivingAddressPointer));
|
||||
// Pay AT's balance to receiving address
|
||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount));
|
||||
// Set redeemed mode
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REDEEMED.value));
|
||||
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||
|
||||
// Fall-through to refunding any remaining balance back to AT creator
|
||||
|
||||
/* Refund balance back to AT creator */
|
||||
labelRefund = codeByteBuffer.position();
|
||||
|
||||
// Set refunded mode
|
||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REFUNDED.value));
|
||||
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||
} catch (CompilationException e) {
|
||||
throw new IllegalStateException("Unable to compile BTC-QORT ACCT?", e);
|
||||
}
|
||||
}
|
||||
|
||||
codeByteBuffer.flip();
|
||||
|
||||
byte[] codeBytes = new byte[codeByteBuffer.limit()];
|
||||
codeByteBuffer.get(codeBytes);
|
||||
|
||||
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;
|
||||
final short numCallStackPages = 0;
|
||||
final short numUserStackPages = 0;
|
||||
final long minActivationAmount = 0L;
|
||||
|
||||
return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||
*/
|
||||
@Override
|
||||
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
||||
ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
|
||||
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||
*/
|
||||
@Override
|
||||
public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
|
||||
ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress());
|
||||
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||
*/
|
||||
public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException {
|
||||
byte[] addressBytes = new byte[25]; // for general use
|
||||
String atAddress = atStateData.getATAddress();
|
||||
|
||||
CrossChainTradeData tradeData = new CrossChainTradeData();
|
||||
|
||||
tradeData.foreignBlockchain = SupportedBlockchain.BITCOIN.name();
|
||||
tradeData.acctName = NAME;
|
||||
|
||||
tradeData.qortalAtAddress = atAddress;
|
||||
tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey);
|
||||
tradeData.creationTimestamp = creationTimestamp;
|
||||
|
||||
Account atAccount = new Account(repository, atAddress);
|
||||
tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT);
|
||||
|
||||
byte[] stateData = atStateData.getStateData();
|
||||
ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData);
|
||||
dataByteBuffer.position(MachineState.HEADER_LENGTH);
|
||||
|
||||
/* Constants */
|
||||
|
||||
// Skip creator's trade address
|
||||
dataByteBuffer.get(addressBytes);
|
||||
tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
|
||||
|
||||
// Creator's Bitcoin/foreign public key hash
|
||||
tradeData.creatorForeignPKH = new byte[20];
|
||||
dataByteBuffer.get(tradeData.creatorForeignPKH);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes
|
||||
|
||||
// Hash of secret B
|
||||
tradeData.hashOfSecretB = new byte[20];
|
||||
dataByteBuffer.get(tradeData.hashOfSecretB);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.hashOfSecretB.length); // skip to 32 bytes
|
||||
|
||||
// Redeem payout
|
||||
tradeData.qortAmount = dataByteBuffer.getLong();
|
||||
|
||||
// Expected BTC amount
|
||||
tradeData.expectedForeignAmount = dataByteBuffer.getLong();
|
||||
|
||||
// Trade timeout
|
||||
tradeData.tradeTimeout = (int) dataByteBuffer.getLong();
|
||||
|
||||
// Skip MESSAGE transaction type
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip expected 'trade' message length
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip expected 'redeem' message length
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to creator's address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to hash-of-secret-B
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to partner's Qortal trade address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to message sender
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip 'trade' message data offset for partner's bitcoin PKH
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to partner's bitcoin PKH
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip 'trade' message data offset for hash-of-secret-A
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to hash-of-secret-A
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip 'redeem' message data offset for secret-B
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip 'redeem' message data offset for partner's Qortal receiving address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to message data
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip message data length
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip pointer to partner's receiving address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
/* End of constants / begin variables */
|
||||
|
||||
// Skip AT creator's address
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
|
||||
|
||||
// Partner's trade address (if present)
|
||||
dataByteBuffer.get(addressBytes);
|
||||
String qortalRecipient = Base58.encode(addressBytes);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
|
||||
|
||||
// Potential lockTimeA (if in trade mode)
|
||||
int lockTimeA = (int) dataByteBuffer.getLong();
|
||||
|
||||
// Potential lockTimeB (if in trade mode)
|
||||
int lockTimeB = (int) dataByteBuffer.getLong();
|
||||
|
||||
// AT refund timeout (probably only useful for debugging)
|
||||
int refundTimeout = (int) dataByteBuffer.getLong();
|
||||
|
||||
// Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height)
|
||||
long tradeRefundTimestamp = dataByteBuffer.getLong();
|
||||
|
||||
// Skip last transaction timestamp
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip block timestamp
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip transaction type
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip temporary result
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip temporary message sender
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
|
||||
|
||||
// Skip message length
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||
|
||||
// Skip temporary message data
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
|
||||
|
||||
// Potential hash160 of secret A
|
||||
byte[] hashOfSecretA = new byte[20];
|
||||
dataByteBuffer.get(hashOfSecretA);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes
|
||||
|
||||
// Potential partner's Bitcoin PKH
|
||||
byte[] partnerBitcoinPKH = new byte[20];
|
||||
dataByteBuffer.get(partnerBitcoinPKH);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerBitcoinPKH.length); // skip to 32 bytes
|
||||
|
||||
// Partner's receiving address (if present)
|
||||
byte[] partnerReceivingAddress = new byte[25];
|
||||
dataByteBuffer.get(partnerReceivingAddress);
|
||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerReceivingAddress.length); // skip to 32 bytes
|
||||
|
||||
// Trade AT's 'mode'
|
||||
long modeValue = dataByteBuffer.getLong();
|
||||
AcctMode acctMode = AcctMode.valueOf((int) (modeValue & 0xffL));
|
||||
|
||||
/* End of variables */
|
||||
|
||||
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.partnerForeignPKH = partnerBitcoinPKH;
|
||||
tradeData.lockTimeA = lockTimeA;
|
||||
tradeData.lockTimeB = lockTimeB;
|
||||
|
||||
if (acctMode == AcctMode.REDEEMED)
|
||||
tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress);
|
||||
} else {
|
||||
tradeData.mode = AcctMode.OFFERING;
|
||||
}
|
||||
|
||||
tradeData.duplicateDeprecated();
|
||||
|
||||
return tradeData;
|
||||
}
|
||||
|
||||
/** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */
|
||||
public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) {
|
||||
byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA);
|
||||
return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes);
|
||||
}
|
||||
|
||||
/** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */
|
||||
public static OfferMessageData extractOfferMessageData(byte[] messageData) {
|
||||
if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH)
|
||||
return null;
|
||||
|
||||
OfferMessageData offerMessageData = new OfferMessageData();
|
||||
offerMessageData.partnerBitcoinPKH = Arrays.copyOfRange(messageData, 0, 20);
|
||||
offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40);
|
||||
offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40);
|
||||
|
||||
return offerMessageData;
|
||||
}
|
||||
|
||||
/** Returns 'trade' MESSAGE payload for AT creator to send to AT. */
|
||||
public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int lockTimeB) {
|
||||
byte[] data = new byte[TRADE_MESSAGE_LENGTH];
|
||||
byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress);
|
||||
byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA);
|
||||
byte[] lockTimeBBytes = BitTwiddling.toBEByteArray((long) lockTimeB);
|
||||
|
||||
System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length);
|
||||
System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length);
|
||||
System.arraycopy(lockTimeBBytes, 0, data, 56, lockTimeBBytes.length);
|
||||
System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length);
|
||||
System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */
|
||||
@Override
|
||||
public byte[] buildCancelMessage(String creatorQortalAddress) {
|
||||
byte[] data = new byte[CANCEL_MESSAGE_LENGTH];
|
||||
byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress);
|
||||
|
||||
System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Returns 'redeem' MESSAGE payload for trade partner/ to send to AT. */
|
||||
public static byte[] buildRedeemMessage(byte[] secretA, byte[] secretB, String qortalReceivingAddress) {
|
||||
byte[] data = new byte[REDEEM_MESSAGE_LENGTH];
|
||||
byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress);
|
||||
|
||||
System.arraycopy(secretA, 0, data, 0, secretA.length);
|
||||
System.arraycopy(secretB, 0, data, 32, secretB.length);
|
||||
System.arraycopy(qortalReceivingAddressBytes, 0, data, 64, qortalReceivingAddressBytes.length);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 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 offerMessageTimestamp and lockTimeA
|
||||
return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L);
|
||||
}
|
||||
|
||||
public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
|
||||
String atAddress = crossChainTradeData.qortalAtAddress;
|
||||
String redeemerAddress = crossChainTradeData.qortalPartnerAddress;
|
||||
|
||||
// We don't have partner's public key so we check every message to AT
|
||||
List<MessageTransactionData> messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, atAddress, null, null, null);
|
||||
if (messageTransactionsData == null)
|
||||
return null;
|
||||
|
||||
// Find 'redeem' message
|
||||
for (MessageTransactionData messageTransactionData : messageTransactionsData) {
|
||||
// Check message payload type/encryption
|
||||
if (messageTransactionData.isText() || messageTransactionData.isEncrypted())
|
||||
continue;
|
||||
|
||||
// Check message payload size
|
||||
byte[] messageData = messageTransactionData.getData();
|
||||
if (messageData.length != REDEEM_MESSAGE_LENGTH)
|
||||
// Wrong payload length
|
||||
continue;
|
||||
|
||||
// Check sender
|
||||
if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress))
|
||||
// Wrong sender;
|
||||
continue;
|
||||
|
||||
// Extract both secretA & secretB
|
||||
byte[] secretA = new byte[32];
|
||||
System.arraycopy(messageData, 0, secretA, 0, secretA.length);
|
||||
byte[] secretB = new byte[32];
|
||||
System.arraycopy(messageData, 32, secretB, 0, secretB.length);
|
||||
|
||||
byte[] hashOfSecretA = Crypto.hash160(secretA);
|
||||
if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA))
|
||||
continue;
|
||||
|
||||
byte[] hashOfSecretB = Crypto.hash160(secretB);
|
||||
if (!Arrays.equals(hashOfSecretB, crossChainTradeData.hashOfSecretB))
|
||||
continue;
|
||||
|
||||
return secretA;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
706
src/main/java/org/qortal/crosschain/Bitcoiny.java
Normal file
706
src/main/java/org/qortal/crosschain/Bitcoiny.java
Normal file
@@ -0,0 +1,706 @@
|
||||
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<BitcoinyTransaction> 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<>();
|
||||
|
||||
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);
|
||||
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<BitcoinyTransaction> newestTimestampFirstComparator = Comparator.comparingInt((BitcoinyTransaction txn) -> txn.timestamp).reversed();
|
||||
|
||||
return walletTransactions.stream().sorted(newestTimestampFirstComparator).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.Base58;
|
||||
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 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);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user