mirror of
https://github.com/Qortal/qortal.git
synced 2025-07-30 05:31:23 +00:00
Compare commits
528 Commits
v1.0.2
...
block-rewa
Author | SHA1 | Date | |
---|---|---|---|
|
16453ed602 | ||
|
fde68dc598 | ||
|
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 | ||
|
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 | ||
|
e05fcd6655 | ||
|
3a7751910e | ||
|
3c139f3e53 | ||
|
2c14a12464 | ||
|
faa6405d5f | ||
|
e2e4555009 | ||
|
448e984995 | ||
|
ec1954bae1 | ||
|
66276a6f65 | ||
|
c00ab2f87c | ||
|
99f3ab9921 | ||
|
75b15c6639 | ||
|
e5e60a5032 | ||
|
b9d2bbb78b | ||
|
3d79408574 | ||
|
67b184acc9 | ||
|
11040ae60a | ||
|
a338202ded | ||
|
e0398490ae | ||
|
847093edac | ||
|
758a42db36 | ||
|
c2b253df55 | ||
|
cc3adc6720 | ||
|
d77acd9eb9 | ||
|
5ad2bc1940 | ||
|
ea4c51026b | ||
|
d0b4a1f12f | ||
|
50b912e229 | ||
|
5ffddd0169 | ||
|
b5512dfa91 | ||
|
2493d5f7a8 | ||
|
bef1828404 | ||
|
0ae232b8ba | ||
|
cdf0795881 | ||
|
31e85226f4 | ||
|
8df3c68df9 | ||
|
ca0deb2bf6 | ||
|
6eea7c2aa1 | ||
|
9aabf93523 | ||
|
322e2cdc41 | ||
|
df395c77db | ||
|
274002c473 | ||
|
3d4fc38fcb | ||
|
d50f16b8a9 | ||
|
59de22883b | ||
|
db73afaf88 | ||
|
3afbd7aa51 | ||
|
0c32afa07f | ||
|
bd543a526b | ||
|
b262044a52 | ||
|
200a97184c | ||
|
0164bca2d7 | ||
|
5f4b66e5b0 | ||
|
9c48343581 | ||
|
219f82f562 | ||
|
51bfd49e25 | ||
|
7102f4a727 | ||
|
28991a926f | ||
|
74f89af841 | ||
|
b4284515e7 | ||
|
032c5d0d07 | ||
|
72100fe1d8 | ||
|
ed178e744d | ||
|
94f7079c2e | ||
|
f1638aa9d9 | ||
|
a7b9215ace | ||
|
956ad7bfa8 | ||
|
4baf442cb8 | ||
|
24eb7c6933 | ||
|
38a2af8cd5 | ||
|
7447ab20a9 | ||
|
197c742ce7 | ||
|
f6ed3388a4 | ||
|
c61690f3e6 | ||
|
9a94873d0e | ||
|
5c8bda37d1 | ||
|
fbb73ee88e | ||
|
fa08041696 | ||
|
f01a34a461 | ||
|
c0242fe78b | ||
|
ef790a8cb1 | ||
|
cea0cee9a8 | ||
|
d9f784ed2b | ||
|
f29ae656b9 | ||
|
a9852e5305 | ||
|
32470fa641 | ||
|
0d1c08bf96 | ||
|
026c904ce4 | ||
|
59ae070c83 | ||
|
2ab695f308 | ||
|
f0ff77cd31 | ||
|
e241d9fa67 | ||
|
5e9b0cd03c | ||
|
a5c437913f | ||
|
3fa7da5115 | ||
|
6d8f41ab05 | ||
|
c7c419a3cd | ||
|
3094ec3c26 | ||
|
359a35931e | ||
|
9e0001c4f6 | ||
|
53112709fe | ||
|
d1bc500ab9 | ||
|
74b5401e84 | ||
|
d2559f36ce | ||
|
0cc9cd728e | ||
|
e5cf76f3e0 | ||
|
44e8b3e6e7 | ||
|
1bca152d9c | ||
|
4edc3ee121 | ||
|
e9f29767c8 | ||
|
e2916b130b | ||
|
538e117abd | ||
|
71e80bd02f | ||
|
800103225b | ||
|
cfb7a3cc4c | ||
|
3185cf23df | ||
|
3ac1b36549 | ||
|
55e99062ca | ||
|
edb56b74da | ||
|
233ace23de | ||
|
e1f3b9a7a3 | ||
|
6be88ac86e | ||
|
d03cca2e76 | ||
|
e86143426b | ||
|
a309f8de9e | ||
|
df15f81b9f | ||
|
6ab50e4dff | ||
|
476d9e4c95 | ||
|
9eaf31707a | ||
|
e0007269b9 | ||
|
0006911e0a | ||
|
e141e98ecc | ||
|
40531284dd | ||
|
9e2663b11b | ||
|
2602bb01e1 | ||
|
ac15dfe789 | ||
|
cd066cf357 | ||
|
bd521baade | ||
|
136188339d | ||
|
48de33fe24 | ||
|
df4798e2a1 | ||
|
edb842f0d1 | ||
|
b563fe567d | ||
|
b3dd0d89df | ||
|
360f6cd4f1 | ||
|
f1e4528581 | ||
|
1375372380 | ||
|
a7d0ad27b1 | ||
|
833a785996 | ||
|
94d18538d8 | ||
|
8baf42765e | ||
|
b93dca1818 | ||
|
98506a038b | ||
|
3eaeb927ec | ||
|
7ded8954c6 | ||
|
d2eb8b0c2b | ||
|
2ed2cc0fab | ||
|
87bb9090f5 | ||
|
8844cc0076 | ||
|
2c4bad6455 | ||
|
5c0134c16a | ||
|
369a45f5c0 | ||
|
d58b7c1f53 | ||
|
5011a2be22 | ||
|
33010f82d8 | ||
|
8dbd8c4e65 | ||
|
c2a3c1271c | ||
|
1e9a7ac87d | ||
|
e25d24964c | ||
|
d90d84ab06 | ||
|
2ddb1fa23e | ||
|
82f6e38adb | ||
|
00ac26cf27 | ||
|
fa1aa1c8b2 | ||
|
9156325ffc | ||
|
70131914b2 | ||
|
bd87e6cc1a | ||
|
6c8e96daae | ||
|
cfb8f53849 | ||
|
7bb2f841ad | ||
|
558263521c | ||
|
1db8c06291 | ||
|
edee08a7b5 | ||
|
0594bdf1c7 | ||
|
72c299a331 | ||
|
0b42a7ad63 | ||
|
51e59f6ab7 | ||
|
38394de661 | ||
|
22f9755f4f | ||
|
4cb2e113cb | ||
|
e0f024ef5c | ||
|
f95cb99cdc | ||
|
1f0170bb4b | ||
|
5eafdf3c80 | ||
|
d7c26c27e1 | ||
|
2d18dd62eb | ||
|
51fd177d79 | ||
|
c4643538f1 | ||
|
0edadaf901 | ||
|
c05533fb71 | ||
|
db270f559f | ||
|
79f7f68b0c | ||
|
d30d61edab | ||
|
f7e2ee383e | ||
|
544fdbfbe9 | ||
|
c3d1ecb7e1 | ||
|
873a9d0cee | ||
|
95cb5f607b | ||
|
54d0b721c4 | ||
|
4a4678b331 | ||
|
12f9ecaaca | ||
|
1d3ee77fb8 | ||
|
83bce3ce52 | ||
|
bf288dbfc2 | ||
|
64055e280d | ||
|
90e0f9dddc | ||
|
b0b0e2ac18 |
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.
1272
WindowsInstaller/Qortal.aip
Executable file
1272
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 |
BIN
lib/org/ciyam/AT/1.3.7/AT-1.3.7.jar
Normal file
BIN
lib/org/ciyam/AT/1.3.7/AT-1.3.7.jar
Normal file
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.ciyam</groupId>
|
||||
<artifactId>at</artifactId>
|
||||
<version>1.0</version>
|
||||
<artifactId>AT</artifactId>
|
||||
<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>
|
16
lib/org/ciyam/AT/maven-metadata-local.xml
Normal file
16
lib/org/ciyam/AT/maven-metadata-local.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>org.ciyam</groupId>
|
||||
<artifactId>AT</artifactId>
|
||||
<versioning>
|
||||
<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>20200925114415</lastUpdated>
|
||||
</versioning>
|
||||
</metadata>
|
Binary file not shown.
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>org.ciyam</groupId>
|
||||
<artifactId>at</artifactId>
|
||||
<versioning>
|
||||
<release>1.0</release>
|
||||
<versions>
|
||||
<version>1.0</version>
|
||||
</versions>
|
||||
<lastUpdated>20181105100741</lastUpdated>
|
||||
</versioning>
|
||||
</metadata>
|
BIN
lib/org/hsqldb/hsqldb/2.5.0-fixed/hsqldb-2.5.0-fixed-sources.jar
Normal file
BIN
lib/org/hsqldb/hsqldb/2.5.0-fixed/hsqldb-2.5.0-fixed-sources.jar
Normal file
Binary file not shown.
BIN
lib/org/hsqldb/hsqldb/2.5.0-fixed/hsqldb-2.5.0-fixed.jar
Normal file
BIN
lib/org/hsqldb/hsqldb/2.5.0-fixed/hsqldb-2.5.0-fixed.jar
Normal file
Binary file not shown.
9
lib/org/hsqldb/hsqldb/2.5.0-fixed/hsqldb-2.5.0-fixed.pom
Normal file
9
lib/org/hsqldb/hsqldb/2.5.0-fixed/hsqldb-2.5.0-fixed.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.hsqldb</groupId>
|
||||
<artifactId>hsqldb</artifactId>
|
||||
<version>2.5.0-fixed</version>
|
||||
<description>POM was created from install:install-file</description>
|
||||
</project>
|
12
lib/org/hsqldb/hsqldb/maven-metadata-local.xml
Normal file
12
lib/org/hsqldb/hsqldb/maven-metadata-local.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>org.hsqldb</groupId>
|
||||
<artifactId>hsqldb</artifactId>
|
||||
<versioning>
|
||||
<release>2.5.0-fixed</release>
|
||||
<versions>
|
||||
<version>2.5.0-fixed</version>
|
||||
</versions>
|
||||
<lastUpdated>20200318133132</lastUpdated>
|
||||
</versioning>
|
||||
</metadata>
|
@@ -1,11 +1,15 @@
|
||||
rootLogger.level = info
|
||||
# On Windows, uncomment this:
|
||||
# property.dirname = ${sys:user.home}\\AppData\\Roaming\\qortal\\
|
||||
# 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
|
||||
|
67
pom.xml
67
pom.xml
@@ -3,20 +3,22 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.qortal</groupId>
|
||||
<artifactId>qortal</artifactId>
|
||||
<version>1.0</version>
|
||||
<version>1.4.3</version>
|
||||
<packaging>jar</packaging>
|
||||
<properties>
|
||||
<bitcoin.version>0.15.4</bitcoin.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.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</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>
|
||||
@@ -198,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>
|
||||
@@ -257,6 +263,8 @@
|
||||
<!-- Don't include original swagger-UI as we're including our own
|
||||
modified version -->
|
||||
<exclude>org.webjars:swagger-ui</exclude>
|
||||
<!-- Don't include JUnit as it's for testing only! -->
|
||||
<exclude>junit:junit</exclude>
|
||||
</excludes>
|
||||
</artifactSet>
|
||||
<filters>
|
||||
@@ -310,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>
|
||||
@@ -372,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 -->
|
||||
@@ -379,12 +400,14 @@
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>build-helper-maven-plugin</artifactId>
|
||||
<version>3.0.0</version>
|
||||
<scope>provided</scope><!-- needed for build, not for runtime -->
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/com.github.bohnman/package-info-maven-plugin -->
|
||||
<dependency>
|
||||
<groupId>com.github.bohnman</groupId>
|
||||
<artifactId>package-info-maven-plugin</artifactId>
|
||||
<version>${package-info-maven-plugin.version}</version>
|
||||
<scope>provided</scope><!-- needed for build, not for runtime -->
|
||||
</dependency>
|
||||
<!-- HSQLDB for repository -->
|
||||
<dependency>
|
||||
@@ -392,23 +415,23 @@
|
||||
<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>
|
||||
<artifactId>at</artifactId>
|
||||
<version>1.0</version>
|
||||
<artifactId>AT</artifactId>
|
||||
<version>${ciyam-at.version}</version>
|
||||
</dependency>
|
||||
<!-- Bitcoin support -->
|
||||
<dependency>
|
||||
<groupId>org.bitcoinj</groupId>
|
||||
<artifactId>bitcoinj-core</artifactId>
|
||||
<version>${bitcoin.version}</version>
|
||||
<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>
|
||||
@@ -446,6 +469,10 @@
|
||||
<groupId>org.asynchttpclient</groupId>
|
||||
<artifactId>async-http-client</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>io.druid</groupId>
|
||||
<artifactId>java-util</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<!-- For NTP -->
|
||||
@@ -499,6 +526,12 @@
|
||||
<artifactId>mail</artifactId>
|
||||
<version>1.5.0-b01</version>
|
||||
</dependency>
|
||||
<!-- Unicode homoglyph utilities -->
|
||||
<dependency>
|
||||
<groupId>net.codebox</groupId>
|
||||
<artifactId>homoglyph</artifactId>
|
||||
<version>1.2.0</version>
|
||||
</dependency>
|
||||
<!-- Jetty -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
@@ -527,6 +560,12 @@
|
||||
<artifactId>jetty-client</artifactId>
|
||||
<version>${jetty.version}</version>
|
||||
</dependency>
|
||||
<!-- Websocket support -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.websocket</groupId>
|
||||
<artifactId>javax-websocket-server-impl</artifactId>
|
||||
<version>${jetty.version}</version>
|
||||
</dependency>
|
||||
<!-- Jersey -->
|
||||
<dependency>
|
||||
<groupId>org.glassfish.jersey.core</groupId>
|
||||
|
34
run.sh
34
run.sh
@@ -1,34 +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
|
||||
|
||||
# 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,9 +35,11 @@ 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 = 5 * 1000L; // ms
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private static final long CHECK_INTERVAL = 10 * 1000L; // ms
|
||||
private static final int MAX_ATTEMPTS = 12;
|
||||
|
||||
public static void main(String[] args) {
|
||||
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -1,7 +1,9 @@
|
||||
package org.qortal.account;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import static org.qortal.utils.Amounts.prettyAmount;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -9,12 +11,11 @@ import org.qortal.block.BlockChain;
|
||||
import org.qortal.data.account.AccountBalanceData;
|
||||
import org.qortal.data.account.AccountData;
|
||||
import org.qortal.data.account.RewardShareData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.NONE) // Stops JAX-RS errors when unmarshalling blockchain config
|
||||
public class Account {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(Account.class);
|
||||
@@ -51,39 +52,52 @@ public class Account {
|
||||
return new AccountData(this.address);
|
||||
}
|
||||
|
||||
public void ensureAccount() throws DataException {
|
||||
this.repository.getAccountRepository().ensureAccount(this.buildAccountData());
|
||||
}
|
||||
|
||||
// Balance manipulations - assetId is 0 for QORT
|
||||
|
||||
public BigDecimal getBalance(long assetId, int height) throws DataException {
|
||||
AccountBalanceData accountBalanceData = this.repository.getAccountRepository().getBalance(this.address, assetId, height);
|
||||
if (accountBalanceData == null)
|
||||
return BigDecimal.ZERO.setScale(8);
|
||||
|
||||
return accountBalanceData.getBalance();
|
||||
}
|
||||
|
||||
public BigDecimal getConfirmedBalance(long assetId) throws DataException {
|
||||
public long getConfirmedBalance(long assetId) throws DataException {
|
||||
AccountBalanceData accountBalanceData = this.repository.getAccountRepository().getBalance(this.address, assetId);
|
||||
if (accountBalanceData == null)
|
||||
return BigDecimal.ZERO.setScale(8);
|
||||
return 0;
|
||||
|
||||
return accountBalanceData.getBalance();
|
||||
}
|
||||
|
||||
public void setConfirmedBalance(long assetId, BigDecimal balance) throws DataException {
|
||||
public void setConfirmedBalance(long assetId, long balance) throws DataException {
|
||||
// Safety feature!
|
||||
if (balance.compareTo(BigDecimal.ZERO) < 0) {
|
||||
String message = String.format("Refusing to set negative balance %s [assetId %d] for %s", balance.toPlainString(), assetId, this.address);
|
||||
if (balance < 0) {
|
||||
String message = String.format("Refusing to set negative balance %s [assetId %d] for %s", prettyAmount(balance), assetId, this.address);
|
||||
LOGGER.error(message);
|
||||
throw new DataException(message);
|
||||
}
|
||||
|
||||
// Delete account balance record instead of setting balance to zero
|
||||
if (balance == 0) {
|
||||
this.repository.getAccountRepository().delete(this.address, assetId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't have a balance without an account - make sure it exists!
|
||||
this.repository.getAccountRepository().ensureAccount(this.buildAccountData());
|
||||
this.ensureAccount();
|
||||
|
||||
AccountBalanceData accountBalanceData = new AccountBalanceData(this.address, assetId, balance);
|
||||
this.repository.getAccountRepository().save(accountBalanceData);
|
||||
|
||||
LOGGER.trace(() -> String.format("%s balance now %s [assetId %s]", this.address, balance.toPlainString(), assetId));
|
||||
LOGGER.trace(() -> String.format("%s balance now %s [assetId %s]", this.address, prettyAmount(balance), assetId));
|
||||
}
|
||||
|
||||
// Convenience method
|
||||
public void modifyAssetBalance(long assetId, long deltaBalance) throws DataException {
|
||||
this.repository.getAccountRepository().modifyAssetBalance(this.getAddress(), assetId, deltaBalance);
|
||||
|
||||
LOGGER.trace(() -> String.format("%s balance %s by %s [assetId %s]",
|
||||
this.address,
|
||||
(deltaBalance >= 0 ? "increased" : "decreased"),
|
||||
prettyAmount(Math.abs(deltaBalance)),
|
||||
assetId));
|
||||
}
|
||||
|
||||
public void deleteBalance(long assetId) throws DataException {
|
||||
@@ -99,38 +113,11 @@ public class Account {
|
||||
* @throws DataException
|
||||
*/
|
||||
public byte[] getLastReference() throws DataException {
|
||||
byte[] reference = this.repository.getAccountRepository().getLastReference(this.address);
|
||||
byte[] reference = AccountRefCache.getLastReference(this.repository, this.address);
|
||||
LOGGER.trace(() -> String.format("Last reference for %s is %s", this.address, reference == null ? "null" : Base58.encode(reference)));
|
||||
return reference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch last reference for account, considering unconfirmed transactions only, or return null.
|
||||
* <p>
|
||||
* NOTE: calls Transaction.getUnconfirmedTransactions which discards uncommitted
|
||||
* repository changes.
|
||||
*
|
||||
* @return byte[] reference, or null if no unconfirmed transactions for this account.
|
||||
* @throws DataException
|
||||
*/
|
||||
public byte[] getUnconfirmedLastReference() throws DataException {
|
||||
// Newest unconfirmed transaction takes priority
|
||||
List<TransactionData> unconfirmedTransactions = Transaction.getUnconfirmedTransactions(repository);
|
||||
|
||||
byte[] reference = null;
|
||||
|
||||
for (TransactionData transactionData : unconfirmedTransactions) {
|
||||
String unconfirmedTransactionAddress = PublicKeyAccount.getAddress(transactionData.getCreatorPublicKey());
|
||||
|
||||
if (unconfirmedTransactionAddress.equals(this.address))
|
||||
reference = transactionData.getSignature();
|
||||
}
|
||||
|
||||
final byte[] loggingReference = reference;
|
||||
LOGGER.trace(() -> String.format("Last unconfirmed reference for %s is %s", this.address, loggingReference == null ? "null" : Base58.encode(loggingReference)));
|
||||
return reference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set last reference for account.
|
||||
*
|
||||
@@ -143,7 +130,7 @@ public class Account {
|
||||
|
||||
AccountData accountData = this.buildAccountData();
|
||||
accountData.setReference(reference);
|
||||
this.repository.getAccountRepository().setLastReference(accountData);
|
||||
AccountRefCache.setLastReference(this.repository, accountData);
|
||||
}
|
||||
|
||||
// Default groupID manipulations
|
||||
@@ -204,11 +191,15 @@ public class Account {
|
||||
* @throws DataException
|
||||
*/
|
||||
public boolean canMint() throws DataException {
|
||||
Integer level = this.getLevel();
|
||||
AccountData accountData = this.repository.getAccountRepository().getAccount(this.address);
|
||||
if (accountData == null)
|
||||
return false;
|
||||
|
||||
Integer level = accountData.getLevel();
|
||||
if (level != null && level >= BlockChain.getInstance().getMinAccountLevelToMint())
|
||||
return true;
|
||||
|
||||
if (this.isFounder())
|
||||
if (Account.isFounder(accountData.getFlags()))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
@@ -226,11 +217,15 @@ public class Account {
|
||||
* @throws DataException
|
||||
*/
|
||||
public boolean canRewardShare() throws DataException {
|
||||
Integer level = this.getLevel();
|
||||
AccountData accountData = this.repository.getAccountRepository().getAccount(this.address);
|
||||
if (accountData == null)
|
||||
return false;
|
||||
|
||||
Integer level = accountData.getLevel();
|
||||
if (level != null && level >= BlockChain.getInstance().getMinAccountLevelToRewardShare())
|
||||
return true;
|
||||
|
||||
if (this.isFounder())
|
||||
if (Account.isFounder(accountData.getFlags()))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
@@ -264,14 +259,14 @@ public class Account {
|
||||
* @throws DataException
|
||||
*/
|
||||
public int getEffectiveMintingLevel() throws DataException {
|
||||
if (this.isFounder())
|
||||
return BlockChain.getInstance().getFounderEffectiveMintingLevel();
|
||||
|
||||
Integer level = this.getLevel();
|
||||
if (level == null)
|
||||
AccountData accountData = this.repository.getAccountRepository().getAccount(this.address);
|
||||
if (accountData == null)
|
||||
return 0;
|
||||
|
||||
return level;
|
||||
if (Account.isFounder(accountData.getFlags()))
|
||||
return BlockChain.getInstance().getFounderEffectiveMintingLevel();
|
||||
|
||||
return accountData.getLevel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,7 +285,7 @@ public class Account {
|
||||
if (rewardShareData == null)
|
||||
return 0;
|
||||
|
||||
PublicKeyAccount rewardShareMinter = new PublicKeyAccount(repository, rewardShareData.getMinterPublicKey());
|
||||
Account rewardShareMinter = new Account(repository, rewardShareData.getMinter());
|
||||
return rewardShareMinter.getEffectiveMintingLevel();
|
||||
}
|
||||
|
||||
|
217
src/main/java/org/qortal/account/AccountRefCache.java
Normal file
217
src/main/java/org/qortal/account/AccountRefCache.java
Normal file
@@ -0,0 +1,217 @@
|
||||
package org.qortal.account;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.function.BinaryOperator;
|
||||
|
||||
import org.qortal.data.account.AccountData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.utils.Pair;
|
||||
|
||||
/**
|
||||
* Account lastReference caching
|
||||
* <p>
|
||||
* When checking an account's lastReference, the value returned should be the
|
||||
* most recent value set after processing the most recent block.
|
||||
* <p>
|
||||
* However, when processing a batch of transactions, e.g. during block processing or validation,
|
||||
* each transaction needs to check, and maybe update, multiple accounts' lastReference values.
|
||||
* <p>
|
||||
* Because the intermediate updates would affect future checks, we set up a cache of that
|
||||
* maintains a consistent value for fetching lastReference, but also tracks the latest new
|
||||
* value, without the overhead of repository calls.
|
||||
* <p>
|
||||
* Thus, when batch transaction processing is finished, only the latest new lastReference values
|
||||
* can be committed to the repository, via {@link AccountRefCache#commit()}.
|
||||
* <p>
|
||||
* Getting and setting lastReferences values are done the usual way via
|
||||
* {@link Account#getLastReference()} and {@link Account#setLastReference(byte[])} which call
|
||||
* package-visibility methods in <tt>AccountRefCache</tt>.
|
||||
* <p>
|
||||
* If {@link Account#getLastReference()} or {@link Account#setLastReference(byte[])} are called
|
||||
* outside of caching then lastReference values are fetched/set directly from/to the repository.
|
||||
* <p>
|
||||
* <tt>AccountRefCache</tt> implements <tt>AutoCloseable</tt> for (typical) use in a try-with-resources block.
|
||||
*
|
||||
* @see Account#getLastReference()
|
||||
* @see Account#setLastReference(byte[])
|
||||
* @see org.qortal.block.Block#process()
|
||||
*/
|
||||
public class AccountRefCache implements AutoCloseable {
|
||||
|
||||
private static final Map<Repository, RefCache> CACHE = new HashMap<>();
|
||||
|
||||
private static class RefCache {
|
||||
private final Map<String, byte[]> getLastReferenceValues = new HashMap<>();
|
||||
private final Map<String, Pair<byte[], byte[]>> setLastReferenceValues = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Function for merging publicKey from new data with old publicKey from map.
|
||||
* <p>
|
||||
* Last reference is <tt>A</tt> element in pair.<br>
|
||||
* Public key is <tt>B</tt> element in pair.
|
||||
*/
|
||||
private static final BinaryOperator<Pair<byte[], byte[]>> mergePublicKey = (oldPair, newPair) -> {
|
||||
// If passed new pair contains non-null publicKey, then we use that one in preference.
|
||||
if (newPair.getB() == null)
|
||||
// Otherwise, inherit publicKey from old map value.
|
||||
newPair.setB(oldPair.getB());
|
||||
|
||||
// We always use new lastReference from new pair.
|
||||
return newPair;
|
||||
};
|
||||
|
||||
|
||||
public byte[] getLastReference(Repository repository, String address) throws DataException {
|
||||
synchronized (this.getLastReferenceValues) {
|
||||
byte[] lastReference = getLastReferenceValues.get(address);
|
||||
if (lastReference != null)
|
||||
// address is present in map, lastReference not null
|
||||
return lastReference;
|
||||
|
||||
// address is present in map, just lastReference is null
|
||||
if (getLastReferenceValues.containsKey(address))
|
||||
return null;
|
||||
|
||||
lastReference = repository.getAccountRepository().getLastReference(address);
|
||||
this.getLastReferenceValues.put(address, lastReference);
|
||||
return lastReference;
|
||||
}
|
||||
}
|
||||
|
||||
public void setLastReference(AccountData accountData) {
|
||||
// We're only interested in lastReference and publicKey
|
||||
Pair<byte[], byte[]> newPair = new Pair<>(accountData.getReference(), accountData.getPublicKey());
|
||||
|
||||
synchronized (this.setLastReferenceValues) {
|
||||
setLastReferenceValues.merge(accountData.getAddress(), newPair, mergePublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Pair<byte[], byte[]>> getNewLastReferences() {
|
||||
return setLastReferenceValues;
|
||||
}
|
||||
}
|
||||
|
||||
private Repository repository;
|
||||
|
||||
/**
|
||||
* Constructs a new account reference cache, unique to passed <tt>repository</tt> handle.
|
||||
*
|
||||
* @param repository
|
||||
* @throws IllegalStateException if a cache already exists for <tt>repository</tt>
|
||||
*/
|
||||
public AccountRefCache(Repository repository) {
|
||||
RefCache refCache = new RefCache();
|
||||
|
||||
synchronized (CACHE) {
|
||||
if (CACHE.putIfAbsent(repository, refCache) != null)
|
||||
throw new IllegalStateException("Account reference cache entry already exists");
|
||||
}
|
||||
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all cached setLastReference account-reference values into repository.
|
||||
* <p>
|
||||
* Closes cache to prevent any future setLastReference() attempts post-commit.
|
||||
*
|
||||
* @throws DataException
|
||||
*/
|
||||
public void commit() throws DataException {
|
||||
RefCache refCache;
|
||||
|
||||
// Also duplicated in close(), this prevents future setLastReference() attempts post-commit.
|
||||
synchronized (CACHE) {
|
||||
refCache = CACHE.remove(this.repository);
|
||||
}
|
||||
|
||||
if (refCache == null)
|
||||
throw new IllegalStateException("Tried to commit non-existent account reference cache");
|
||||
|
||||
Map<String, Pair<byte[], byte[]>> newLastReferenceValues = refCache.getNewLastReferences();
|
||||
|
||||
for (Entry<String, Pair<byte[], byte[]>> entry : newLastReferenceValues.entrySet()) {
|
||||
AccountData accountData = new AccountData(entry.getKey());
|
||||
|
||||
accountData.setReference(entry.getValue().getA());
|
||||
|
||||
if (entry.getValue().getB() != null)
|
||||
accountData.setPublicKey(entry.getValue().getB());
|
||||
|
||||
this.repository.getAccountRepository().setLastReference(accountData);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (CACHE) {
|
||||
CACHE.remove(this.repository);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns lastReference value for account.
|
||||
* <p>
|
||||
* If cache is not in effect for passed <tt>repository</tt> handle,
|
||||
* then this method fetches lastReference directly from repository.
|
||||
* <p>
|
||||
* If cache <i>is</i> in effect, then this method returns cached
|
||||
* lastReference, which is <b>not</b> affected by calls to
|
||||
* <tt>setLastReference</tt>.
|
||||
* <p>
|
||||
* Typically called by corresponding method in Account class.
|
||||
*
|
||||
* @param repository
|
||||
* @param address account's address
|
||||
* @return account's lastReference, or null if account unknown, or lastReference not set
|
||||
* @throws DataException
|
||||
*/
|
||||
/*package*/ static byte[] getLastReference(Repository repository, String address) throws DataException {
|
||||
RefCache refCache;
|
||||
|
||||
synchronized (CACHE) {
|
||||
refCache = CACHE.get(repository);
|
||||
}
|
||||
|
||||
if (refCache == null)
|
||||
return repository.getAccountRepository().getLastReference(address);
|
||||
|
||||
return refCache.getLastReference(repository, address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets lastReference value for account.
|
||||
* <p>
|
||||
* If cache is not in effect for passed <tt>repository</tt> handle,
|
||||
* then this method sets lastReference directly in repository.
|
||||
* <p>
|
||||
* If cache <i>is</i> in effect, then this method caches the new
|
||||
* lastReference, which is <b>not</b> returned by calls to
|
||||
* <tt>getLastReference</tt>.
|
||||
* <p>
|
||||
* Typically called by corresponding method in Account class.
|
||||
*
|
||||
* @param repository
|
||||
* @param accountData
|
||||
* @throws DataException
|
||||
*/
|
||||
/*package*/ static void setLastReference(Repository repository, AccountData accountData) throws DataException {
|
||||
RefCache refCache;
|
||||
|
||||
synchronized (CACHE) {
|
||||
refCache = CACHE.get(repository);
|
||||
}
|
||||
|
||||
if (refCache == null) {
|
||||
repository.getAccountRepository().setLastReference(accountData);
|
||||
return;
|
||||
}
|
||||
|
||||
refCache.setLastReference(accountData);
|
||||
}
|
||||
|
||||
}
|
@@ -1,13 +0,0 @@
|
||||
package org.qortal.account;
|
||||
|
||||
import org.qortal.repository.Repository;
|
||||
|
||||
public final class GenesisAccount extends PublicKeyAccount {
|
||||
|
||||
public static final byte[] PUBLIC_KEY = new byte[] { 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
|
||||
|
||||
public GenesisAccount(Repository repository) {
|
||||
super(repository, PUBLIC_KEY);
|
||||
}
|
||||
|
||||
}
|
24
src/main/java/org/qortal/account/NullAccount.java
Normal file
24
src/main/java/org/qortal/account/NullAccount.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package org.qortal.account;
|
||||
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.repository.Repository;
|
||||
|
||||
public final class NullAccount extends PublicKeyAccount {
|
||||
|
||||
public static final byte[] PUBLIC_KEY = new byte[32];
|
||||
public static final String ADDRESS = Crypto.toAddress(PUBLIC_KEY);
|
||||
|
||||
public NullAccount(Repository repository) {
|
||||
super(repository, PUBLIC_KEY, ADDRESS);
|
||||
}
|
||||
|
||||
protected NullAccount() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verify(byte[] signature, byte[] message) {
|
||||
// Can't sign, hence can't verify.
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
@@ -2,18 +2,11 @@ package org.qortal.account;
|
||||
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters;
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
|
||||
import org.bouncycastle.crypto.params.X25519PrivateKeyParameters;
|
||||
import org.bouncycastle.crypto.params.X25519PublicKeyParameters;
|
||||
import org.bouncycastle.math.ec.rfc8032.Ed25519;
|
||||
import org.qortal.crypto.BouncyCastle25519;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.repository.Repository;
|
||||
|
||||
public class PrivateKeyAccount extends PublicKeyAccount {
|
||||
|
||||
private static final int SIGNATURE_LENGTH = 64;
|
||||
private static final int SHARED_SECRET_LENGTH = 32;
|
||||
|
||||
private final byte[] privateKey;
|
||||
private final Ed25519PrivateKeyParameters edPrivateKeyParams;
|
||||
|
||||
@@ -49,24 +42,11 @@ public class PrivateKeyAccount extends PublicKeyAccount {
|
||||
}
|
||||
|
||||
public byte[] sign(byte[] message) {
|
||||
byte[] signature = new byte[SIGNATURE_LENGTH];
|
||||
|
||||
this.edPrivateKeyParams.sign(Ed25519.Algorithm.Ed25519, edPublicKeyParams, null, message, 0, message.length, signature, 0);
|
||||
|
||||
return signature;
|
||||
return Crypto.sign(this.edPrivateKeyParams, message);
|
||||
}
|
||||
|
||||
public byte[] getSharedSecret(byte[] publicKey) {
|
||||
byte[] x25519PrivateKey = BouncyCastle25519.toX25519PrivateKey(this.privateKey);
|
||||
X25519PrivateKeyParameters xPrivateKeyParams = new X25519PrivateKeyParameters(x25519PrivateKey, 0);
|
||||
|
||||
byte[] x25519PublicKey = BouncyCastle25519.toX25519PublicKey(publicKey);
|
||||
X25519PublicKeyParameters xPublicKeyParams = new X25519PublicKeyParameters(x25519PublicKey, 0);
|
||||
|
||||
byte[] sharedSecret = new byte[SHARED_SECRET_LENGTH];
|
||||
xPrivateKeyParams.generateSecret(xPublicKeyParams, sharedSecret, 0);
|
||||
|
||||
return sharedSecret;
|
||||
return Crypto.getSharedSecret(this.privateKey, publicKey);
|
||||
}
|
||||
|
||||
public byte[] getRewardSharePrivateKey(byte[] publicKey) {
|
||||
|
@@ -1,7 +1,6 @@
|
||||
package org.qortal.account;
|
||||
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
|
||||
import org.bouncycastle.math.ec.rfc8032.Ed25519;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.account.AccountData;
|
||||
import org.qortal.repository.Repository;
|
||||
@@ -22,6 +21,18 @@ public class PublicKeyAccount extends Account {
|
||||
this.publicKey = edPublicKeyParams.getEncoded();
|
||||
}
|
||||
|
||||
protected PublicKeyAccount(Repository repository, byte[] publicKey, String address) {
|
||||
super(repository, address);
|
||||
|
||||
this.publicKey = publicKey;
|
||||
this.edPublicKeyParams = null;
|
||||
}
|
||||
|
||||
protected PublicKeyAccount() {
|
||||
this.publicKey = null;
|
||||
this.edPublicKeyParams = null;
|
||||
}
|
||||
|
||||
public byte[] getPublicKey() {
|
||||
return this.publicKey;
|
||||
}
|
||||
@@ -34,15 +45,7 @@ public class PublicKeyAccount extends Account {
|
||||
}
|
||||
|
||||
public boolean verify(byte[] signature, byte[] message) {
|
||||
return PublicKeyAccount.verify(this.publicKey, signature, message);
|
||||
}
|
||||
|
||||
public static boolean verify(byte[] publicKey, byte[] signature, byte[] message) {
|
||||
try {
|
||||
return Ed25519.verify(signature, 0, publicKey, 0, message, 0, message.length);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
return Crypto.verify(this.publicKey, signature, message);
|
||||
}
|
||||
|
||||
public static String getAddress(byte[] publicKey) {
|
||||
|
27
src/main/java/org/qortal/api/AmountTypeAdapter.java
Normal file
27
src/main/java/org/qortal/api/AmountTypeAdapter.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package org.qortal.api;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import javax.xml.bind.annotation.adapters.XmlAdapter;
|
||||
|
||||
import org.qortal.utils.Amounts;
|
||||
|
||||
public class AmountTypeAdapter extends XmlAdapter<String, Long> {
|
||||
|
||||
@Override
|
||||
public Long unmarshal(String input) throws Exception {
|
||||
if (input == null)
|
||||
return null;
|
||||
|
||||
return new BigDecimal(input).setScale(8).unscaledValue().longValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String marshal(Long output) throws Exception {
|
||||
if (output == null)
|
||||
return null;
|
||||
|
||||
return Amounts.prettyAmount(output);
|
||||
}
|
||||
|
||||
}
|
@@ -5,16 +5,23 @@ import static java.util.stream.Collectors.toMap;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlRootElement;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.NONE)
|
||||
@XmlRootElement
|
||||
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),
|
||||
NON_PRODUCTION(6, 403),
|
||||
BLOCKCHAIN_NEEDS_SYNC(7, 503),
|
||||
NO_TIME_SYNC(8, 503),
|
||||
|
||||
// VALIDATION
|
||||
INVALID_SIGNATURE(101, 400),
|
||||
@@ -117,7 +124,12 @@ public enum ApiError {
|
||||
// MESSAGESIZE_EXCEEDED(1004, 400),
|
||||
|
||||
// Groups
|
||||
GROUP_UNKNOWN(1101, 404);
|
||||
GROUP_UNKNOWN(1101, 404),
|
||||
|
||||
// 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();
|
||||
|
||||
|
20
src/main/java/org/qortal/api/ApiErrorRoot.java
Normal file
20
src/main/java/org/qortal/api/ApiErrorRoot.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package org.qortal.api;
|
||||
|
||||
import javax.xml.bind.annotation.XmlElement;
|
||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
public class ApiErrorRoot {
|
||||
|
||||
private ApiError apiError;
|
||||
|
||||
@XmlJavaTypeAdapter(ApiErrorTypeAdapter.class)
|
||||
@XmlElement(name = "error")
|
||||
public ApiError getApiError() {
|
||||
return this.apiError;
|
||||
}
|
||||
|
||||
public void setApiError(ApiError apiError) {
|
||||
this.apiError = apiError;
|
||||
}
|
||||
|
||||
}
|
32
src/main/java/org/qortal/api/ApiErrorTypeAdapter.java
Normal file
32
src/main/java/org/qortal/api/ApiErrorTypeAdapter.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package org.qortal.api;
|
||||
|
||||
import javax.xml.bind.annotation.adapters.XmlAdapter;
|
||||
|
||||
public class ApiErrorTypeAdapter extends XmlAdapter<ApiErrorTypeAdapter.AdaptedApiError, ApiError> {
|
||||
|
||||
public static class AdaptedApiError {
|
||||
public int code;
|
||||
public String description;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiError unmarshal(AdaptedApiError adaptedInput) throws Exception {
|
||||
if (adaptedInput == null)
|
||||
return null;
|
||||
|
||||
return ApiError.fromCode(adaptedInput.code);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdaptedApiError marshal(ApiError output) throws Exception {
|
||||
if (output == null)
|
||||
return null;
|
||||
|
||||
AdaptedApiError adaptedOutput = new AdaptedApiError();
|
||||
adaptedOutput.code = output.getCode();
|
||||
adaptedOutput.description = output.name();
|
||||
|
||||
return adaptedOutput;
|
||||
}
|
||||
|
||||
}
|
@@ -149,8 +149,8 @@ public class ApiRequest {
|
||||
HttpURLConnection con = (HttpURLConnection) url.openConnection();
|
||||
|
||||
con.setRequestMethod("GET");
|
||||
con.setConnectTimeout(5000);
|
||||
con.setReadTimeout(3000);
|
||||
con.setConnectTimeout(30000);
|
||||
con.setReadTimeout(10000);
|
||||
ApiRequest.setConnectionSSL(con, ipAddress);
|
||||
|
||||
int status = con.getResponseCode();
|
||||
|
@@ -2,15 +2,31 @@ package org.qortal.api;
|
||||
|
||||
import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.KeyStore;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
||||
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.RequestLog;
|
||||
import org.eclipse.jetty.server.RequestLogWriter;
|
||||
import org.eclipse.jetty.server.SecureRequestCustomizer;
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import org.eclipse.jetty.server.ServerConnector;
|
||||
import org.eclipse.jetty.server.SslConnectionFactory;
|
||||
import org.eclipse.jetty.server.handler.ErrorHandler;
|
||||
import org.eclipse.jetty.server.handler.InetAccessHandler;
|
||||
import org.eclipse.jetty.servlet.DefaultServlet;
|
||||
@@ -18,10 +34,18 @@ import org.eclipse.jetty.servlet.FilterHolder;
|
||||
import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||
import org.eclipse.jetty.servlet.ServletHolder;
|
||||
import org.eclipse.jetty.servlets.CrossOriginFilter;
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
||||
import org.glassfish.jersey.server.ResourceConfig;
|
||||
import org.glassfish.jersey.servlet.ServletContainer;
|
||||
import org.qortal.api.resource.AnnotationPostProcessor;
|
||||
import org.qortal.api.resource.ApiDefinition;
|
||||
import org.qortal.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 {
|
||||
@@ -53,9 +77,57 @@ public class ApiService {
|
||||
public void start() {
|
||||
try {
|
||||
// Create API server
|
||||
InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress());
|
||||
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getApiPort());
|
||||
this.server = new Server(endpoint);
|
||||
|
||||
// SSL support if requested
|
||||
String keystorePathname = Settings.getInstance().getSslKeystorePathname();
|
||||
String keystorePassword = Settings.getInstance().getSslKeystorePassword();
|
||||
|
||||
if (keystorePathname != null && keystorePassword != null) {
|
||||
// SSL version
|
||||
if (!Files.isReadable(Path.of(keystorePathname)))
|
||||
throw new RuntimeException("Failed to start SSL API due to broken keystore");
|
||||
|
||||
// BouncyCastle-specific SSLContext build
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
|
||||
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
|
||||
|
||||
try (InputStream keystoreStream = Files.newInputStream(Paths.get(keystorePathname))) {
|
||||
keyStore.load(keystoreStream, keystorePassword.toCharArray());
|
||||
}
|
||||
|
||||
keyManagerFactory.init(keyStore, keystorePassword.toCharArray());
|
||||
sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom());
|
||||
|
||||
SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
|
||||
sslContextFactory.setSslContext(sslContext);
|
||||
|
||||
this.server = new Server();
|
||||
|
||||
HttpConfiguration httpConfig = new HttpConfiguration();
|
||||
httpConfig.setSecureScheme("https");
|
||||
httpConfig.setSecurePort(Settings.getInstance().getApiPort());
|
||||
|
||||
SecureRequestCustomizer src = new SecureRequestCustomizer();
|
||||
httpConfig.addCustomizer(src);
|
||||
|
||||
HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(httpConfig);
|
||||
SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString());
|
||||
|
||||
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
|
||||
new DetectorConnectionFactory(sslConnectionFactory),
|
||||
httpConnectionFactory);
|
||||
portUnifiedConnector.setHost(Settings.getInstance().getBindAddress());
|
||||
portUnifiedConnector.setPort(Settings.getInstance().getApiPort());
|
||||
|
||||
this.server.addConnector(portUnifiedConnector);
|
||||
} else {
|
||||
// Non-SSL
|
||||
InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress());
|
||||
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getApiPort());
|
||||
this.server = new Server(endpoint);
|
||||
}
|
||||
|
||||
// Error handler
|
||||
ErrorHandler errorHandler = new ApiErrorHandler();
|
||||
@@ -108,10 +180,29 @@ public class ApiService {
|
||||
swaggerUIServlet.setInitParameter("pathInfoOnly", "true");
|
||||
context.addServlet(swaggerUIServlet, "/api-documentation/*");
|
||||
|
||||
rewriteHandler.addRule(new RedirectPatternRule("", "/api-documentation/")); // redirect to Swagger UI start page
|
||||
rewriteHandler.addRule(new RedirectPatternRule("/api-documentation", "/api-documentation/")); // redirect to Swagger UI start page
|
||||
rewriteHandler.addRule(new RedirectPatternRule("", "/api-documentation/")); // redirect empty path to API docs
|
||||
rewriteHandler.addRule(new RedirectPatternRule("/api-documentation", "/api-documentation/")); // redirect to add trailing slash if missing
|
||||
} else {
|
||||
// Simple pages that explains that API documentation is disabled
|
||||
ClassLoader loader = this.getClass().getClassLoader();
|
||||
ServletHolder swaggerUIServlet = new ServletHolder("api-docs-disabled", DefaultServlet.class);
|
||||
swaggerUIServlet.setInitParameter("resourceBase", loader.getResource("api-docs-disabled/").toString());
|
||||
swaggerUIServlet.setInitParameter("dirAllowed", "true");
|
||||
swaggerUIServlet.setInitParameter("pathInfoOnly", "true");
|
||||
context.addServlet(swaggerUIServlet, "/api-documentation/*");
|
||||
|
||||
rewriteHandler.addRule(new RedirectPatternRule("", "/api-documentation/")); // redirect empty path to API docs
|
||||
rewriteHandler.addRule(new RedirectPatternRule("/api-documentation", "/api-documentation/")); // redirect to add trailing slash if missing
|
||||
}
|
||||
|
||||
context.addServlet(AdminStatusWebSocket.class, "/websockets/admin/status");
|
||||
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();
|
||||
} catch (Exception e) {
|
||||
|
@@ -0,0 +1,25 @@
|
||||
package org.qortal.api;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import javax.xml.bind.annotation.adapters.XmlAdapter;
|
||||
|
||||
public class RewardSharePercentTypeAdapter extends XmlAdapter<String, Integer> {
|
||||
|
||||
@Override
|
||||
public Integer unmarshal(String input) throws Exception {
|
||||
if (input == null)
|
||||
return null;
|
||||
|
||||
return new BigDecimal(input).setScale(2).unscaledValue().intValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String marshal(Integer output) throws Exception {
|
||||
if (output == null)
|
||||
return null;
|
||||
|
||||
return String.format("%d.%02d", output / 100, Math.abs(output % 100));
|
||||
}
|
||||
|
||||
}
|
@@ -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,11 +1,10 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import javax.xml.bind.Marshaller;
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlElement;
|
||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
import org.qortal.data.asset.OrderData;
|
||||
|
||||
@@ -29,12 +28,14 @@ public class AggregatedOrder {
|
||||
}
|
||||
|
||||
@XmlElement(name = "price")
|
||||
public BigDecimal getPrice() {
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long getPrice() {
|
||||
return this.orderData.getPrice();
|
||||
}
|
||||
|
||||
@XmlElement(name = "unfulfilled")
|
||||
public BigDecimal getUnfulfilled() {
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long getUnfulfilled() {
|
||||
return this.orderData.getAmount();
|
||||
}
|
||||
|
||||
|
@@ -6,7 +6,7 @@ import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import org.qortal.crypto.Crypto;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class BlockMinterSummary {
|
||||
public class BlockSignerSummary {
|
||||
|
||||
// Properties
|
||||
|
||||
@@ -20,22 +20,25 @@ public class BlockMinterSummary {
|
||||
|
||||
// Constructors
|
||||
|
||||
protected BlockMinterSummary() {
|
||||
protected BlockSignerSummary() {
|
||||
}
|
||||
|
||||
/** Constructs BlockMinterSummary in non-reward-share context. */
|
||||
public BlockMinterSummary(byte[] blockMinterPublicKey, int blockCount) {
|
||||
/** Constructs BlockSignerSummary in non-reward-share context. */
|
||||
public BlockSignerSummary(byte[] blockMinterPublicKey, int blockCount) {
|
||||
this.blockCount = blockCount;
|
||||
|
||||
this.mintingAccountPublicKey = blockMinterPublicKey;
|
||||
this.mintingAccount = Crypto.toAddress(this.mintingAccountPublicKey);
|
||||
}
|
||||
|
||||
/** Constructs BlockMinterSummary in reward-share context. */
|
||||
public BlockMinterSummary(byte[] rewardSharePublicKey, int blockCount, byte[] mintingAccountPublicKey, String recipientAccount) {
|
||||
this(mintingAccountPublicKey, blockCount);
|
||||
|
||||
/** Constructs BlockSignerSummary in reward-share context. */
|
||||
public BlockSignerSummary(byte[] rewardSharePublicKey, int blockCount, byte[] mintingAccountPublicKey, String minterAccount, String recipientAccount) {
|
||||
this.rewardSharePublicKey = rewardSharePublicKey;
|
||||
this.blockCount = blockCount;
|
||||
|
||||
this.mintingAccountPublicKey = mintingAccountPublicKey;
|
||||
this.mintingAccount = minterAccount;
|
||||
|
||||
this.recipientAccount = recipientAccount;
|
||||
}
|
||||
|
@@ -25,7 +25,8 @@ public class ConnectedPeer {
|
||||
|
||||
public String address;
|
||||
public String version;
|
||||
public Long buildTimestamp;
|
||||
|
||||
public String nodeId;
|
||||
|
||||
public Integer lastHeight;
|
||||
@Schema(example = "base58")
|
||||
@@ -45,10 +46,9 @@ public class ConnectedPeer {
|
||||
this.peersConnectedWhen = peer.getPeersConnectionTimestamp();
|
||||
|
||||
this.address = peerData.getAddress().toString();
|
||||
if (peer.getVersionMessage() != null) {
|
||||
this.version = peer.getVersionMessage().getVersionString();
|
||||
this.buildTimestamp = peer.getVersionMessage().getBuildTimestamp();
|
||||
}
|
||||
|
||||
this.version = peer.getPeersVersionString();
|
||||
this.nodeId = peer.getPeersNodeId();
|
||||
|
||||
PeerChainTipData peerChainTipData = peer.getChainTipData();
|
||||
if (peerChainTipData != null) {
|
||||
|
@@ -0,0 +1,34 @@
|
||||
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 CrossChainBitcoinRedeemRequest {
|
||||
|
||||
@Schema(description = "Bitcoin HASH160(public key) for refund", example = "2nGDBPPPFS1c9w1h33YwFk4KUJU2")
|
||||
public byte[] refundPublicKeyHash;
|
||||
|
||||
@Schema(description = "Bitcoin PRIVATE KEY for redeem", example = "cUvGNSnu14q6Hr1X7TESjYVTqBpFjj8GGLGjGdpJwD9NhSQKeYUk")
|
||||
public byte[] redeemPrivateKey;
|
||||
|
||||
@Schema(description = "Qortal AT address")
|
||||
public String atAddress;
|
||||
|
||||
@Schema(description = "Bitcoin miner fee", example = "0.00001000")
|
||||
public BigDecimal bitcoinMinerFee;
|
||||
|
||||
@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() {
|
||||
}
|
||||
|
||||
}
|
@@ -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 CrossChainBitcoinRefundRequest {
|
||||
|
||||
@Schema(description = "Bitcoin PRIVATE KEY for refund", example = "cSP3zTb6bfm8GATtAcEJ8LqYtNQmzZ9jE2wQUVnZGiBzojDdrwKV")
|
||||
public byte[] refundPrivateKey;
|
||||
|
||||
@Schema(description = "Bitcoin HASH160(public key) for redeem", example = "2daMveGc5pdjRyFacbxBzMksCbyC")
|
||||
public byte[] redeemPublicKeyHash;
|
||||
|
||||
@Schema(description = "Qortal AT address")
|
||||
public String atAddress;
|
||||
|
||||
@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,23 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainBitcoinTemplateRequest {
|
||||
|
||||
@Schema(description = "Bitcoin HASH160(public key) for refund", example = "2nGDBPPPFS1c9w1h33YwFk4KUJU2")
|
||||
public byte[] refundPublicKeyHash;
|
||||
|
||||
@Schema(description = "Bitcoin HASH160(public key) for redeem", example = "2daMveGc5pdjRyFacbxBzMksCbyC")
|
||||
public byte[] redeemPublicKeyHash;
|
||||
|
||||
@Schema(description = "Qortal AT address")
|
||||
public String atAddress;
|
||||
|
||||
public CrossChainBitcoinTemplateRequest() {
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainBitcoinyHTLCStatus {
|
||||
|
||||
@Schema(description = "P2SH address", example = "3CdH27kTpV8dcFHVRYjQ8EEV5FJg9X8pSJ (mainnet), 2fMiRRXVsxhZeyfum9ifybZvaMHbQTmwdZw (testnet)")
|
||||
public String bitcoinP2shAddress;
|
||||
|
||||
@Schema(description = "P2SH balance")
|
||||
public BigDecimal bitcoinP2shBalance;
|
||||
|
||||
@Schema(description = "Can HTLC redeem yet?")
|
||||
public boolean canRedeem;
|
||||
|
||||
@Schema(description = "Can HTLC refund yet?")
|
||||
public boolean canRefund;
|
||||
|
||||
@Schema(description = "Secret used by HTLC redeemer")
|
||||
public byte[] secret;
|
||||
|
||||
public CrossChainBitcoinyHTLCStatus() {
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,39 @@
|
||||
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 io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainBuildRequest {
|
||||
|
||||
@Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] creatorPublicKey;
|
||||
|
||||
@Schema(description = "Final QORT amount paid out on successful trade", example = "80.40200000")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
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[] hashOfSecretB;
|
||||
|
||||
@Schema(description = "Bitcoin P2SH BTC balance for release of secret", example = "0.00864200")
|
||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||
public long bitcoinAmount;
|
||||
|
||||
@Schema(description = "Trade time window (minutes) from trade agreement to automatic refund", example = "10080")
|
||||
public Integer tradeTimeout;
|
||||
|
||||
public CrossChainBuildRequest() {
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainCancelRequest {
|
||||
|
||||
@Schema(description = "AT creator's public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] creatorPublicKey;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainSecretRequest {
|
||||
|
||||
@Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] partnerPublicKey;
|
||||
|
||||
@Schema(description = "Qortal AT address")
|
||||
public String atAddress;
|
||||
|
||||
@Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1")
|
||||
public byte[] secretA;
|
||||
|
||||
@Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx")
|
||||
public byte[] secretB;
|
||||
|
||||
@Schema(description = "Qortal address for receiving QORT from AT")
|
||||
public String receivingAddress;
|
||||
|
||||
public CrossChainSecretRequest() {
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class CrossChainTradeRequest {
|
||||
|
||||
@Schema(description = "AT creator's 'trade' public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
|
||||
public byte[] tradePublicKey;
|
||||
|
||||
@Schema(description = "Qortal AT address")
|
||||
public String atAddress;
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
@@ -10,6 +10,8 @@ public class NodeInfo {
|
||||
public long uptime;
|
||||
public String buildVersion;
|
||||
public long buildTimestamp;
|
||||
public String nodeId;
|
||||
public boolean isTestNet;
|
||||
|
||||
public NodeInfo() {
|
||||
}
|
||||
|
33
src/main/java/org/qortal/api/model/NodeStatus.java
Normal file
33
src/main/java/org/qortal/api/model/NodeStatus.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.network.Network;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class NodeStatus {
|
||||
|
||||
public final boolean isMintingPossible;
|
||||
public final boolean isSynchronizing;
|
||||
|
||||
// Not always present
|
||||
public final Integer syncPercent;
|
||||
|
||||
public final int numberOfConnections;
|
||||
|
||||
public final int height;
|
||||
|
||||
public NodeStatus() {
|
||||
this.isMintingPossible = Controller.getInstance().isMintingPossible();
|
||||
|
||||
this.syncPercent = Controller.getInstance().getSyncPercent();
|
||||
this.isSynchronizing = this.syncPercent != null;
|
||||
|
||||
this.numberOfConnections = Network.getInstance().getHandshakedPeers().size();
|
||||
|
||||
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;
|
||||
@@ -28,6 +29,7 @@ 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.ApiOnlineAccount;
|
||||
import org.qortal.api.model.RewardShareKeyRequest;
|
||||
import org.qortal.asset.Asset;
|
||||
@@ -36,18 +38,27 @@ import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.account.AccountData;
|
||||
import org.qortal.data.account.RewardShareData;
|
||||
import org.qortal.data.network.OnlineAccountData;
|
||||
import org.qortal.data.transaction.PublicizeTransactionData;
|
||||
import org.qortal.data.transaction.RewardShareTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.PublicizeTransaction;
|
||||
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.PublicizeTransactionTransformer;
|
||||
import org.qortal.transform.transaction.RewardShareTransactionTransformer;
|
||||
import org.qortal.transform.transaction.TransactionTransformer;
|
||||
import org.qortal.utils.Amounts;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
@Path("/addresses")
|
||||
@Tag(name = "Addresses")
|
||||
public class AddressesResource {
|
||||
@@ -66,32 +77,18 @@ public class AddressesResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public AccountData getAccountInfo(@PathParam("address") String address) {
|
||||
if (!Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
AccountData accountData = repository.getAccountRepository().getAccount(address);
|
||||
|
||||
// Not found?
|
||||
if (accountData == null)
|
||||
accountData = new AccountData(address);
|
||||
else {
|
||||
// Unconfirmed transactions could update lastReference
|
||||
Account account = new Account(repository, address);
|
||||
|
||||
// Use last reference based on unconfirmed transactions if possible
|
||||
byte[] unconfirmedLastReference = account.getUnconfirmedLastReference();
|
||||
|
||||
if (unconfirmedLastReference != null)
|
||||
// There are unconfirmed transactions so modify returned data
|
||||
accountData.setReference(unconfirmedLastReference);
|
||||
}
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
return accountData;
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -100,42 +97,37 @@ public class AddressesResource {
|
||||
@GET
|
||||
@Path("/lastreference/{address}")
|
||||
@Operation(
|
||||
summary = "Fetch reference for next transaction to be created by address, considering unconfirmed transactions",
|
||||
description = "Returns the base58-encoded signature of the last confirmed/unconfirmed transaction created by address, failing that: the first incoming transaction. Returns \"false\" if there is no transactions.",
|
||||
summary = "Fetch reference for next transaction to be created by address",
|
||||
description = "Returns the base58-encoded signature of the last confirmed transaction created by address, failing that: the first incoming transaction. Returns \"false\" if there is no last-reference.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "the base58-encoded transaction signature",
|
||||
description = "the base58-encoded last-reference",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
public String getLastReferenceUnconfirmed(@PathParam("address") String address) {
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public String getLastReference(@PathParam("address") String address) {
|
||||
if (!Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
byte[] lastReference = null;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Account account = new Account(repository, address);
|
||||
AccountData accountData = repository.getAccountRepository().getAccount(address);
|
||||
// Not found?
|
||||
if (accountData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
// Use last reference based on unconfirmed transactions if possible
|
||||
lastReference = account.getUnconfirmedLastReference();
|
||||
|
||||
if (lastReference == null)
|
||||
// No unconfirmed transactions so fallback to using one save in account data
|
||||
lastReference = account.getLastReference();
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
lastReference = accountData.getReference();
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
|
||||
if(lastReference == null || lastReference.length == 0) {
|
||||
if (lastReference == null || lastReference.length == 0)
|
||||
return "false";
|
||||
} else {
|
||||
return Base58.encode(lastReference);
|
||||
}
|
||||
|
||||
return Base58.encode(lastReference);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -192,7 +184,7 @@ public class AddressesResource {
|
||||
@Path("/balance/{address}")
|
||||
@Operation(
|
||||
summary = "Returns account balance",
|
||||
description = "Returns account's balance, optionally of given asset and at given height",
|
||||
description = "Returns account's QORT balance, or of other specified asset",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "the balance",
|
||||
@@ -202,8 +194,7 @@ public class AddressesResource {
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.INVALID_ASSET_ID, ApiError.INVALID_HEIGHT, ApiError.REPOSITORY_ISSUE})
|
||||
public BigDecimal getBalance(@PathParam("address") String address,
|
||||
@QueryParam("assetId") Long assetId,
|
||||
@QueryParam("height") Integer height) {
|
||||
@QueryParam("assetId") Long assetId) {
|
||||
if (!Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
@@ -215,12 +206,7 @@ public class AddressesResource {
|
||||
else if (!repository.getAssetRepository().assetExists(assetId))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ASSET_ID);
|
||||
|
||||
if (height == null)
|
||||
height = repository.getBlockRepository().getBlockchainHeight();
|
||||
else if (height <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT);
|
||||
|
||||
return account.getBalance(assetId, height);
|
||||
return Amounts.toBigDecimal(account.getConfirmedBalance(assetId));
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
@@ -414,4 +400,120 @@ public class AddressesResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/publicize")
|
||||
@Operation(
|
||||
summary = "Build raw, unsigned, PUBLICIZE transaction",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = PublicizeTransactionData.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "raw, unsigned, PUBLICIZE transaction encoded in Base58",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.NON_PRODUCTION, ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
public String publicize(PublicizeTransactionData transactionData) {
|
||||
if (Settings.getInstance().isApiRestricted())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Transaction transaction = Transaction.fromData(repository, transactionData);
|
||||
|
||||
ValidationResult result = transaction.isValidUnconfirmed();
|
||||
if (result != ValidationResult.OK && result != ValidationResult.INCORRECT_NONCE)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
byte[] bytes = PublicizeTransactionTransformer.toBytes(transactionData);
|
||||
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("/publicize/compute")
|
||||
@Operation(
|
||||
summary = "Compute nonce for raw, unsigned PUBLICIZE transaction",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
description = "raw, unsigned PUBLICIZE transaction in base58 encoding",
|
||||
example = "raw transaction base58"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "raw, unsigned, PUBLICIZE transaction encoded in Base58",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String computePublicize(String rawBytes58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
byte[] rawBytes = Base58.decode(rawBytes58);
|
||||
// We're expecting unsigned transaction, so append empty signature prior to decoding
|
||||
rawBytes = Bytes.concat(rawBytes, new byte[TransactionTransformer.SIGNATURE_LENGTH]);
|
||||
|
||||
TransactionData transactionData = TransactionTransformer.fromBytes(rawBytes);
|
||||
if (transactionData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (transactionData.getType() != TransactionType.PUBLICIZE)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
PublicizeTransaction publicizeTransaction = (PublicizeTransaction) Transaction.fromData(repository, transactionData);
|
||||
|
||||
// Quicker validity check first before we compute nonce
|
||||
ValidationResult result = publicizeTransaction.isValid();
|
||||
if (result != ValidationResult.OK && result != ValidationResult.INCORRECT_NONCE)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
publicizeTransaction.computeNonce();
|
||||
|
||||
// Re-check, but ignores signature
|
||||
result = publicizeTransaction.isValidUnconfirmed();
|
||||
if (result != ValidationResult.OK)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
// Strip zeroed signature
|
||||
transactionData.setSignature(null);
|
||||
|
||||
byte[] bytes = PublicizeTransactionTransformer.toBytes(transactionData);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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;
|
||||
@@ -36,15 +37,15 @@ import javax.ws.rs.core.MediaType;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.core.LoggerContext;
|
||||
import org.apache.logging.log4j.core.appender.RollingFileAppender;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.account.PrivateKeyAccount;
|
||||
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.ActivitySummary;
|
||||
import org.qortal.api.model.NodeInfo;
|
||||
import org.qortal.api.model.NodeStatus;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.controller.Synchronizer.SynchronizationResult;
|
||||
@@ -56,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;
|
||||
|
||||
@@ -116,10 +118,31 @@ public class AdminResource {
|
||||
nodeInfo.uptime = System.currentTimeMillis() - Controller.startTime;
|
||||
nodeInfo.buildVersion = Controller.getInstance().getVersionString();
|
||||
nodeInfo.buildTimestamp = Controller.getInstance().getBuildTimestamp();
|
||||
nodeInfo.nodeId = Network.getInstance().getOurNodeId();
|
||||
nodeInfo.isTestNet = Settings.getInstance().isTestNet();
|
||||
|
||||
return nodeInfo;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/status")
|
||||
@Operation(
|
||||
summary = "Fetch node status",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = NodeStatus.class))
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public NodeStatus status() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
NodeStatus nodeStatus = new NodeStatus();
|
||||
|
||||
return nodeStatus;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/stop")
|
||||
@Operation(
|
||||
@@ -132,6 +155,7 @@ public class AdminResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String shutdown() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -160,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();
|
||||
@@ -172,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) {
|
||||
@@ -189,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(
|
||||
@@ -201,6 +249,7 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public List<MintingAccountData> getMintingAccounts() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -218,7 +267,7 @@ public class AdminResource {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return new MintingAccountData(mintingAccountData.getPrivateKey(), rewardShareData);
|
||||
return new MintingAccountData(mintingAccountData, rewardShareData);
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
return mintingAccounts;
|
||||
@@ -247,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);
|
||||
|
||||
@@ -262,11 +312,11 @@ public class AdminResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
// Qortal: check reward-share's minting account is still allowed to mint
|
||||
PublicKeyAccount rewardShareMintingAccount = new PublicKeyAccount(repository, rewardShareData.getMinterPublicKey());
|
||||
Account rewardShareMintingAccount = new Account(repository, rewardShareData.getMinter());
|
||||
if (!rewardShareMintingAccount.canMint())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.CANNOT_MINT);
|
||||
|
||||
MintingAccountData mintingAccountData = new MintingAccountData(seed);
|
||||
MintingAccountData mintingAccountData = new MintingAccountData(mintingAccount.getPrivateKey(), mintingAccount.getPublicKey());
|
||||
|
||||
repository.getAccountRepository().save(mintingAccountData);
|
||||
repository.saveChanges();
|
||||
@@ -282,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"
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -299,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();
|
||||
@@ -398,6 +449,7 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_HEIGHT, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String orphan(String targetHeightString) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -415,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +491,7 @@ public class AdminResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String forceSync(String targetPeerAddress) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -472,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) {
|
||||
@@ -481,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();
|
||||
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 = {
|
||||
@@ -13,7 +19,10 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@Tag(name = "Admin"),
|
||||
@Tag(name = "Arbitrary"),
|
||||
@Tag(name = "Assets"),
|
||||
@Tag(name = "Automated Transactions"),
|
||||
@Tag(name = "Blocks"),
|
||||
@Tag(name = "Chat"),
|
||||
@Tag(name = "Cross-Chain"),
|
||||
@Tag(name = "Groups"),
|
||||
@Tag(name = "Names"),
|
||||
@Tag(name = "Payments"),
|
||||
@@ -27,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 {
|
||||
}
|
204
src/main/java/org/qortal/api/resource/AtResource.java
Normal file
204
src/main/java/org/qortal/api/resource/AtResource.java
Normal file
@@ -0,0 +1,204 @@
|
||||
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 javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
import org.ciyam.at.MachineState;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiException;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
import org.qortal.data.transaction.DeployAtTransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.transaction.DeployAtTransactionTransformer;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
@Path("/at")
|
||||
@Tag(name = "Automated Transactions")
|
||||
public class AtResource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/byfunction/{codehash}")
|
||||
@Operation(
|
||||
summary = "Find automated transactions with matching functionality (code hash)",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "automated transactions",
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = ATData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({
|
||||
ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public List<ATData> getByFunctionality(
|
||||
@PathParam("codehash")
|
||||
String codeHash58,
|
||||
@Parameter(description = "whether to include ATs that can run, or not, or both (if omitted)")
|
||||
@QueryParam("isExecutable")
|
||||
Boolean isExecutable,
|
||||
@Parameter( ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter( ref = "offset" ) @QueryParam("offset") Integer offset,
|
||||
@Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) {
|
||||
// Decode codeHash
|
||||
byte[] codeHash;
|
||||
try {
|
||||
codeHash = Base58.decode(codeHash58);
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e);
|
||||
}
|
||||
|
||||
// codeHash must be present and have correct length
|
||||
if (codeHash == null || codeHash.length != 32)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Impose a limit on 'limit'
|
||||
if (limit != null && limit > 100)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{ataddress}")
|
||||
@Operation(
|
||||
summary = "Fetch info associated with AT address",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "automated transaction",
|
||||
content = @Content(
|
||||
schema = @Schema(implementation = ATData.class)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({
|
||||
ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public ATData getByAddress(@PathParam("ataddress") String atAddress) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getATRepository().fromATAddress(atAddress);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{ataddress}/data")
|
||||
@Operation(
|
||||
summary = "Fetch data segment associated with AT address",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "automated transaction",
|
||||
content = @Content(
|
||||
schema = @Schema(implementation = byte[].class)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({
|
||||
ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public byte[] getDataByAddress(@PathParam("ataddress") String atAddress) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress);
|
||||
byte[] stateData = atStateData.getStateData();
|
||||
|
||||
byte[] dataBytes = MachineState.extractDataBytes(stateData);
|
||||
|
||||
return dataBytes;
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Operation(
|
||||
summary = "Build raw, unsigned, DEPLOY_AT transaction",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = DeployAtTransactionData.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "raw, unsigned, DEPLOY_AT transaction encoded in Base58",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.NON_PRODUCTION, ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
public String createDeployAt(DeployAtTransactionData transactionData) {
|
||||
if (Settings.getInstance().isApiRestricted())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Transaction transaction = Transaction.fromData(repository, transactionData);
|
||||
|
||||
ValidationResult result = transaction.isValidUnconfirmed();
|
||||
if (result != ValidationResult.OK)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
byte[] bytes = DeployAtTransactionTransformer.toBytes(transactionData);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -22,9 +22,8 @@ import javax.ws.rs.core.MediaType;
|
||||
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiException;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.model.BlockMinterSummary;
|
||||
import org.qortal.api.model.BlockSignerSummary;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.account.AccountData;
|
||||
import org.qortal.data.block.BlockData;
|
||||
@@ -71,9 +70,11 @@ public class BlocksResource {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getBlockRepository().fromSignature(signature);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
BlockData blockData = repository.getBlockRepository().fromSignature(signature);
|
||||
if (blockData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
return blockData;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -120,8 +121,6 @@ public class BlocksResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -223,8 +222,6 @@ public class BlocksResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
return childBlockData;
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -253,8 +250,6 @@ public class BlocksResource {
|
||||
public int getHeight() {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getBlockRepository().getBlockchainHeight();
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -297,8 +292,6 @@ public class BlocksResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
return blockData.getHeight();
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -330,8 +323,6 @@ public class BlocksResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
return blockData;
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -366,8 +357,6 @@ public class BlocksResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
|
||||
|
||||
return blockData;
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -416,9 +405,9 @@ public class BlocksResource {
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/minter/{address}")
|
||||
@Path("/signer/{address}")
|
||||
@Operation(
|
||||
summary = "Fetch block summaries for blocks minted by address",
|
||||
summary = "Fetch block summaries for blocks signed by address",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "block summaries",
|
||||
@@ -433,7 +422,7 @@ public class BlocksResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.PUBLIC_KEY_NOT_FOUND, ApiError.REPOSITORY_ISSUE})
|
||||
public List<BlockSummaryData> getBlockSummariesByMinter(@PathParam("address") String address, @Parameter(
|
||||
public List<BlockSummaryData> getBlockSummariesBySigner(@PathParam("address") String address, @Parameter(
|
||||
ref = "limit"
|
||||
) @QueryParam("limit") Integer limit, @Parameter(
|
||||
ref = "offset"
|
||||
@@ -449,32 +438,30 @@ public class BlocksResource {
|
||||
if (accountData == null || accountData.getPublicKey() == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.PUBLIC_KEY_NOT_FOUND);
|
||||
|
||||
return repository.getBlockRepository().getBlockSummariesByMinter(accountData.getPublicKey(), limit, offset, reverse);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
return repository.getBlockRepository().getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/minters")
|
||||
@Path("/signers")
|
||||
@Operation(
|
||||
summary = "Show summary of block minters",
|
||||
description = "Returns count of blocks minted, optionally limited to minters/recipients in passed address(es).",
|
||||
summary = "Show summary of block signers",
|
||||
description = "Returns count of blocks signed, optionally limited to minters/recipients in passed address(es).",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = BlockMinterSummary.class
|
||||
implementation = BlockSignerSummary.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
public List<BlockMinterSummary> getBlockMinters(@QueryParam("address") List<String> addresses,
|
||||
public List<BlockSignerSummary> getBlockSigners(@QueryParam("address") List<String> addresses,
|
||||
@Parameter(
|
||||
ref = "limit"
|
||||
) @QueryParam("limit") Integer limit, @Parameter(
|
||||
@@ -487,7 +474,47 @@ public class BlocksResource {
|
||||
if (!Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
return repository.getBlockRepository().getBlockMinters(addresses, limit, offset, reverse);
|
||||
return repository.getBlockRepository().getBlockSigners(addresses, limit, offset, reverse);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
250
src/main/java/org/qortal/api/resource/ChatResource.java
Normal file
250
src/main/java/org/qortal/api/resource/ChatResource.java
Normal file
@@ -0,0 +1,250 @@
|
||||
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.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.chat.ActiveChats;
|
||||
import org.qortal.data.chat.ChatMessage;
|
||||
import org.qortal.data.transaction.ChatTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transaction.ChatTransaction;
|
||||
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.transaction.ChatTransactionTransformer;
|
||||
import org.qortal.transform.transaction.TransactionTransformer;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
@Path("/chat")
|
||||
@Tag(name = "Chat")
|
||||
public class ChatResource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/messages")
|
||||
@Operation(
|
||||
summary = "Find chat messages",
|
||||
description = "Returns CHAT messages that match criteria. Must provide EITHER 'txGroupId' OR two 'involving' addresses.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "CHAT messages",
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = ChatMessage.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
public List<ChatMessage> searchChat(@QueryParam("before") Long before, @QueryParam("after") Long after,
|
||||
@QueryParam("txGroupId") Integer txGroupId,
|
||||
@QueryParam("involving") List<String> involvingAddresses,
|
||||
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
|
||||
// Check args meet expectations
|
||||
if ((txGroupId == null && involvingAddresses.size() != 2)
|
||||
|| (txGroupId != null && !involvingAddresses.isEmpty()))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Check any provided addresses are valid
|
||||
if (involvingAddresses.stream().anyMatch(address -> !Crypto.isValidAddress(address)))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (before != null && before < 1500000000000L)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (after != null && after < 1500000000000L)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getChatRepository().getMessagesMatchingCriteria(
|
||||
before,
|
||||
after,
|
||||
txGroupId,
|
||||
involvingAddresses,
|
||||
limit, offset, reverse);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/active/{address}")
|
||||
@Operation(
|
||||
summary = "Find active chats (group/direct) involving address",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = ActiveChats.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
public ActiveChats getActiveChats(@PathParam("address") String address) {
|
||||
if (address == null || !Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getChatRepository().getActiveChats(address);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Operation(
|
||||
summary = "Build raw, unsigned, CHAT transaction",
|
||||
description = "Builds a raw, unsigned CHAT transaction but does NOT compute proof-of-work nonce. See POST /chat/compute.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = ChatTransactionData.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "raw, unsigned, CHAT transaction encoded in Base58",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String buildChat(ChatTransactionData transactionData) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ChatTransaction chatTransaction = (ChatTransaction) Transaction.fromData(repository, transactionData);
|
||||
|
||||
ValidationResult result = chatTransaction.isValidUnconfirmed();
|
||||
if (result != ValidationResult.OK)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
byte[] bytes = ChatTransactionTransformer.toBytes(transactionData);
|
||||
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("/compute")
|
||||
@Operation(
|
||||
summary = "Compute nonce for raw, unsigned CHAT transaction",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
description = "raw, unsigned CHAT transaction in base58 encoding",
|
||||
example = "raw transaction base58"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "raw, unsigned, CHAT transaction encoded in Base58",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String buildChat(String rawBytes58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
byte[] rawBytes = Base58.decode(rawBytes58);
|
||||
// We're expecting unsigned transaction, so append empty signature prior to decoding
|
||||
rawBytes = Bytes.concat(rawBytes, new byte[TransactionTransformer.SIGNATURE_LENGTH]);
|
||||
|
||||
TransactionData transactionData = TransactionTransformer.fromBytes(rawBytes);
|
||||
if (transactionData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
if (transactionData.getType() != TransactionType.CHAT)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
|
||||
ChatTransaction chatTransaction = (ChatTransaction) Transaction.fromData(repository, transactionData);
|
||||
|
||||
// Quicker validity check first before we compute nonce
|
||||
ValidationResult result = chatTransaction.isValid();
|
||||
if (result != ValidationResult.OK)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
chatTransaction.computeNonce();
|
||||
|
||||
// Re-check, but ignores signature
|
||||
result = chatTransaction.isValidUnconfirmed();
|
||||
if (result != ValidationResult.OK)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
// Strip zeroed signature
|
||||
transactionData.setSignature(null);
|
||||
|
||||
byte[] bytes = ChatTransactionTransformer.toBytes(transactionData);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -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();
|
||||
}
|
||||
|
||||
}
|
418
src/main/java/org/qortal/api/resource/CrossChainResource.java
Normal file
418
src/main/java/org/qortal/api/resource/CrossChainResource.java
Normal file
@@ -0,0 +1,418 @@
|
||||
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.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
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.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.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.api.model.CrossChainCancelRequest;
|
||||
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.transaction.BaseTransactionData;
|
||||
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.MessageTransaction;
|
||||
import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.Transformer;
|
||||
import org.qortal.transform.transaction.MessageTransactionTransformer;
|
||||
import org.qortal.utils.Amounts;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.ByteArray;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
@Path("/crosschain")
|
||||
@Tag(name = "Cross-Chain")
|
||||
public class CrossChainResource {
|
||||
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/tradeoffers")
|
||||
@Operation(
|
||||
summary = "Find cross-chain trade offers",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = CrossChainTradeData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@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) {
|
||||
// Impose a limit on 'limit'
|
||||
if (limit != null && limit > 100)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
final boolean isExecutable = true;
|
||||
List<CrossChainTradeData> crossChainTradesData = new ArrayList<>();
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain);
|
||||
|
||||
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||
byte[] codeHash = acctInfo.getKey().value;
|
||||
ACCT acct = acctInfo.getValue().get();
|
||||
|
||||
List<ATData> atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse);
|
||||
|
||||
for (ATData atData : atsData) {
|
||||
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||
crossChainTradesData.add(crossChainTradeData);
|
||||
}
|
||||
}
|
||||
|
||||
return crossChainTradesData;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/trade/{ataddress}")
|
||||
@Operation(
|
||||
summary = "Show detailed trade info",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
implementation = CrossChainTradeData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@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 = repository.getATRepository().fromATAddress(atAddress);
|
||||
if (atData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||
|
||||
ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
|
||||
if (acct == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
return acct.populateTradeData(repository, atData);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/trades")
|
||||
@Operation(
|
||||
summary = "Find completed cross-chain trades",
|
||||
description = "Returns summary info about successfully completed cross-chain trades",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = CrossChainTradeSummary.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@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);
|
||||
|
||||
// minimumTimestamp (if given) needs to be positive
|
||||
if (minimumTimestamp != null && minimumTimestamp <= 0)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
final Boolean isFinished = Boolean.TRUE;
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Integer minimumFinalHeight = null;
|
||||
|
||||
if (minimumTimestamp != null) {
|
||||
minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(minimumTimestamp);
|
||||
|
||||
if (minimumFinalHeight == 0)
|
||||
// We don't have any blocks since minimumTimestamp, let alone trades, so nothing to return
|
||||
return Collections.emptyList();
|
||||
|
||||
// height returned from repository is for block BEFORE timestamp
|
||||
// but we want trades AFTER timestamp so bump height accordingly
|
||||
minimumFinalHeight++;
|
||||
}
|
||||
|
||||
List<CrossChainTradeSummary> crossChainTrades = new ArrayList<>();
|
||||
|
||||
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain);
|
||||
|
||||
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||
byte[] codeHash = acctInfo.getKey().value;
|
||||
ACCT acct = acctInfo.getValue().get();
|
||||
|
||||
List<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;
|
||||
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, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/tradeoffer")
|
||||
@Operation(
|
||||
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.<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(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CrossChainCancelRequest.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String cancelTrade(CrossChainCancelRequest cancelRequest) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
byte[] creatorPublicKey = cancelRequest.creatorPublicKey;
|
||||
|
||||
if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||
|
||||
if (cancelRequest.atAddress == null || !Crypto.isValidAtAddress(cancelRequest.atAddress))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress);
|
||||
|
||||
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
|
||||
|
||||
String atCreatorAddress = Crypto.toAddress(creatorPublicKey);
|
||||
byte[] messageData = acct.buildCancelMessage(atCreatorAddress);
|
||||
|
||||
byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.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);
|
||||
|
||||
// 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,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,12 @@ 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;
|
||||
|
||||
@@ -25,12 +29,19 @@ 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;
|
||||
|
||||
@Path("/peers")
|
||||
@Tag(name = "Peers")
|
||||
@@ -80,11 +91,7 @@ public class PeersResource {
|
||||
ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public List<PeerData> getKnownPeers() {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getNetworkRepository().getAllPeers();
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
return Network.getInstance().getAllKnownPeers();
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -108,6 +115,30 @@ public class PeersResource {
|
||||
return Network.getInstance().getSelfPeers();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/enginestats")
|
||||
@Operation(
|
||||
summary = "Fetch statistics snapshot for networking engine",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = ExecuteProduceConsume.StatsSnapshot.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public ExecuteProduceConsume.StatsSnapshot getEngineStats() {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
return Network.getInstance().getStatsSnapshot();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Operation(
|
||||
summary = "Add new peer address",
|
||||
@@ -139,21 +170,25 @@ public class PeersResource {
|
||||
@ApiErrors({
|
||||
ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String addPeer(String address) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
final Long addedWhen = NTP.getTime();
|
||||
if (addedWhen == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NO_TIME_SYNC);
|
||||
|
||||
try {
|
||||
PeerAddress peerAddress = PeerAddress.fromString(address);
|
||||
|
||||
PeerData peerData = new PeerData(peerAddress, System.currentTimeMillis(), "API");
|
||||
repository.getNetworkRepository().save(peerData);
|
||||
repository.saveChanges();
|
||||
List<PeerAddress> newPeerAddresses = new ArrayList<>(1);
|
||||
newPeerAddresses.add(peerAddress);
|
||||
|
||||
return "true";
|
||||
boolean addResult = Network.getInstance().mergePeers("API", addedWhen, newPeerAddresses);
|
||||
|
||||
return addResult ? "true" : "false";
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_NETWORK_ADDRESS);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -190,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);
|
||||
|
||||
@@ -225,6 +261,7 @@ public class PeersResource {
|
||||
@ApiErrors({
|
||||
ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String removeKnownPeers(String address) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -237,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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -363,6 +363,60 @@ public class TransactionsResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/creator/{publickey}")
|
||||
@Operation(
|
||||
summary = "Find matching transactions created by account with given public key",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "transactions",
|
||||
content = @Content(
|
||||
array = @ArraySchema(
|
||||
schema = @Schema(
|
||||
implementation = TransactionData.class
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({
|
||||
ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public List<TransactionData> findCreatorsTransactions(@PathParam("publickey") String publicKey58,
|
||||
@Parameter(
|
||||
description = "whether to include confirmed, unconfirmed or both",
|
||||
required = true
|
||||
) @QueryParam("confirmationStatus") ConfirmationStatus confirmationStatus, @Parameter(
|
||||
ref = "limit"
|
||||
) @QueryParam("limit") Integer limit, @Parameter(
|
||||
ref = "offset"
|
||||
) @QueryParam("offset") Integer offset, @Parameter(
|
||||
ref = "reverse"
|
||||
) @QueryParam("reverse") Boolean reverse) {
|
||||
// Decode public key
|
||||
byte[] publicKey;
|
||||
try {
|
||||
publicKey = Base58.decode(publicKey58);
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY, e);
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null,
|
||||
publicKey, confirmationStatus, limit, offset, reverse);
|
||||
|
||||
// Expand signatures to transactions
|
||||
List<TransactionData> transactions = new ArrayList<>(signatures.size());
|
||||
for (byte[] signature : signatures)
|
||||
transactions.add(repository.getTransactionRepository().fromSignature(signature));
|
||||
|
||||
return transactions;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/sign")
|
||||
@Operation(
|
||||
@@ -456,41 +510,39 @@ 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())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_SIGNATURE);
|
||||
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
if (!blockchainLock.tryLock(500, TimeUnit.MILLISECONDS))
|
||||
if (!blockchainLock.tryLock(30, TimeUnit.SECONDS))
|
||||
throw createTransactionInvalidException(request, ValidationResult.NO_BLOCKCHAIN_LOCK);
|
||||
|
||||
try {
|
||||
ValidationResult result = transaction.importAsUnconfirmed();
|
||||
if (result != ValidationResult.OK)
|
||||
throw createTransactionInvalidException(request, result);
|
||||
|
||||
// Notify controller of new transaction
|
||||
Controller.getInstance().onNewTransaction(transactionData);
|
||||
|
||||
return "true";
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -573,7 +625,7 @@ public class TransactionsResource {
|
||||
|
||||
public static ApiException createTransactionInvalidException(HttpServletRequest request, ValidationResult result) {
|
||||
String translatedResult = Translator.INSTANCE.translate("TransactionValidity", request.getLocale().getLanguage(), result.name());
|
||||
return ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID, null, translatedResult);
|
||||
return ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID, null, translatedResult, result.name());
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -0,0 +1,96 @@
|
||||
package org.qortal.api.websocket;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
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.WebSocketServletFactory;
|
||||
import org.qortal.controller.ChatNotifier;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.chat.ActiveChats;
|
||||
import org.qortal.data.transaction.ChatTransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class ActiveChatsWebSocket extends ApiWebSocket {
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
factory.register(ActiveChatsWebSocket.class);
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, String> pathParams = getPathParams(session, "/{address}");
|
||||
|
||||
String address = pathParams.get("address");
|
||||
if (address == null || !Crypto.isValidAddress(address)) {
|
||||
session.close(4001, "invalid address");
|
||||
return;
|
||||
}
|
||||
|
||||
AtomicReference<String> previousOutput = new AtomicReference<>(null);
|
||||
|
||||
ChatNotifier.Listener listener = chatTransactionData -> onNotify(session, chatTransactionData, address, previousOutput);
|
||||
ChatNotifier.getInstance().register(session, listener);
|
||||
|
||||
this.onNotify(session, null, address, previousOutput);
|
||||
}
|
||||
|
||||
@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) {
|
||||
// If CHAT has a recipient (i.e. direct message, not group-based) and we're neither sender nor recipient, then it's of no interest
|
||||
if (chatTransactionData != null) {
|
||||
String recipient = chatTransactionData.getRecipient();
|
||||
|
||||
if (recipient != null && (!recipient.equals(ourAddress) && !chatTransactionData.getSender().equals(ourAddress)))
|
||||
return;
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ActiveChats activeChats = repository.getChatRepository().getActiveChats(ourAddress);
|
||||
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
|
||||
marshall(stringWriter, activeChats);
|
||||
|
||||
// Only output if something has changed
|
||||
String output = stringWriter.toString();
|
||||
if (output.equals(previousOutput.get()))
|
||||
return;
|
||||
|
||||
previousOutput.set(output);
|
||||
session.getRemote().sendStringByFuture(output);
|
||||
} catch (DataException | IOException | WebSocketException e) {
|
||||
// No output this time?
|
||||
}
|
||||
}
|
||||
|
||||
}
|
101
src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java
Normal file
101
src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java
Normal file
@@ -0,0 +1,101 @@
|
||||
package org.qortal.api.websocket;
|
||||
|
||||
import java.io.IOException;
|
||||
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.WebSocketServletFactory;
|
||||
import org.qortal.api.model.NodeStatus;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.event.Event;
|
||||
import org.qortal.event.EventBus;
|
||||
import org.qortal.event.Listener;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class AdminStatusWebSocket extends ApiWebSocket 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) {
|
||||
this.sendStatus(session, previousOutput.get());
|
||||
|
||||
super.onWebSocketConnect(session);
|
||||
}
|
||||
|
||||
@OnWebSocketClose
|
||||
@Override
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
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 static String buildStatusString() throws IOException {
|
||||
NodeStatus nodeStatus = new NodeStatus();
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
marshall(stringWriter, nodeStatus);
|
||||
return stringWriter.toString();
|
||||
}
|
||||
|
||||
private void sendStatus(Session session, String status) {
|
||||
try {
|
||||
session.getRemote().sendStringByFuture(status);
|
||||
} catch (WebSocketException e) {
|
||||
// No output this time?
|
||||
}
|
||||
}
|
||||
|
||||
}
|
122
src/main/java/org/qortal/api/websocket/ApiWebSocket.java
Normal file
122
src/main/java/org/qortal/api/websocket/ApiWebSocket.java
Normal file
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
import javax.xml.bind.JAXBException;
|
||||
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;
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
abstract class ApiWebSocket extends WebSocketServlet {
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
protected static Map<String, String> getPathParams(Session session, String pathSpec) {
|
||||
UriTemplatePathSpec uriTemplatePathSpec = new UriTemplatePathSpec(pathSpec);
|
||||
return uriTemplatePathSpec.getPathParams(getPathInfo(session));
|
||||
}
|
||||
|
||||
protected static void sendError(Session session, ApiError apiError) {
|
||||
ApiErrorRoot apiErrorRoot = new ApiErrorRoot();
|
||||
apiErrorRoot.setApiError(apiError);
|
||||
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
try {
|
||||
marshall(stringWriter, apiErrorRoot);
|
||||
session.getRemote().sendString(stringWriter.toString());
|
||||
} catch (IOException e) {
|
||||
// Remote end probably closed
|
||||
}
|
||||
}
|
||||
|
||||
protected static void marshall(Writer writer, Object object) throws IOException {
|
||||
Marshaller marshaller = createMarshaller(object.getClass());
|
||||
|
||||
try {
|
||||
marshaller.marshal(object, writer);
|
||||
} catch (JAXBException e) {
|
||||
throw new IOException("Unable to create marshall object for websocket", e);
|
||||
}
|
||||
}
|
||||
|
||||
protected static void marshall(Writer writer, Collection<?> collection) throws IOException {
|
||||
// If collection is empty then we're returning "[]" anyway
|
||||
if (collection.isEmpty()) {
|
||||
writer.append("[]");
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab an entry from collection so we can determine type
|
||||
Object entry = collection.iterator().next();
|
||||
|
||||
Marshaller marshaller = createMarshaller(entry.getClass());
|
||||
|
||||
try {
|
||||
marshaller.marshal(collection, writer);
|
||||
} catch (JAXBException e) {
|
||||
throw new IOException("Unable to create marshall object for websocket", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Marshaller createMarshaller(Class<?> objectClass) {
|
||||
try {
|
||||
// Create JAXB context aware of object's class
|
||||
JAXBContext jc = JAXBContextFactory.createContext(new Class[] { objectClass }, null);
|
||||
|
||||
// Create marshaller
|
||||
Marshaller marshaller = jc.createMarshaller();
|
||||
|
||||
// Set the marshaller media type to JSON
|
||||
marshaller.setProperty(MarshallerProperties.MEDIA_TYPE, "application/json");
|
||||
|
||||
// Tell marshaller not to include JSON root element in the output
|
||||
marshaller.setProperty(MarshallerProperties.JSON_INCLUDE_ROOT, false);
|
||||
|
||||
return marshaller;
|
||||
} catch (JAXBException e) {
|
||||
throw new RuntimeException("Unable to create websocket marshaller", e);
|
||||
}
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
141
src/main/java/org/qortal/api/websocket/BlocksWebSocket.java
Normal file
141
src/main/java/org/qortal/api/websocket/BlocksWebSocket.java
Normal file
@@ -0,0 +1,141 @@
|
||||
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.WebSocketServletFactory;
|
||||
import org.qortal.api.ApiError;
|
||||
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;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
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) {
|
||||
super.onWebSocketConnect(session);
|
||||
}
|
||||
|
||||
@OnWebSocketClose
|
||||
@Override
|
||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||
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) {
|
||||
// We're expecting either a base58 block signature or an integer block height
|
||||
if (message.length() > 128) {
|
||||
// Try base58 block signature
|
||||
byte[] signature;
|
||||
|
||||
try {
|
||||
signature = Base58.decode(message);
|
||||
} catch (NumberFormatException e) {
|
||||
sendError(session, ApiError.INVALID_SIGNATURE);
|
||||
return;
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int height = repository.getBlockRepository().getHeightFromSignature(signature);
|
||||
if (height == 0) {
|
||||
sendError(session, ApiError.BLOCK_UNKNOWN);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.length() > 10)
|
||||
// Bigger than max integer value, so probably a ping - silently ignore
|
||||
return;
|
||||
|
||||
// Try integer
|
||||
int height;
|
||||
|
||||
try {
|
||||
height = Integer.parseInt(message);
|
||||
} catch (NumberFormatException e) {
|
||||
sendError(session, ApiError.INVALID_HEIGHT);
|
||||
return;
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendBlockSummary(Session session, BlockSummaryData blockSummary) {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
|
||||
try {
|
||||
marshall(stringWriter, blockSummary);
|
||||
|
||||
session.getRemote().sendStringByFuture(stringWriter.toString());
|
||||
} catch (IOException | WebSocketException e) {
|
||||
// No output this time
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,156 @@
|
||||
package org.qortal.api.websocket;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
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.WebSocketServletFactory;
|
||||
import org.qortal.controller.ChatNotifier;
|
||||
import org.qortal.data.chat.ChatMessage;
|
||||
import org.qortal.data.transaction.ChatTransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
|
||||
@Override
|
||||
public void configure(WebSocketServletFactory factory) {
|
||||
factory.register(ChatMessagesWebSocket.class);
|
||||
}
|
||||
|
||||
@OnWebSocketConnect
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
|
||||
List<String> txGroupIds = queryParams.get("txGroupId");
|
||||
if (txGroupIds != null && txGroupIds.size() == 1) {
|
||||
int txGroupId = Integer.parseInt(txGroupIds.get(0));
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<ChatMessage> chatMessages = repository.getChatRepository().getMessagesMatchingCriteria(
|
||||
null,
|
||||
null,
|
||||
txGroupId,
|
||||
null,
|
||||
null, null, null);
|
||||
|
||||
sendMessages(session, chatMessages);
|
||||
} catch (DataException e) {
|
||||
// Not a good start
|
||||
session.close(4001, "Couldn't fetch initial messages from repository");
|
||||
return;
|
||||
}
|
||||
|
||||
ChatNotifier.Listener listener = chatTransactionData -> onNotify(session, chatTransactionData, txGroupId);
|
||||
ChatNotifier.getInstance().register(session, listener);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> involvingAddresses = queryParams.get("involving");
|
||||
if (involvingAddresses == null || involvingAddresses.size() != 2) {
|
||||
session.close(4001, "invalid criteria");
|
||||
return;
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<ChatMessage> chatMessages = repository.getChatRepository().getMessagesMatchingCriteria(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
involvingAddresses,
|
||||
null, null, null);
|
||||
|
||||
sendMessages(session, chatMessages);
|
||||
} catch (DataException e) {
|
||||
// Not a good start
|
||||
session.close(4001, "Couldn't fetch initial messages from repository");
|
||||
return;
|
||||
}
|
||||
|
||||
ChatNotifier.Listener listener = chatTransactionData -> onNotify(session, chatTransactionData, involvingAddresses);
|
||||
ChatNotifier.getInstance().register(session, listener);
|
||||
}
|
||||
|
||||
@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) {
|
||||
if (chatTransactionData == null)
|
||||
// There has been a group-membership change, but we're not interested
|
||||
return;
|
||||
|
||||
// We only want group-based messages with our txGroupId
|
||||
if (chatTransactionData.getRecipient() != null || chatTransactionData.getTxGroupId() != txGroupId)
|
||||
return;
|
||||
|
||||
sendChat(session, chatTransactionData);
|
||||
}
|
||||
|
||||
private void onNotify(Session session, ChatTransactionData chatTransactionData, List<String> involvingAddresses) {
|
||||
// We only want direct/non-group messages where sender/recipient match our addresses
|
||||
String recipient = chatTransactionData.getRecipient();
|
||||
if (recipient == null)
|
||||
return;
|
||||
|
||||
List<String> transactionAddresses = Arrays.asList(recipient, chatTransactionData.getSender());
|
||||
|
||||
if (!transactionAddresses.containsAll(involvingAddresses))
|
||||
return;
|
||||
|
||||
sendChat(session, chatTransactionData);
|
||||
}
|
||||
|
||||
private void sendMessages(Session session, List<ChatMessage> chatMessages) {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
|
||||
try {
|
||||
marshall(stringWriter, chatMessages);
|
||||
|
||||
session.getRemote().sendStringByFuture(stringWriter.toString());
|
||||
} catch (IOException | WebSocketException e) {
|
||||
// No output this time?
|
||||
}
|
||||
}
|
||||
|
||||
private void sendChat(Session session, ChatTransactionData chatTransactionData) {
|
||||
// Convert ChatTransactionData to ChatMessage
|
||||
ChatMessage chatMessage;
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
chatMessage = repository.getChatRepository().toChatMessage(chatTransactionData);
|
||||
} catch (DataException e) {
|
||||
// No output this time?
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessages(session, Collections.singletonList(chatMessage));
|
||||
}
|
||||
|
||||
}
|
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;
|
||||
}
|
||||
|
||||
}
|
@@ -1,11 +1,13 @@
|
||||
package org.qortal.asset;
|
||||
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.asset.AssetData;
|
||||
import org.qortal.data.transaction.IssueAssetTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.data.transaction.UpdateAssetTransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.utils.Amounts;
|
||||
|
||||
public class Asset {
|
||||
|
||||
@@ -21,12 +23,12 @@ public class Asset {
|
||||
|
||||
// Other useful constants
|
||||
|
||||
public static final int MAX_NAME_SIZE = 400;
|
||||
public static final int MIN_NAME_SIZE = 3;
|
||||
public static final int MAX_NAME_SIZE = 40;
|
||||
public static final int MAX_DESCRIPTION_SIZE = 4000;
|
||||
public static final int MAX_DATA_SIZE = 400000;
|
||||
|
||||
public static final long MAX_DIVISIBLE_QUANTITY = 10_000_000_000L; // but also to 8 decimal places
|
||||
public static final long MAX_INDIVISIBLE_QUANTITY = 1_000_000_000_000_000_000L;
|
||||
public static final long MAX_QUANTITY = 10_000_000_000L * Amounts.MULTIPLIER; // but also to 8 decimal places
|
||||
|
||||
// Properties
|
||||
private Repository repository;
|
||||
@@ -42,12 +44,14 @@ public class Asset {
|
||||
public Asset(Repository repository, IssueAssetTransactionData issueAssetTransactionData) {
|
||||
this.repository = repository;
|
||||
|
||||
String ownerAddress = Crypto.toAddress(issueAssetTransactionData.getCreatorPublicKey());
|
||||
|
||||
// NOTE: transaction's reference is used to look up newly assigned assetID on creation!
|
||||
this.assetData = new AssetData(issueAssetTransactionData.getOwner(), issueAssetTransactionData.getAssetName(),
|
||||
this.assetData = new AssetData(ownerAddress, issueAssetTransactionData.getAssetName(),
|
||||
issueAssetTransactionData.getDescription(), issueAssetTransactionData.getQuantity(),
|
||||
issueAssetTransactionData.getIsDivisible(), issueAssetTransactionData.getData(),
|
||||
issueAssetTransactionData.getIsUnspendable(),
|
||||
issueAssetTransactionData.getTxGroupId(), issueAssetTransactionData.getSignature());
|
||||
issueAssetTransactionData.isDivisible(), issueAssetTransactionData.getData(),
|
||||
issueAssetTransactionData.isUnspendable(), issueAssetTransactionData.getTxGroupId(),
|
||||
issueAssetTransactionData.getSignature(), issueAssetTransactionData.getReducedAssetName());
|
||||
}
|
||||
|
||||
public Asset(Repository repository, long assetId) throws DataException {
|
||||
@@ -118,10 +122,11 @@ public class Asset {
|
||||
throw new IllegalStateException("Missing referenced transaction when orphaning UPDATE_ASSET");
|
||||
|
||||
switch (previousTransactionData.getType()) {
|
||||
case ISSUE_ASSET:
|
||||
case ISSUE_ASSET: {
|
||||
IssueAssetTransactionData previousIssueAssetTransactionData = (IssueAssetTransactionData) previousTransactionData;
|
||||
|
||||
this.assetData.setOwner(previousIssueAssetTransactionData.getOwner());
|
||||
String ownerAddress = Crypto.toAddress(previousIssueAssetTransactionData.getCreatorPublicKey());
|
||||
this.assetData.setOwner(ownerAddress);
|
||||
|
||||
if (needDescription) {
|
||||
this.assetData.setDescription(previousIssueAssetTransactionData.getDescription());
|
||||
@@ -133,8 +138,9 @@ public class Asset {
|
||||
needData = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UPDATE_ASSET:
|
||||
case UPDATE_ASSET: {
|
||||
UpdateAssetTransactionData previousUpdateAssetTransactionData = (UpdateAssetTransactionData) previousTransactionData;
|
||||
|
||||
this.assetData.setOwner(previousUpdateAssetTransactionData.getNewOwner());
|
||||
@@ -152,7 +158,9 @@ public class Asset {
|
||||
// Get signature for previous transaction in chain, just in case we need it
|
||||
if (needDescription || needData)
|
||||
previousTransactionSignature = previousUpdateAssetTransactionData.getOrphanReference();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new IllegalStateException("Invalid referenced transaction when orphaning UPDATE_ASSET");
|
||||
|
@@ -1,8 +1,8 @@
|
||||
package org.qortal.asset;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import static org.qortal.utils.Amounts.prettyAmount;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@@ -11,13 +11,12 @@ import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.account.PublicKeyAccount;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.data.asset.AssetData;
|
||||
import org.qortal.data.asset.OrderData;
|
||||
import org.qortal.data.asset.TradeData;
|
||||
import org.qortal.repository.AssetRepository;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.utils.Amounts;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
public class Order {
|
||||
@@ -29,9 +28,11 @@ public class Order {
|
||||
private OrderData orderData;
|
||||
|
||||
// Used quite a bit
|
||||
private final boolean isOurOrderNewPricing;
|
||||
private final long haveAssetId;
|
||||
private final long wantAssetId;
|
||||
private final boolean isAmountInWantAsset;
|
||||
private final BigInteger orderAmount;
|
||||
private final BigInteger orderPrice;
|
||||
|
||||
/** Cache of price-pair units e.g. QORT/GOLD, but use getPricePair() instead! */
|
||||
private String cachedPricePair;
|
||||
@@ -47,9 +48,12 @@ public class Order {
|
||||
this.repository = repository;
|
||||
this.orderData = orderData;
|
||||
|
||||
this.isOurOrderNewPricing = this.orderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp();
|
||||
this.haveAssetId = this.orderData.getHaveAssetId();
|
||||
this.wantAssetId = this.orderData.getWantAssetId();
|
||||
this.isAmountInWantAsset = haveAssetId < wantAssetId;
|
||||
|
||||
this.orderAmount = BigInteger.valueOf(this.orderData.getAmount());
|
||||
this.orderPrice = BigInteger.valueOf(this.orderData.getPrice());
|
||||
}
|
||||
|
||||
// Getters/Setters
|
||||
@@ -60,16 +64,16 @@ public class Order {
|
||||
|
||||
// More information
|
||||
|
||||
public static BigDecimal getAmountLeft(OrderData orderData) {
|
||||
return orderData.getAmount().subtract(orderData.getFulfilled());
|
||||
public static long getAmountLeft(OrderData orderData) {
|
||||
return orderData.getAmount() - orderData.getFulfilled();
|
||||
}
|
||||
|
||||
public BigDecimal getAmountLeft() {
|
||||
public long getAmountLeft() {
|
||||
return Order.getAmountLeft(this.orderData);
|
||||
}
|
||||
|
||||
public static boolean isFulfilled(OrderData orderData) {
|
||||
return orderData.getFulfilled().compareTo(orderData.getAmount()) == 0;
|
||||
return orderData.getFulfilled() == orderData.getAmount();
|
||||
}
|
||||
|
||||
public boolean isFulfilled() {
|
||||
@@ -86,13 +90,10 @@ public class Order {
|
||||
* <p>
|
||||
* @return granularity of matched-amount
|
||||
*/
|
||||
public static BigDecimal calculateAmountGranularity(boolean isAmountAssetDivisible, boolean isReturnAssetDivisible, BigDecimal price) {
|
||||
// Multiplier to scale BigDecimal fractional amounts into integer domain
|
||||
BigInteger multiplier = BigInteger.valueOf(1_0000_0000L);
|
||||
|
||||
public static long calculateAmountGranularity(boolean isAmountAssetDivisible, boolean isReturnAssetDivisible, long price) {
|
||||
// Calculate the minimum increment for matched-amount using greatest-common-divisor
|
||||
BigInteger returnAmount = multiplier; // 1 unit (* multiplier)
|
||||
BigInteger matchedAmount = price.movePointRight(8).toBigInteger();
|
||||
BigInteger returnAmount = Amounts.MULTIPLIER_BI; // 1 unit * multiplier
|
||||
BigInteger matchedAmount = BigInteger.valueOf(price);
|
||||
|
||||
BigInteger gcd = returnAmount.gcd(matchedAmount);
|
||||
returnAmount = returnAmount.divide(gcd);
|
||||
@@ -100,20 +101,20 @@ public class Order {
|
||||
|
||||
// Calculate GCD in combination with divisibility
|
||||
if (isAmountAssetDivisible)
|
||||
returnAmount = returnAmount.multiply(multiplier);
|
||||
returnAmount = returnAmount.multiply(Amounts.MULTIPLIER_BI);
|
||||
|
||||
if (isReturnAssetDivisible)
|
||||
matchedAmount = matchedAmount.multiply(multiplier);
|
||||
matchedAmount = matchedAmount.multiply(Amounts.MULTIPLIER_BI);
|
||||
|
||||
gcd = returnAmount.gcd(matchedAmount);
|
||||
|
||||
// Calculate the granularity at which we have to buy
|
||||
BigDecimal granularity = new BigDecimal(returnAmount.divide(gcd));
|
||||
BigInteger granularity = returnAmount.multiply(Amounts.MULTIPLIER_BI).divide(gcd);
|
||||
if (isAmountAssetDivisible)
|
||||
granularity = granularity.movePointLeft(8);
|
||||
granularity = granularity.divide(Amounts.MULTIPLIER_BI);
|
||||
|
||||
// Return
|
||||
return granularity;
|
||||
return granularity.longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +131,7 @@ public class Order {
|
||||
|
||||
/** Calculate price pair. (e.g. QORT/GOLD)
|
||||
* <p>
|
||||
* Under 'new' pricing scheme, lowest-assetID asset is first,
|
||||
* Lowest-assetID asset is first,
|
||||
* so if QORT has assetID 0 and GOLD has assetID 10, then
|
||||
* the pricing pair is QORT/GOLD.
|
||||
* <p>
|
||||
@@ -141,32 +142,32 @@ public class Order {
|
||||
AssetData haveAssetData = getHaveAsset();
|
||||
AssetData wantAssetData = getWantAsset();
|
||||
|
||||
if (isOurOrderNewPricing && haveAssetId > wantAssetId)
|
||||
if (haveAssetId > wantAssetId)
|
||||
cachedPricePair = wantAssetData.getName() + "/" + haveAssetData.getName();
|
||||
else
|
||||
cachedPricePair = haveAssetData.getName() + "/" + wantAssetData.getName();
|
||||
}
|
||||
|
||||
/** Returns amount of have-asset to remove from order's creator's balance on placing this order. */
|
||||
private BigDecimal calcHaveAssetCommittment() {
|
||||
BigDecimal committedCost = this.orderData.getAmount();
|
||||
private long calcHaveAssetCommittment() {
|
||||
// Simple case: amount is in have asset
|
||||
if (!this.isAmountInWantAsset)
|
||||
return this.orderData.getAmount();
|
||||
|
||||
// If 'new' pricing and "amount" is in want-asset then we need to convert
|
||||
if (isOurOrderNewPricing && haveAssetId < wantAssetId)
|
||||
committedCost = committedCost.multiply(this.orderData.getPrice()).setScale(8, RoundingMode.HALF_UP);
|
||||
return Amounts.roundUpScaledMultiply(this.orderAmount, this.orderPrice);
|
||||
}
|
||||
|
||||
return committedCost;
|
||||
private long calcHaveAssetRefund(long amount) {
|
||||
// Simple case: amount is in have asset
|
||||
if (!this.isAmountInWantAsset)
|
||||
return amount;
|
||||
|
||||
return Amounts.roundUpScaledMultiply(BigInteger.valueOf(amount), this.orderPrice);
|
||||
}
|
||||
|
||||
/** Returns amount of remaining have-asset to refund to order's creator's balance on cancelling this order. */
|
||||
private BigDecimal calcHaveAssetRefund() {
|
||||
BigDecimal refund = getAmountLeft();
|
||||
|
||||
// If 'new' pricing and "amount" is in want-asset then we need to convert
|
||||
if (isOurOrderNewPricing && haveAssetId < wantAssetId)
|
||||
refund = refund.multiply(this.orderData.getPrice()).setScale(8, RoundingMode.HALF_UP);
|
||||
|
||||
return refund;
|
||||
private long calcHaveAssetRefund() {
|
||||
return calcHaveAssetRefund(getAmountLeft());
|
||||
}
|
||||
|
||||
// Navigation
|
||||
@@ -192,27 +193,19 @@ public class Order {
|
||||
/**
|
||||
* Returns AssetData for asset in effect for "amount" field.
|
||||
* <p>
|
||||
* For 'old' pricing, this is the have-asset.<br>
|
||||
* For 'new' pricing, this is the asset with highest assetID.
|
||||
* This is the asset with highest assetID.
|
||||
*/
|
||||
public AssetData getAmountAsset() throws DataException {
|
||||
if (isOurOrderNewPricing && wantAssetId > haveAssetId)
|
||||
return getWantAsset();
|
||||
else
|
||||
return getHaveAsset();
|
||||
return (wantAssetId > haveAssetId) ? getWantAsset() : getHaveAsset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns AssetData for other (return) asset traded.
|
||||
* <p>
|
||||
* For 'old' pricing, this is the want-asset.<br>
|
||||
* For 'new' pricing, this is the asset with lowest assetID.
|
||||
* This is the asset with lowest assetID.
|
||||
*/
|
||||
public AssetData getReturnAsset() throws DataException {
|
||||
if (isOurOrderNewPricing && haveAssetId < wantAssetId)
|
||||
return getHaveAsset();
|
||||
else
|
||||
return getWantAsset();
|
||||
return (haveAssetId < wantAssetId) ? getHaveAsset() : getWantAsset();
|
||||
}
|
||||
|
||||
// Processing
|
||||
@@ -227,8 +220,6 @@ public class Order {
|
||||
|
||||
// NOTE: the following values are specific to passed orderData, not the same as class instance values!
|
||||
|
||||
final boolean isOrderNewAssetPricing = orderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp();
|
||||
|
||||
// Cached for readability
|
||||
final long _haveAssetId = orderData.getHaveAssetId();
|
||||
final long _wantAssetId = orderData.getWantAssetId();
|
||||
@@ -236,43 +227,36 @@ public class Order {
|
||||
final AssetData haveAssetData = this.repository.getAssetRepository().fromAssetId(_haveAssetId);
|
||||
final AssetData wantAssetData = this.repository.getAssetRepository().fromAssetId(_wantAssetId);
|
||||
|
||||
final long amountAssetId = (isOurOrderNewPricing && _wantAssetId > _haveAssetId) ? _wantAssetId : _haveAssetId;
|
||||
final long returnAssetId = (isOurOrderNewPricing && _haveAssetId < _wantAssetId) ? _haveAssetId : _wantAssetId;
|
||||
final long amountAssetId = (_wantAssetId > _haveAssetId) ? _wantAssetId : _haveAssetId;
|
||||
final long returnAssetId = (_haveAssetId < _wantAssetId) ? _haveAssetId : _wantAssetId;
|
||||
|
||||
final AssetData amountAssetData = this.repository.getAssetRepository().fromAssetId(amountAssetId);
|
||||
final AssetData returnAssetData = this.repository.getAssetRepository().fromAssetId(returnAssetId);
|
||||
|
||||
LOGGER.debug(String.format("%s %s", orderPrefix, Base58.encode(orderData.getOrderId())));
|
||||
LOGGER.debug(() -> String.format("%s %s", orderPrefix, Base58.encode(orderData.getOrderId())));
|
||||
|
||||
LOGGER.trace(String.format("%s have %s, want %s. '%s' pricing scheme.", weThey, haveAssetData.getName(), wantAssetData.getName(), isOrderNewAssetPricing ? "new" : "old"));
|
||||
LOGGER.trace(() -> String.format("%s have %s, want %s.", weThey, haveAssetData.getName(), wantAssetData.getName()));
|
||||
|
||||
LOGGER.trace(String.format("%s amount: %s (ordered) - %s (fulfilled) = %s %s left", ourTheir,
|
||||
orderData.getAmount().stripTrailingZeros().toPlainString(),
|
||||
orderData.getFulfilled().stripTrailingZeros().toPlainString(),
|
||||
Order.getAmountLeft(orderData).stripTrailingZeros().toPlainString(),
|
||||
LOGGER.trace(() -> String.format("%s amount: %s (ordered) - %s (fulfilled) = %s %s left", ourTheir,
|
||||
prettyAmount(orderData.getAmount()),
|
||||
prettyAmount(orderData.getFulfilled()),
|
||||
prettyAmount(Order.getAmountLeft(orderData)),
|
||||
amountAssetData.getName()));
|
||||
|
||||
BigDecimal maxReturnAmount = Order.getAmountLeft(orderData).multiply(orderData.getPrice()).setScale(8, RoundingMode.HALF_UP);
|
||||
long maxReturnAmount = Amounts.roundUpScaledMultiply(Order.getAmountLeft(orderData), orderData.getPrice());
|
||||
String pricePair = getPricePair();
|
||||
|
||||
LOGGER.trace(String.format("%s price: %s %s (%s %s tradable)", ourTheir,
|
||||
orderData.getPrice().toPlainString(), getPricePair(),
|
||||
maxReturnAmount.stripTrailingZeros().toPlainString(), returnAssetData.getName()));
|
||||
LOGGER.trace(() -> String.format("%s price: %s %s (%s %s tradable)", ourTheir,
|
||||
prettyAmount(orderData.getPrice()),
|
||||
pricePair,
|
||||
prettyAmount(maxReturnAmount),
|
||||
returnAssetData.getName()));
|
||||
}
|
||||
|
||||
public void process() throws DataException {
|
||||
AssetRepository assetRepository = this.repository.getAssetRepository();
|
||||
|
||||
AssetData haveAssetData = getHaveAsset();
|
||||
AssetData wantAssetData = getWantAsset();
|
||||
|
||||
/** The asset while working out amount that matches. */
|
||||
AssetData matchingAssetData = isOurOrderNewPricing ? getAmountAsset() : wantAssetData;
|
||||
/** The return asset traded if trade completes. */
|
||||
AssetData returnAssetData = isOurOrderNewPricing ? getReturnAsset() : haveAssetData;
|
||||
|
||||
// Subtract have-asset from creator
|
||||
Account creator = new PublicKeyAccount(this.repository, this.orderData.getCreatorPublicKey());
|
||||
creator.setConfirmedBalance(haveAssetId, creator.getConfirmedBalance(haveAssetId).subtract(this.calcHaveAssetCommittment()));
|
||||
creator.modifyAssetBalance(haveAssetId, - this.calcHaveAssetCommittment());
|
||||
|
||||
// Save this order into repository so it's available for matching, possibly by itself
|
||||
this.repository.getAssetRepository().save(this.orderData);
|
||||
@@ -281,36 +265,28 @@ public class Order {
|
||||
|
||||
// Fetch corresponding open orders that might potentially match, hence reversed want/have assetIDs.
|
||||
// Returned orders are sorted with lowest "price" first.
|
||||
List<OrderData> orders = assetRepository.getOpenOrdersForTrading(wantAssetId, haveAssetId, isOurOrderNewPricing ? this.orderData.getPrice() : null);
|
||||
LOGGER.trace("Open orders fetched from repository: " + orders.size());
|
||||
List<OrderData> orders = this.repository.getAssetRepository().getOpenOrdersForTrading(wantAssetId, haveAssetId, this.orderData.getPrice());
|
||||
LOGGER.trace(() -> String.format("Open orders fetched from repository: %d", orders.size()));
|
||||
|
||||
if (orders.isEmpty())
|
||||
return;
|
||||
|
||||
matchOrders(orders);
|
||||
}
|
||||
|
||||
private void matchOrders(List<OrderData> orders) throws DataException {
|
||||
AssetData haveAssetData = getHaveAsset();
|
||||
AssetData wantAssetData = getWantAsset();
|
||||
|
||||
/** The asset while working out amount that matches. */
|
||||
AssetData matchingAssetData = getAmountAsset();
|
||||
/** The return asset traded if trade completes. */
|
||||
AssetData returnAssetData = getReturnAsset();
|
||||
|
||||
// Attempt to match orders
|
||||
|
||||
/*
|
||||
* Potential matching order example ("old"):
|
||||
*
|
||||
* Our order:
|
||||
* haveAssetId=[GOLD], wantAssetId=0 (QORT), amount=40 (GOLD), price=486 (QORT/GOLD)
|
||||
* This translates to "we have 40 GOLD and want QORT at a price of 486 QORT per GOLD"
|
||||
* If our order matched, we'd end up with 40 * 486 = 19,440 QORT.
|
||||
*
|
||||
* Their order:
|
||||
* haveAssetId=0 (QORT), wantAssetId=[GOLD], amount=20,000 (QORT), price=0.00205761 (GOLD/QORT)
|
||||
* This translates to "they have 20,000 QORT and want GOLD at a price of 0.00205761 GOLD per QORT"
|
||||
*
|
||||
* Their price, converted into 'our' units of QORT/GOLD, is: 1 / 0.00205761 = 486.00074844 QORT/GOLD.
|
||||
* This is better than our requested 486 QORT/GOLD so this order matches.
|
||||
*
|
||||
* Using their price, we end up with 40 * 486.00074844 = 19440.02993760 QORT. They end up with 40 GOLD.
|
||||
*
|
||||
* If their order had 19,440 QORT left, only 19,440 * 0.00205761 = 39.99993840 GOLD would be traded.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Potential matching order example ("new"):
|
||||
* Potential matching order example:
|
||||
*
|
||||
* Our order:
|
||||
* haveAssetId=[GOLD], wantAssetId=0 (QORT), amount=40 (GOLD), price=486 (QORT/GOLD)
|
||||
@@ -328,129 +304,107 @@ public class Order {
|
||||
* If their order only had 36 GOLD left, only 36 * 486.00074844 = 17496.02694384 QORT would be traded.
|
||||
*/
|
||||
|
||||
BigDecimal ourPrice = this.orderData.getPrice();
|
||||
long ourPrice = this.orderData.getPrice();
|
||||
String pricePair = getPricePair();
|
||||
|
||||
for (OrderData theirOrderData : orders) {
|
||||
logOrder("Considering order", false, theirOrderData);
|
||||
|
||||
// Not used:
|
||||
// boolean isTheirOrderNewAssetPricing = theirOrderData.getTimestamp() >= BlockChain.getInstance().getNewAssetPricingTimestamp();
|
||||
|
||||
// Determine their order price
|
||||
BigDecimal theirPrice;
|
||||
|
||||
if (isOurOrderNewPricing) {
|
||||
// Pricing units are the same way round for both orders, so no conversion needed.
|
||||
// Orders under 'old' pricing have been converted during repository update.
|
||||
theirPrice = theirOrderData.getPrice();
|
||||
LOGGER.trace(String.format("Their price: %s %s", theirPrice.toPlainString(), getPricePair()));
|
||||
} else {
|
||||
// If our order is 'old' pricing then all other existing orders must be 'old' pricing too
|
||||
// Their order pricing will be inverted, so convert
|
||||
theirPrice = BigDecimal.ONE.setScale(8).divide(theirOrderData.getPrice(), RoundingMode.DOWN);
|
||||
LOGGER.trace(String.format("Their price: %s %s per %s", theirPrice.toPlainString(), wantAssetData.getName(), haveAssetData.getName()));
|
||||
}
|
||||
long theirPrice = theirOrderData.getPrice();
|
||||
LOGGER.trace(() -> String.format("Their price: %s %s", prettyAmount(theirPrice), pricePair));
|
||||
|
||||
// If their price is worse than what we're willing to accept then we're done as prices only get worse as we iterate through list of orders
|
||||
if (isOurOrderNewPricing) {
|
||||
if (haveAssetId < wantAssetId && theirPrice.compareTo(ourPrice) > 0)
|
||||
break;
|
||||
if (haveAssetId > wantAssetId && theirPrice.compareTo(ourPrice) < 0)
|
||||
break;
|
||||
} else {
|
||||
// 'old' pricing scheme
|
||||
if (theirPrice.compareTo(ourPrice) < 0)
|
||||
break;
|
||||
}
|
||||
if ((haveAssetId < wantAssetId && theirPrice > ourPrice) || (haveAssetId > wantAssetId && theirPrice < ourPrice))
|
||||
break;
|
||||
|
||||
// Calculate how much we could buy at their price.
|
||||
BigDecimal ourMaxAmount;
|
||||
if (isOurOrderNewPricing)
|
||||
// In 'new' pricing scheme, "amount" is expressed in terms of asset with highest assetID
|
||||
ourMaxAmount = this.getAmountLeft();
|
||||
else
|
||||
// In 'old' pricing scheme, "amount" is expressed in terms of our want-asset.
|
||||
ourMaxAmount = this.getAmountLeft().multiply(theirPrice).setScale(8, RoundingMode.DOWN);
|
||||
LOGGER.trace("ourMaxAmount (max we could trade at their price): " + ourMaxAmount.stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName());
|
||||
// Calculate how much we could buy at their price, "amount" is expressed in terms of asset with highest assetID.
|
||||
long ourMaxAmount = this.getAmountLeft();
|
||||
LOGGER.trace(() -> String.format("ourMaxAmount (max we could trade at their price): %s %s", prettyAmount(ourMaxAmount), matchingAssetData.getName()));
|
||||
|
||||
// How much is remaining available in their order.
|
||||
BigDecimal theirAmountLeft = Order.getAmountLeft(theirOrderData);
|
||||
LOGGER.trace("theirAmountLeft (max amount remaining in their order): " + theirAmountLeft.stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName());
|
||||
long theirAmountLeft = Order.getAmountLeft(theirOrderData);
|
||||
LOGGER.trace(() -> String.format("theirAmountLeft (max amount remaining in their order): %s %s", prettyAmount(theirAmountLeft), matchingAssetData.getName()));
|
||||
|
||||
// So matchable want-asset amount is the minimum of above two values
|
||||
BigDecimal matchedAmount = ourMaxAmount.min(theirAmountLeft);
|
||||
LOGGER.trace("matchedAmount: " + matchedAmount.stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName());
|
||||
long interimMatchedAmount = Math.min(ourMaxAmount, theirAmountLeft);
|
||||
LOGGER.trace(() -> String.format("matchedAmount: %s %s", prettyAmount(interimMatchedAmount), matchingAssetData.getName()));
|
||||
|
||||
// If we can't buy anything then try another order
|
||||
if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0)
|
||||
if (interimMatchedAmount <= 0)
|
||||
continue;
|
||||
|
||||
// Calculate amount granularity, based on price and both assets' divisibility, so that return-amount traded is a valid value (integer or to 8 d.p.)
|
||||
BigDecimal granularity = calculateAmountGranularity(matchingAssetData.getIsDivisible(), returnAssetData.getIsDivisible(), theirOrderData.getPrice());
|
||||
LOGGER.trace("granularity (amount granularity): " + granularity.stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName());
|
||||
long granularity = calculateAmountGranularity(matchingAssetData.isDivisible(), returnAssetData.isDivisible(), theirOrderData.getPrice());
|
||||
LOGGER.trace(() -> String.format("granularity (amount granularity): %s %s", prettyAmount(granularity), matchingAssetData.getName()));
|
||||
|
||||
// Reduce matched amount (if need be) to fit granularity
|
||||
matchedAmount = matchedAmount.subtract(matchedAmount.remainder(granularity));
|
||||
LOGGER.trace("matchedAmount adjusted for granularity: " + matchedAmount.stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName());
|
||||
long matchedAmount = interimMatchedAmount - interimMatchedAmount % granularity;
|
||||
LOGGER.trace(() -> String.format("matchedAmount adjusted for granularity: %s %s", prettyAmount(matchedAmount), matchingAssetData.getName()));
|
||||
|
||||
// If we can't buy anything then try another order
|
||||
if (matchedAmount.compareTo(BigDecimal.ZERO) <= 0)
|
||||
if (matchedAmount <= 0)
|
||||
continue;
|
||||
|
||||
// Safety check
|
||||
if (!matchingAssetData.getIsDivisible() && matchedAmount.stripTrailingZeros().scale() > 0) {
|
||||
Account participant = new PublicKeyAccount(this.repository, theirOrderData.getCreatorPublicKey());
|
||||
|
||||
String message = String.format("Refusing to trade fractional %s [indivisible assetID %d] for %s",
|
||||
matchedAmount.toPlainString(), matchingAssetData.getAssetId(), participant.getAddress());
|
||||
LOGGER.error(message);
|
||||
throw new DataException(message);
|
||||
}
|
||||
checkDivisibility(matchingAssetData, matchedAmount, theirOrderData);
|
||||
|
||||
// Trade can go ahead!
|
||||
|
||||
// Calculate the total cost to us, in return-asset, based on their price
|
||||
BigDecimal returnAmountTraded = matchedAmount.multiply(theirOrderData.getPrice()).setScale(8, RoundingMode.DOWN);
|
||||
LOGGER.trace("returnAmountTraded: " + returnAmountTraded.stripTrailingZeros().toPlainString() + " " + returnAssetData.getName());
|
||||
long returnAmountTraded = Amounts.roundDownScaledMultiply(matchedAmount, theirOrderData.getPrice());
|
||||
LOGGER.trace(() -> String.format("returnAmountTraded: %s %s", prettyAmount(returnAmountTraded), returnAssetData.getName()));
|
||||
|
||||
// Safety check
|
||||
if (!returnAssetData.getIsDivisible() && returnAmountTraded.stripTrailingZeros().scale() > 0) {
|
||||
String message = String.format("Refusing to trade fractional %s [indivisible assetID %d] for %s",
|
||||
returnAmountTraded.toPlainString(), returnAssetData.getAssetId(), creator.getAddress());
|
||||
LOGGER.error(message);
|
||||
throw new DataException(message);
|
||||
}
|
||||
checkDivisibility(returnAssetData, returnAmountTraded, this.orderData);
|
||||
|
||||
BigDecimal tradedWantAmount = (isOurOrderNewPricing && haveAssetId > wantAssetId) ? returnAmountTraded : matchedAmount;
|
||||
BigDecimal tradedHaveAmount = (isOurOrderNewPricing && haveAssetId > wantAssetId) ? matchedAmount : returnAmountTraded;
|
||||
long tradedWantAmount = this.isAmountInWantAsset ? matchedAmount : returnAmountTraded;
|
||||
long tradedHaveAmount = this.isAmountInWantAsset ? returnAmountTraded : matchedAmount;
|
||||
|
||||
// We also need to know how much have-asset to refund based on price improvement ('new' pricing only and only one direction applies)
|
||||
BigDecimal haveAssetRefund = isOurOrderNewPricing && haveAssetId < wantAssetId ? ourPrice.subtract(theirPrice).abs().multiply(matchedAmount).setScale(8, RoundingMode.DOWN) : BigDecimal.ZERO;
|
||||
// We also need to know how much have-asset to refund based on price improvement (only one direction applies)
|
||||
long haveAssetRefund = this.isAmountInWantAsset ? Amounts.roundDownScaledMultiply(matchedAmount, Math.abs(ourPrice - theirPrice)) : 0;
|
||||
|
||||
LOGGER.trace(String.format("We traded %s %s (have-asset) for %s %s (want-asset), saving %s %s (have-asset)",
|
||||
tradedHaveAmount.toPlainString(), haveAssetData.getName(),
|
||||
tradedWantAmount.toPlainString(), wantAssetData.getName(),
|
||||
haveAssetRefund.toPlainString(), haveAssetData.getName()));
|
||||
LOGGER.trace(() -> String.format("We traded %s %s (have-asset) for %s %s (want-asset), saving %s %s (have-asset)",
|
||||
prettyAmount(tradedHaveAmount), haveAssetData.getName(),
|
||||
prettyAmount(tradedWantAmount), wantAssetData.getName(),
|
||||
prettyAmount(haveAssetRefund), haveAssetData.getName()));
|
||||
|
||||
// Construct trade
|
||||
TradeData tradeData = new TradeData(this.orderData.getOrderId(), theirOrderData.getOrderId(),
|
||||
tradedWantAmount, tradedHaveAmount, haveAssetRefund, this.orderData.getTimestamp());
|
||||
|
||||
// Process trade, updating corresponding orders in repository
|
||||
Trade trade = new Trade(this.repository, tradeData);
|
||||
trade.process();
|
||||
|
||||
// Update our order in terms of fulfilment, etc. but do not save into repository as that's handled by Trade above
|
||||
BigDecimal amountFulfilled = isOurOrderNewPricing ? matchedAmount : returnAmountTraded;
|
||||
this.orderData.setFulfilled(this.orderData.getFulfilled().add(amountFulfilled));
|
||||
LOGGER.trace("Updated our order's fulfilled amount to: " + this.orderData.getFulfilled().stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName());
|
||||
LOGGER.trace("Our order's amount remaining: " + this.getAmountLeft().stripTrailingZeros().toPlainString() + " " + matchingAssetData.getName());
|
||||
long amountFulfilled = matchedAmount;
|
||||
this.orderData.setFulfilled(this.orderData.getFulfilled() + amountFulfilled);
|
||||
LOGGER.trace(() -> String.format("Updated our order's fulfilled amount to: %s %s", prettyAmount(this.orderData.getFulfilled()), matchingAssetData.getName()));
|
||||
LOGGER.trace(() -> String.format("Our order's amount remaining: %s %s", prettyAmount(this.getAmountLeft()), matchingAssetData.getName()));
|
||||
|
||||
// Continue on to process other open orders if we still have amount left to match
|
||||
if (this.getAmountLeft().compareTo(BigDecimal.ZERO) <= 0)
|
||||
if (this.getAmountLeft() <= 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check amount has no fractional part if asset is indivisible.
|
||||
*
|
||||
* @throws DataException if divisibility check fails
|
||||
*/
|
||||
private void checkDivisibility(AssetData assetData, long amount, OrderData orderData) throws DataException {
|
||||
if (assetData.isDivisible() || amount % Amounts.MULTIPLIER == 0)
|
||||
// Asset is divisible or amount has no fractional part
|
||||
return;
|
||||
|
||||
String message = String.format("Refusing to trade fractional %s [indivisible assetID %d] for order %s",
|
||||
prettyAmount(amount), assetData.getAssetId(), Base58.encode(orderData.getOrderId()));
|
||||
LOGGER.error(message);
|
||||
throw new DataException(message);
|
||||
}
|
||||
|
||||
public void orphan() throws DataException {
|
||||
// Orphan trades that occurred as a result of this order
|
||||
for (TradeData tradeData : getTrades())
|
||||
@@ -464,7 +418,7 @@ public class Order {
|
||||
|
||||
// Return asset to creator
|
||||
Account creator = new PublicKeyAccount(this.repository, this.orderData.getCreatorPublicKey());
|
||||
creator.setConfirmedBalance(haveAssetId, creator.getConfirmedBalance(haveAssetId).add(this.calcHaveAssetCommittment()));
|
||||
creator.modifyAssetBalance(haveAssetId, this.calcHaveAssetCommittment());
|
||||
}
|
||||
|
||||
// This is called by CancelOrderTransaction so that an Order can no longer trade
|
||||
@@ -474,14 +428,14 @@ public class Order {
|
||||
|
||||
// Update creator's balance with unfulfilled amount
|
||||
Account creator = new PublicKeyAccount(this.repository, this.orderData.getCreatorPublicKey());
|
||||
creator.setConfirmedBalance(haveAssetId, creator.getConfirmedBalance(haveAssetId).add(calcHaveAssetRefund()));
|
||||
creator.modifyAssetBalance(haveAssetId, calcHaveAssetRefund());
|
||||
}
|
||||
|
||||
// Opposite of cancel() above for use during orphaning
|
||||
public void reopen() throws DataException {
|
||||
// Update creator's balance with unfulfilled amount
|
||||
Account creator = new PublicKeyAccount(this.repository, this.orderData.getCreatorPublicKey());
|
||||
creator.setConfirmedBalance(haveAssetId, creator.getConfirmedBalance(haveAssetId).subtract(calcHaveAssetRefund()));
|
||||
creator.modifyAssetBalance(haveAssetId, - calcHaveAssetRefund());
|
||||
|
||||
this.orderData.setIsClosed(false);
|
||||
this.repository.getAssetRepository().save(this.orderData);
|
||||
|
@@ -1,10 +1,7 @@
|
||||
package org.qortal.asset;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.account.PublicKeyAccount;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.data.asset.OrderData;
|
||||
import org.qortal.data.asset.TradeData;
|
||||
import org.qortal.repository.AssetRepository;
|
||||
@@ -17,12 +14,11 @@ public class Trade {
|
||||
private Repository repository;
|
||||
private TradeData tradeData;
|
||||
|
||||
private boolean isNewPricing;
|
||||
private AssetRepository assetRepository;
|
||||
|
||||
private OrderData initiatingOrder;
|
||||
private OrderData targetOrder;
|
||||
private BigDecimal newPricingFulfilled;
|
||||
private long fulfilled;
|
||||
|
||||
// Constructors
|
||||
|
||||
@@ -30,7 +26,6 @@ public class Trade {
|
||||
this.repository = repository;
|
||||
this.tradeData = tradeData;
|
||||
|
||||
this.isNewPricing = this.tradeData.getTimestamp() > BlockChain.getInstance().getNewAssetPricingTimestamp();
|
||||
this.assetRepository = this.repository.getAssetRepository();
|
||||
}
|
||||
|
||||
@@ -43,9 +38,9 @@ public class Trade {
|
||||
// Note: targetAmount is amount traded FROM target order
|
||||
// Note: initiatorAmount is amount traded FROM initiating order
|
||||
|
||||
// Under 'new' pricing scheme, "amount" and "fulfilled" are the same asset for both orders
|
||||
// "amount" and "fulfilled" are the same asset for both orders
|
||||
// which is the matchedAmount in asset with highest assetID
|
||||
this.newPricingFulfilled = (initiatingOrder.getHaveAssetId() < initiatingOrder.getWantAssetId()) ? this.tradeData.getTargetAmount() : this.tradeData.getInitiatorAmount();
|
||||
this.fulfilled = initiatingOrder.getHaveAssetId() < initiatingOrder.getWantAssetId() ? this.tradeData.getTargetAmount() : this.tradeData.getInitiatorAmount();
|
||||
}
|
||||
|
||||
public void process() throws DataException {
|
||||
@@ -55,16 +50,16 @@ public class Trade {
|
||||
// Note: targetAmount is amount traded FROM target order
|
||||
// Note: initiatorAmount is amount traded FROM initiating order
|
||||
|
||||
// Update corresponding Orders on both sides of trade
|
||||
commonPrep();
|
||||
|
||||
initiatingOrder.setFulfilled(initiatingOrder.getFulfilled().add(isNewPricing ? newPricingFulfilled : tradeData.getInitiatorAmount()));
|
||||
// Update corresponding Orders on both sides of trade
|
||||
initiatingOrder.setFulfilled(initiatingOrder.getFulfilled() + fulfilled);
|
||||
initiatingOrder.setIsFulfilled(Order.isFulfilled(initiatingOrder));
|
||||
// Set isClosed to true if isFulfilled now true
|
||||
initiatingOrder.setIsClosed(initiatingOrder.getIsFulfilled());
|
||||
assetRepository.save(initiatingOrder);
|
||||
|
||||
targetOrder.setFulfilled(targetOrder.getFulfilled().add(isNewPricing ? newPricingFulfilled : tradeData.getTargetAmount()));
|
||||
targetOrder.setFulfilled(targetOrder.getFulfilled() + fulfilled);
|
||||
targetOrder.setIsFulfilled(Order.isFulfilled(targetOrder));
|
||||
// Set isClosed to true if isFulfilled now true
|
||||
targetOrder.setIsClosed(targetOrder.getIsFulfilled());
|
||||
@@ -72,33 +67,31 @@ public class Trade {
|
||||
|
||||
// Actually transfer asset balances
|
||||
Account initiatingCreator = new PublicKeyAccount(this.repository, initiatingOrder.getCreatorPublicKey());
|
||||
initiatingCreator.setConfirmedBalance(initiatingOrder.getWantAssetId(), initiatingCreator.getConfirmedBalance(initiatingOrder.getWantAssetId()).add(tradeData.getTargetAmount()));
|
||||
initiatingCreator.modifyAssetBalance(initiatingOrder.getWantAssetId(), tradeData.getTargetAmount());
|
||||
|
||||
Account targetCreator = new PublicKeyAccount(this.repository, targetOrder.getCreatorPublicKey());
|
||||
targetCreator.setConfirmedBalance(targetOrder.getWantAssetId(), targetCreator.getConfirmedBalance(targetOrder.getWantAssetId()).add(tradeData.getInitiatorAmount()));
|
||||
targetCreator.modifyAssetBalance(targetOrder.getWantAssetId(), tradeData.getInitiatorAmount());
|
||||
|
||||
// Possible partial saving to refund to initiator
|
||||
BigDecimal initiatorSaving = this.tradeData.getInitiatorSaving();
|
||||
if (initiatorSaving.compareTo(BigDecimal.ZERO) > 0)
|
||||
initiatingCreator.setConfirmedBalance(initiatingOrder.getHaveAssetId(), initiatingCreator.getConfirmedBalance(initiatingOrder.getHaveAssetId()).add(initiatorSaving));
|
||||
long initiatorSaving = this.tradeData.getInitiatorSaving();
|
||||
if (initiatorSaving > 0)
|
||||
initiatingCreator.modifyAssetBalance(initiatingOrder.getHaveAssetId(), initiatorSaving);
|
||||
}
|
||||
|
||||
public void orphan() throws DataException {
|
||||
AssetRepository assetRepository = this.repository.getAssetRepository();
|
||||
|
||||
// Note: targetAmount is amount traded FROM target order
|
||||
// Note: initiatorAmount is amount traded FROM initiating order
|
||||
|
||||
// Revert corresponding Orders on both sides of trade
|
||||
commonPrep();
|
||||
|
||||
initiatingOrder.setFulfilled(initiatingOrder.getFulfilled().subtract(isNewPricing ? newPricingFulfilled : tradeData.getInitiatorAmount()));
|
||||
// Revert corresponding Orders on both sides of trade
|
||||
initiatingOrder.setFulfilled(initiatingOrder.getFulfilled() - fulfilled);
|
||||
initiatingOrder.setIsFulfilled(Order.isFulfilled(initiatingOrder));
|
||||
// Set isClosed to false if isFulfilled now false
|
||||
initiatingOrder.setIsClosed(initiatingOrder.getIsFulfilled());
|
||||
assetRepository.save(initiatingOrder);
|
||||
|
||||
targetOrder.setFulfilled(targetOrder.getFulfilled().subtract(isNewPricing ? newPricingFulfilled : tradeData.getTargetAmount()));
|
||||
targetOrder.setFulfilled(targetOrder.getFulfilled() - fulfilled);
|
||||
targetOrder.setIsFulfilled(Order.isFulfilled(targetOrder));
|
||||
// Set isClosed to false if isFulfilled now false
|
||||
targetOrder.setIsClosed(targetOrder.getIsFulfilled());
|
||||
@@ -106,15 +99,15 @@ public class Trade {
|
||||
|
||||
// Reverse asset transfers
|
||||
Account initiatingCreator = new PublicKeyAccount(this.repository, initiatingOrder.getCreatorPublicKey());
|
||||
initiatingCreator.setConfirmedBalance(initiatingOrder.getWantAssetId(), initiatingCreator.getConfirmedBalance(initiatingOrder.getWantAssetId()).subtract(tradeData.getTargetAmount()));
|
||||
initiatingCreator.modifyAssetBalance(initiatingOrder.getWantAssetId(), - tradeData.getTargetAmount());
|
||||
|
||||
Account targetCreator = new PublicKeyAccount(this.repository, targetOrder.getCreatorPublicKey());
|
||||
targetCreator.setConfirmedBalance(targetOrder.getWantAssetId(), targetCreator.getConfirmedBalance(targetOrder.getWantAssetId()).subtract(tradeData.getInitiatorAmount()));
|
||||
targetCreator.modifyAssetBalance(targetOrder.getWantAssetId(), - tradeData.getInitiatorAmount());
|
||||
|
||||
// Possible partial saving to claw back from initiator
|
||||
BigDecimal initiatorSaving = this.tradeData.getInitiatorSaving();
|
||||
if (initiatorSaving.compareTo(BigDecimal.ZERO) > 0)
|
||||
initiatingCreator.setConfirmedBalance(initiatingOrder.getHaveAssetId(), initiatingCreator.getConfirmedBalance(initiatingOrder.getHaveAssetId()).subtract(initiatorSaving));
|
||||
long initiatorSaving = this.tradeData.getInitiatorSaving();
|
||||
if (initiatorSaving > 0)
|
||||
initiatingCreator.modifyAssetBalance(initiatingOrder.getHaveAssetId(), - initiatorSaving);
|
||||
|
||||
// Remove trade from repository
|
||||
assetRepository.delete(tradeData);
|
||||
|
@@ -1,11 +1,9 @@
|
||||
package org.qortal.at;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
import org.ciyam.at.MachineState;
|
||||
import org.qortal.asset.Asset;
|
||||
import org.ciyam.at.Timestamp;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.at.ATStateData;
|
||||
@@ -42,66 +40,28 @@ public class AT {
|
||||
int height = this.repository.getBlockRepository().getBlockchainHeight() + 1;
|
||||
byte[] creatorPublicKey = deployATTransactionData.getCreatorPublicKey();
|
||||
long creation = deployATTransactionData.getTimestamp();
|
||||
|
||||
byte[] creationBytes = deployATTransactionData.getCreationBytes();
|
||||
long assetId = deployATTransactionData.getAssetId();
|
||||
short version = (short) ((creationBytes[0] & 0xff) | (creationBytes[1] << 8)); // Little-endian
|
||||
|
||||
if (version >= 2) {
|
||||
MachineState machineState = new MachineState(deployATTransactionData.getCreationBytes());
|
||||
// Just enough AT data to allow API to query initial balances, etc.
|
||||
ATData skeletonAtData = new ATData(atAddress, creatorPublicKey, creation, assetId);
|
||||
|
||||
this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, machineState.getCodeBytes(),
|
||||
machineState.getIsSleeping(), machineState.getSleepUntilHeight(), machineState.getIsFinished(), machineState.getHadFatalError(),
|
||||
machineState.getIsFrozen(), machineState.getFrozenBalance());
|
||||
long blockTimestamp = Timestamp.toLong(height, 0);
|
||||
QortalATAPI api = new QortalATAPI(repository, skeletonAtData, blockTimestamp);
|
||||
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
|
||||
|
||||
byte[] stateData = machineState.toBytes();
|
||||
byte[] stateHash = Crypto.digest(stateData);
|
||||
MachineState machineState = new MachineState(api, loggerFactory, deployATTransactionData.getCreationBytes());
|
||||
|
||||
this.atStateData = new ATStateData(atAddress, height, creation, stateData, stateHash, BigDecimal.ZERO.setScale(8));
|
||||
} else {
|
||||
// Legacy v1 AT
|
||||
// We would deploy these in 'dead' state as they will never be run on Qortal
|
||||
// but this breaks import from Qora1 so something else will have to mark them dead at hard-fork
|
||||
byte[] codeBytes = machineState.getCodeBytes();
|
||||
byte[] codeHash = Crypto.digest(codeBytes);
|
||||
|
||||
// Extract code bytes length
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap(deployATTransactionData.getCreationBytes());
|
||||
this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, codeBytes, codeHash,
|
||||
machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(),
|
||||
machineState.isFrozen(), machineState.getFrozenBalance());
|
||||
|
||||
// v1 AT header is: version, reserved, code-pages, data-pages, call-stack-pages, user-stack-pages (all shorts)
|
||||
byte[] stateData = machineState.toBytes();
|
||||
byte[] stateHash = Crypto.digest(stateData);
|
||||
|
||||
// Number of code pages
|
||||
short numCodePages = byteBuffer.get(2 + 2);
|
||||
|
||||
// Skip header and also "minimum activation amount" (long)
|
||||
byteBuffer.position(6 * 2 + 8);
|
||||
|
||||
int codeLen = 0;
|
||||
|
||||
// Extract actual code length, stored in minimal-size form (byte, short or int)
|
||||
if (numCodePages * 256 < 257) {
|
||||
codeLen = byteBuffer.get() & 0xff;
|
||||
} else if (numCodePages * 256 < Short.MAX_VALUE + 1) {
|
||||
codeLen = byteBuffer.getShort() & 0xffff;
|
||||
} else if (numCodePages * 256 <= Integer.MAX_VALUE) {
|
||||
codeLen = byteBuffer.getInt();
|
||||
}
|
||||
|
||||
// Extract code bytes
|
||||
byte[] codeBytes = new byte[codeLen];
|
||||
byteBuffer.get(codeBytes);
|
||||
|
||||
// Create AT
|
||||
boolean isSleeping = false;
|
||||
Integer sleepUntilHeight = null;
|
||||
boolean isFinished = false;
|
||||
boolean hadFatalError = false;
|
||||
boolean isFrozen = false;
|
||||
Long frozenBalance = null;
|
||||
|
||||
this.atData = new ATData(atAddress, creatorPublicKey, creation, version, Asset.QORT, codeBytes, isSleeping, sleepUntilHeight, isFinished,
|
||||
hadFatalError, isFrozen, frozenBalance);
|
||||
|
||||
this.atStateData = new ATStateData(atAddress, height, creation, null, null, BigDecimal.ZERO.setScale(8));
|
||||
}
|
||||
this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true);
|
||||
}
|
||||
|
||||
// Getters / setters
|
||||
@@ -116,9 +76,7 @@ public class AT {
|
||||
ATRepository atRepository = this.repository.getATRepository();
|
||||
atRepository.save(this.atData);
|
||||
|
||||
// For version 2+ we also store initial AT state data
|
||||
if (this.atData.getVersion() >= 2)
|
||||
atRepository.save(this.atStateData);
|
||||
atRepository.save(this.atStateData);
|
||||
}
|
||||
|
||||
public void undeploy() throws DataException {
|
||||
@@ -126,34 +84,80 @@ public class AT {
|
||||
this.repository.getATRepository().delete(this.atData.getATAddress());
|
||||
}
|
||||
|
||||
public List<AtTransaction> run(long blockTimestamp) throws DataException {
|
||||
public List<AtTransaction> run(int blockHeight, long blockTimestamp) throws DataException {
|
||||
String atAddress = this.atData.getATAddress();
|
||||
|
||||
QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp);
|
||||
QortalATLogger logger = new QortalATLogger();
|
||||
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
|
||||
|
||||
byte[] codeBytes = this.atData.getCodeBytes();
|
||||
|
||||
// Fetch latest ATStateData for this AT (if any)
|
||||
// Fetch latest ATStateData for this AT
|
||||
ATStateData latestAtStateData = this.repository.getATRepository().getLatestATState(atAddress);
|
||||
|
||||
// There should be at least initial AT state data
|
||||
// There should be at least initial deployment AT state data
|
||||
if (latestAtStateData == null)
|
||||
throw new IllegalStateException("No initial AT state data found");
|
||||
throw new IllegalStateException("No previous AT state data found");
|
||||
|
||||
// [Re]create AT machine state using AT state data or from scratch as applicable
|
||||
MachineState state = MachineState.fromBytes(api, logger, latestAtStateData.getStateData(), codeBytes);
|
||||
state.execute();
|
||||
MachineState state = MachineState.fromBytes(api, loggerFactory, latestAtStateData.getStateData(), codeBytes);
|
||||
try {
|
||||
state.execute();
|
||||
} catch (Exception e) {
|
||||
throw new DataException(String.format("Uncaught exception while running AT '%s'", atAddress), e);
|
||||
}
|
||||
|
||||
int height = this.repository.getBlockRepository().getBlockchainHeight() + 1;
|
||||
long creation = this.atData.getCreation();
|
||||
byte[] stateData = state.toBytes();
|
||||
byte[] stateHash = Crypto.digest(stateData);
|
||||
BigDecimal atFees = api.calcFinalFees(state);
|
||||
long atFees = api.calcFinalFees(state);
|
||||
|
||||
this.atStateData = new ATStateData(atAddress, height, creation, stateData, stateHash, atFees);
|
||||
this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false);
|
||||
|
||||
return api.getTransactions();
|
||||
}
|
||||
|
||||
public void update(int blockHeight, long blockTimestamp) throws DataException {
|
||||
// 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);
|
||||
|
||||
// Update AT info in repository too
|
||||
this.atData.setIsSleeping(state.isSleeping());
|
||||
this.atData.setSleepUntilHeight(state.getSleepUntilHeight());
|
||||
this.atData.setIsFinished(state.isFinished());
|
||||
this.atData.setHadFatalError(state.hadFatalError());
|
||||
this.atData.setIsFrozen(state.isFrozen());
|
||||
this.atData.setFrozenBalance(state.getFrozenBalance());
|
||||
this.repository.getATRepository().save(this.atData);
|
||||
}
|
||||
|
||||
public void revert(int blockHeight, long blockTimestamp) throws DataException {
|
||||
String atAddress = this.atData.getATAddress();
|
||||
|
||||
// Delete old AT state data from repository
|
||||
this.repository.getATRepository().delete(atAddress, blockHeight);
|
||||
|
||||
if (this.atStateData.isInitial())
|
||||
return;
|
||||
|
||||
// Load previous state data
|
||||
ATStateData previousStateData = this.repository.getATRepository().getLatestATState(atAddress);
|
||||
if (previousStateData == null)
|
||||
throw new DataException("Can't find previous AT state data for " + atAddress);
|
||||
|
||||
// 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());
|
||||
this.atData.setSleepUntilHeight(state.getSleepUntilHeight());
|
||||
this.atData.setIsFinished(state.isFinished());
|
||||
this.atData.setHadFatalError(state.hadFatalError());
|
||||
this.atData.setIsFrozen(state.isFrozen());
|
||||
this.atData.setFrozenBalance(state.getFrozenBalance());
|
||||
this.repository.getATRepository().save(this.atData);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,134 +0,0 @@
|
||||
package org.qortal.at;
|
||||
|
||||
import static java.util.Arrays.stream;
|
||||
import static java.util.stream.Collectors.toMap;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.ciyam.at.MachineState;
|
||||
import org.ciyam.at.Timestamp;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.block.Block;
|
||||
import org.qortal.data.block.BlockData;
|
||||
import org.qortal.data.transaction.ATTransactionData;
|
||||
import org.qortal.data.transaction.PaymentTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.repository.BlockRepository;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.transaction.Transaction;
|
||||
|
||||
public enum BlockchainAPI {
|
||||
|
||||
QORTAL(0) {
|
||||
@Override
|
||||
public void putTransactionFromRecipientAfterTimestampInA(String recipient, Timestamp timestamp, MachineState state) {
|
||||
int height = timestamp.blockHeight;
|
||||
int sequence = timestamp.transactionSequence + 1;
|
||||
|
||||
QortalATAPI api = (QortalATAPI) state.getAPI();
|
||||
BlockRepository blockRepository = api.repository.getBlockRepository();
|
||||
|
||||
try {
|
||||
Account recipientAccount = new Account(api.repository, recipient);
|
||||
|
||||
while (height <= blockRepository.getBlockchainHeight()) {
|
||||
BlockData blockData = blockRepository.fromHeight(height);
|
||||
|
||||
if (blockData == null)
|
||||
throw new DataException("Unable to fetch block " + height + " from repository?");
|
||||
|
||||
Block block = new Block(api.repository, blockData);
|
||||
|
||||
List<Transaction> transactions = block.getTransactions();
|
||||
|
||||
// No more transactions in this block? Try next block
|
||||
if (sequence >= transactions.size()) {
|
||||
++height;
|
||||
sequence = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
Transaction transaction = transactions.get(sequence);
|
||||
|
||||
// Transaction needs to be sent to specified recipient
|
||||
if (transaction.getRecipientAccounts().contains(recipientAccount)) {
|
||||
// Found a transaction
|
||||
|
||||
api.setA1(state, new Timestamp(height, timestamp.blockchainId, sequence).longValue());
|
||||
|
||||
// Hash transaction's signature into other three A fields for future verification that it's the same transaction
|
||||
byte[] hash = QortalATAPI.sha192(transaction.getTransactionData().getSignature());
|
||||
|
||||
api.setA2(state, QortalATAPI.fromBytes(hash, 0));
|
||||
api.setA3(state, QortalATAPI.fromBytes(hash, 8));
|
||||
api.setA4(state, QortalATAPI.fromBytes(hash, 16));
|
||||
return;
|
||||
}
|
||||
|
||||
// Transaction wasn't for us - keep going
|
||||
++sequence;
|
||||
}
|
||||
|
||||
// No more transactions - zero A and exit
|
||||
api.zeroA(state);
|
||||
} catch (DataException e) {
|
||||
throw new RuntimeException("AT API unable to fetch next transaction?", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getAmountFromTransactionInA(Timestamp timestamp, MachineState state) {
|
||||
QortalATAPI api = (QortalATAPI) state.getAPI();
|
||||
TransactionData transactionData = api.fetchTransaction(state);
|
||||
|
||||
switch (transactionData.getType()) {
|
||||
case PAYMENT:
|
||||
return ((PaymentTransactionData) transactionData).getAmount().unscaledValue().longValue();
|
||||
|
||||
case AT:
|
||||
BigDecimal amount = ((ATTransactionData) transactionData).getAmount();
|
||||
|
||||
if (amount != null)
|
||||
return amount.unscaledValue().longValue();
|
||||
else
|
||||
return 0xffffffffffffffffL;
|
||||
|
||||
default:
|
||||
return 0xffffffffffffffffL;
|
||||
}
|
||||
}
|
||||
},
|
||||
BTC(1) {
|
||||
@Override
|
||||
public void putTransactionFromRecipientAfterTimestampInA(String recipient, Timestamp timestamp, MachineState state) {
|
||||
// TODO BTC transaction support for ATv2
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getAmountFromTransactionInA(Timestamp timestamp, MachineState state) {
|
||||
// TODO BTC transaction support for ATv2
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
public final int value;
|
||||
|
||||
private static final Map<Integer, BlockchainAPI> map = stream(BlockchainAPI.values()).collect(toMap(type -> type.value, type -> type));
|
||||
|
||||
BlockchainAPI(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public static BlockchainAPI valueOf(int value) {
|
||||
return map.get(value);
|
||||
}
|
||||
|
||||
// Blockchain-specific API methods
|
||||
|
||||
public abstract void putTransactionFromRecipientAfterTimestampInA(String recipient, Timestamp timestamp, MachineState state);
|
||||
|
||||
public abstract long getAmountFromTransactionInA(Timestamp timestamp, MachineState state);
|
||||
|
||||
}
|
@@ -1,11 +1,11 @@
|
||||
package org.qortal.at;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.ciyam.at.API;
|
||||
import org.ciyam.at.ExecutionException;
|
||||
import org.ciyam.at.FunctionData;
|
||||
@@ -14,35 +14,41 @@ import org.ciyam.at.MachineState;
|
||||
import org.ciyam.at.OpCode;
|
||||
import org.ciyam.at.Timestamp;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.account.GenesisAccount;
|
||||
import org.qortal.account.NullAccount;
|
||||
import org.qortal.account.PublicKeyAccount;
|
||||
import org.qortal.asset.Asset;
|
||||
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.block.BlockData;
|
||||
import org.qortal.data.block.BlockSummaryData;
|
||||
import org.qortal.data.transaction.ATTransactionData;
|
||||
import org.qortal.data.transaction.BaseTransactionData;
|
||||
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.ATRepository;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.transaction.AtTransaction;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.BitTwiddling;
|
||||
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
public class QortalATAPI extends API {
|
||||
|
||||
// Useful constants
|
||||
private static final BigDecimal FEE_PER_STEP = BigDecimal.valueOf(1.0).setScale(8); // 1 QORT per "step"
|
||||
private static final int MAX_STEPS_PER_ROUND = 500;
|
||||
private static final int STEPS_PER_FUNCTION_CALL = 10;
|
||||
private static final int MINUTES_PER_BLOCK = 10;
|
||||
private static final byte[] ADDRESS_PADDING = new byte[32 - Account.ADDRESS_LENGTH];
|
||||
private static final Logger LOGGER = LogManager.getLogger(QortalATAPI.class);
|
||||
|
||||
// Properties
|
||||
Repository repository;
|
||||
ATData atData;
|
||||
long blockTimestamp;
|
||||
private Repository repository;
|
||||
private ATData atData;
|
||||
private long blockTimestamp;
|
||||
private final CiyamAtSettings ciyamAtSettings;
|
||||
|
||||
/** List of generated AT transactions */
|
||||
List<AtTransaction> transactions;
|
||||
@@ -54,36 +60,42 @@ public class QortalATAPI extends API {
|
||||
this.atData = atData;
|
||||
this.transactions = new ArrayList<>();
|
||||
this.blockTimestamp = blockTimestamp;
|
||||
|
||||
this.ciyamAtSettings = BlockChain.getInstance().getCiyamAtSettings();
|
||||
}
|
||||
|
||||
// Methods specific to Qortal AT processing, not inherited
|
||||
|
||||
public Repository getRepository() {
|
||||
return this.repository;
|
||||
}
|
||||
|
||||
public List<AtTransaction> getTransactions() {
|
||||
return this.transactions;
|
||||
}
|
||||
|
||||
public BigDecimal calcFinalFees(MachineState state) {
|
||||
return FEE_PER_STEP.multiply(BigDecimal.valueOf(state.getSteps()));
|
||||
public long calcFinalFees(MachineState state) {
|
||||
return state.getSteps() * this.ciyamAtSettings.feePerStep;
|
||||
}
|
||||
|
||||
// Inherited methods from CIYAM AT API
|
||||
|
||||
@Override
|
||||
public int getMaxStepsPerRound() {
|
||||
return MAX_STEPS_PER_ROUND;
|
||||
return this.ciyamAtSettings.maxStepsPerRound;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOpCodeSteps(OpCode opcode) {
|
||||
if (opcode.value >= OpCode.EXT_FUN.value && opcode.value <= OpCode.EXT_FUN_RET_DAT_2.value)
|
||||
return STEPS_PER_FUNCTION_CALL;
|
||||
return this.ciyamAtSettings.stepsPerFunctionCall;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFeePerStep() {
|
||||
return FEE_PER_STEP.unscaledValue().longValue();
|
||||
return this.ciyamAtSettings.feePerStep;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -105,31 +117,67 @@ public class QortalATAPI extends API {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putPreviousBlockHashInA(MachineState state) {
|
||||
public void putPreviousBlockHashIntoA(MachineState state) {
|
||||
try {
|
||||
BlockData blockData = this.repository.getBlockRepository().fromHeight(this.getPreviousBlockHeight());
|
||||
int previousBlockHeight = this.repository.getBlockRepository().getBlockchainHeight() - 1;
|
||||
|
||||
// We only need signature, so only request a block summary
|
||||
List<BlockSummaryData> blockSummaries = this.repository.getBlockRepository().getBlockSummaries(previousBlockHeight, previousBlockHeight);
|
||||
if (blockSummaries == null || blockSummaries.size() != 1)
|
||||
throw new RuntimeException("AT API unable to fetch previous block hash?");
|
||||
|
||||
// Block's signature is 128 bytes so we need to reduce this to 4 longs (32 bytes)
|
||||
byte[] blockHash = Crypto.digest(blockData.getSignature());
|
||||
// To be able to use hash to look up block, save height (8 bytes) and partial signature (24 bytes)
|
||||
this.setA1(state, previousBlockHeight);
|
||||
|
||||
this.setA(state, blockHash);
|
||||
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, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putTransactionAfterTimestampInA(Timestamp timestamp, MachineState state) {
|
||||
public void putTransactionAfterTimestampIntoA(Timestamp timestamp, MachineState state) {
|
||||
// Recipient is this AT
|
||||
String recipient = this.atData.getATAddress();
|
||||
String atAddress = this.atData.getATAddress();
|
||||
|
||||
BlockchainAPI blockchainAPI = BlockchainAPI.valueOf(timestamp.blockchainId);
|
||||
blockchainAPI.putTransactionFromRecipientAfterTimestampInA(recipient, timestamp, state);
|
||||
int height = timestamp.blockHeight;
|
||||
int sequence = timestamp.transactionSequence;
|
||||
|
||||
if (state.getCurrentBlockHeight() < BlockChain.getInstance().getAtFindNextTransactionFixHeight())
|
||||
// Off-by-one bug still in effect
|
||||
sequence += 1;
|
||||
|
||||
ATRepository.NextTransactionInfo nextTransactionInfo;
|
||||
try {
|
||||
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
|
||||
public long getTypeFromTransactionInA(MachineState state) {
|
||||
TransactionData transactionData = this.fetchTransaction(state);
|
||||
TransactionData transactionData = this.getTransactionFromA(state);
|
||||
|
||||
switch (transactionData.getType()) {
|
||||
case PAYMENT:
|
||||
@@ -151,22 +199,36 @@ public class QortalATAPI extends API {
|
||||
|
||||
@Override
|
||||
public long getAmountFromTransactionInA(MachineState state) {
|
||||
Timestamp timestamp = new Timestamp(state.getA1());
|
||||
BlockchainAPI blockchainAPI = BlockchainAPI.valueOf(timestamp.blockchainId);
|
||||
return blockchainAPI.getAmountFromTransactionInA(timestamp, state);
|
||||
TransactionData transactionData = this.getTransactionFromA(state);
|
||||
|
||||
switch (transactionData.getType()) {
|
||||
case PAYMENT:
|
||||
return ((PaymentTransactionData) transactionData).getAmount();
|
||||
|
||||
case AT:
|
||||
Long amount = ((ATTransactionData) transactionData).getAmount();
|
||||
|
||||
if (amount != null)
|
||||
return amount;
|
||||
|
||||
// fall-through to default
|
||||
|
||||
default:
|
||||
return 0xffffffffffffffffL;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimestampFromTransactionInA(MachineState state) {
|
||||
// Transaction's "timestamp" already stored in A1
|
||||
Timestamp timestamp = new Timestamp(state.getA1());
|
||||
Timestamp timestamp = new Timestamp(this.getA1(state));
|
||||
return timestamp.longValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long generateRandomUsingTransactionInA(MachineState state) {
|
||||
// The plan here is to sleep for a block then use next block's signature and this transaction's signature to generate pseudo-random, but deterministic,
|
||||
// value.
|
||||
// The plan here is to sleep for a block then use next block's signature
|
||||
// and this transaction's signature to generate pseudo-random, but deterministic, value.
|
||||
|
||||
if (!isFirstOpCodeAfterSleeping(state)) {
|
||||
// First call
|
||||
@@ -179,7 +241,7 @@ public class QortalATAPI extends API {
|
||||
// Second call
|
||||
|
||||
// HASH(A and new block hash)
|
||||
TransactionData transactionData = this.fetchTransaction(state);
|
||||
TransactionData transactionData = this.getTransactionFromA(state);
|
||||
|
||||
try {
|
||||
BlockData blockData = this.repository.getBlockRepository().getLastBlock();
|
||||
@@ -191,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);
|
||||
}
|
||||
@@ -203,50 +265,50 @@ public class QortalATAPI extends API {
|
||||
// Zero B in case of issues or shorter-than-B message
|
||||
this.zeroB(state);
|
||||
|
||||
TransactionData transactionData = this.fetchTransaction(state);
|
||||
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
|
||||
public void putAddressFromTransactionInAIntoB(MachineState state) {
|
||||
TransactionData transactionData = this.fetchTransaction(state);
|
||||
TransactionData transactionData = this.getTransactionFromA(state);
|
||||
|
||||
// We actually use public key as it has more potential utility (e.g. message verification) than an address
|
||||
byte[] bytes = transactionData.getCreatorPublicKey();
|
||||
String address;
|
||||
if (transactionData.getType() == TransactionType.AT) {
|
||||
// Use AT address from transaction data, as transaction's public key will always be fake
|
||||
address = ((ATTransactionData) transactionData).getATAddress();
|
||||
} else {
|
||||
byte[] publicKey = transactionData.getCreatorPublicKey();
|
||||
address = Crypto.toAddress(publicKey);
|
||||
}
|
||||
|
||||
this.setB(state, bytes);
|
||||
// Convert to byte form as this only takes 25 bytes,
|
||||
// compared to string-form's 34 bytes,
|
||||
// and we only have 32 bytes available.
|
||||
byte[] addressBytes = Bytes.ensureCapacity(Base58.decode(address), 32, 0); // pad to 32 bytes
|
||||
|
||||
this.setB(state, addressBytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putCreatorAddressIntoB(MachineState state) {
|
||||
// We actually use public key as it has more potential utility (e.g. message verification) than an address
|
||||
byte[] bytes = atData.getCreatorPublicKey();
|
||||
byte[] publicKey = atData.getCreatorPublicKey();
|
||||
String address = Crypto.toAddress(publicKey);
|
||||
|
||||
this.setB(state, bytes);
|
||||
// Convert to byte form as this only takes 25 bytes,
|
||||
// compared to string-form's 34 bytes,
|
||||
// and we only have 32 bytes available.
|
||||
byte[] addressBytes = Bytes.ensureCapacity(Base58.decode(address), 32, 0); // pad to 32 bytes
|
||||
|
||||
this.setB(state, addressBytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -254,25 +316,22 @@ public class QortalATAPI extends API {
|
||||
try {
|
||||
Account atAccount = this.getATAccount();
|
||||
|
||||
return atAccount.getConfirmedBalance(Asset.QORT).unscaledValue().longValue();
|
||||
return atAccount.getConfirmedBalance(Asset.QORT);
|
||||
} catch (DataException e) {
|
||||
throw new RuntimeException("AT API unable to fetch AT's current balance?", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void payAmountToB(long unscaledAmount, MachineState state) {
|
||||
byte[] publicKey = state.getB();
|
||||
|
||||
PublicKeyAccount recipient = new PublicKeyAccount(this.repository, publicKey);
|
||||
public void payAmountToB(long amount, MachineState state) {
|
||||
Account recipient = getAccountFromB(state);
|
||||
|
||||
long timestamp = this.getNextTransactionTimestamp();
|
||||
byte[] reference = this.getLastReference();
|
||||
BigDecimal amount = BigDecimal.valueOf(unscaledAmount, 8);
|
||||
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, GenesisAccount.PUBLIC_KEY, BigDecimal.ZERO, null);
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, NullAccount.PUBLIC_KEY, 0L, null);
|
||||
ATTransactionData atTransactionData = new ATTransactionData(baseTransactionData, this.atData.getATAddress(),
|
||||
recipient.getAddress(), amount, this.atData.getAssetId(), new byte[0]);
|
||||
recipient.getAddress(), amount, this.atData.getAssetId());
|
||||
AtTransaction atTransaction = new AtTransaction(this.repository, atTransactionData);
|
||||
|
||||
// Add to our transactions
|
||||
@@ -281,17 +340,15 @@ public class QortalATAPI extends API {
|
||||
|
||||
@Override
|
||||
public void messageAToB(MachineState state) {
|
||||
byte[] message = state.getA();
|
||||
byte[] publicKey = state.getB();
|
||||
|
||||
PublicKeyAccount recipient = new PublicKeyAccount(this.repository, publicKey);
|
||||
byte[] message = this.getA(state);
|
||||
Account recipient = getAccountFromB(state);
|
||||
|
||||
long timestamp = this.getNextTransactionTimestamp();
|
||||
byte[] reference = this.getLastReference();
|
||||
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, GenesisAccount.PUBLIC_KEY, BigDecimal.ZERO, null);
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, NullAccount.PUBLIC_KEY, 0L, null);
|
||||
ATTransactionData atTransactionData = new ATTransactionData(baseTransactionData, this.atData.getATAddress(),
|
||||
recipient.getAddress(), BigDecimal.ZERO, this.atData.getAssetId(), message);
|
||||
recipient.getAddress(), message);
|
||||
AtTransaction atTransaction = new AtTransaction(this.repository, atTransactionData);
|
||||
|
||||
// Add to our transactions
|
||||
@@ -303,22 +360,24 @@ public class QortalATAPI extends API {
|
||||
int blockHeight = timestamp.blockHeight;
|
||||
|
||||
// At least one block in the future
|
||||
blockHeight += (minutes / MINUTES_PER_BLOCK) + 1;
|
||||
blockHeight += Math.max(minutes / this.ciyamAtSettings.minutesPerBlock, 1);
|
||||
|
||||
return new Timestamp(blockHeight, 0).longValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFinished(long finalBalance, MachineState state) {
|
||||
if (finalBalance <= 0)
|
||||
return;
|
||||
|
||||
// Refund remaining balance (if any) to AT's creator
|
||||
Account creator = this.getCreator();
|
||||
long timestamp = this.getNextTransactionTimestamp();
|
||||
byte[] reference = this.getLastReference();
|
||||
BigDecimal amount = BigDecimal.valueOf(finalBalance, 8);
|
||||
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, GenesisAccount.PUBLIC_KEY, BigDecimal.ZERO, null);
|
||||
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, NullAccount.PUBLIC_KEY, 0L, null);
|
||||
ATTransactionData atTransactionData = new ATTransactionData(baseTransactionData, this.atData.getATAddress(),
|
||||
creator.getAddress(), amount, this.atData.getAssetId(), new byte[0]);
|
||||
creator.getAddress(), finalBalance, this.atData.getAssetId());
|
||||
AtTransaction atTransaction = new AtTransaction(this.repository, atTransactionData);
|
||||
|
||||
// Add to our transactions
|
||||
@@ -327,7 +386,7 @@ public class QortalATAPI extends API {
|
||||
|
||||
@Override
|
||||
public void onFatalError(MachineState state, ExecutionException e) {
|
||||
state.getLogger().error("AT " + this.atData.getATAddress() + " suffered fatal error: " + e.getMessage());
|
||||
LOGGER.error("AT " + this.atData.getATAddress() + " suffered fatal error: " + e.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -338,47 +397,38 @@ public class QortalATAPI extends API {
|
||||
if (qortalFunctionCode == null)
|
||||
throw new IllegalFunctionCodeException("Unknown Qortal function code 0x" + String.format("%04x", rawFunctionCode) + " encountered");
|
||||
|
||||
qortalFunctionCode.preExecuteCheck(2, true, state, rawFunctionCode);
|
||||
qortalFunctionCode.preExecuteCheck(paramCount, returnValueExpected, rawFunctionCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void platformSpecificPostCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
|
||||
QortalFunctionCode qortalFunctionCode = QortalFunctionCode.valueOf(rawFunctionCode);
|
||||
|
||||
if (qortalFunctionCode == null)
|
||||
throw new IllegalFunctionCodeException("Unknown Qortal function code 0x" + String.format("%04x", rawFunctionCode) + " encountered");
|
||||
|
||||
qortalFunctionCode.execute(functionData, state, rawFunctionCode);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** Returns SHA2-192 digest of input - used to verify transaction signatures */
|
||||
public static byte[] sha192(byte[] input) {
|
||||
try {
|
||||
// SHA2-192
|
||||
MessageDigest sha192 = MessageDigest.getInstance("SHA-192");
|
||||
return sha192.digest(input);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("SHA-192 not available");
|
||||
}
|
||||
}
|
||||
/** Verify transaction's partial signature matches A2 thru A4 */
|
||||
private void verifyTransaction(TransactionData transactionData, MachineState state) {
|
||||
// Compare end of transaction's signature against A2 thru A4
|
||||
byte[] sig = transactionData.getSignature();
|
||||
|
||||
/** Verify transaction's SHA2-192 hashed signature matches A2 thru A4 */
|
||||
private static void verifyTransaction(TransactionData transactionData, MachineState state) {
|
||||
// Compare SHA2-192 of transaction's signature against A2 thru A4
|
||||
byte[] hash = sha192(transactionData.getSignature());
|
||||
|
||||
if (state.getA2() != fromBytes(hash, 0) || state.getA3() != fromBytes(hash, 8) || state.getA4() != fromBytes(hash, 16))
|
||||
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");
|
||||
}
|
||||
|
||||
/** Returns transaction data from repository using block height & sequence from A1, checking the transaction signatures match too */
|
||||
/* package */ TransactionData fetchTransaction(MachineState state) {
|
||||
Timestamp timestamp = new Timestamp(state.getA1());
|
||||
/* package */ TransactionData getTransactionFromA(MachineState state) {
|
||||
Timestamp timestamp = new Timestamp(this.getA1(state));
|
||||
|
||||
try {
|
||||
TransactionData transactionData = this.repository.getTransactionRepository().fromHeightAndSequence(timestamp.blockHeight,
|
||||
@@ -396,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());
|
||||
@@ -409,29 +473,17 @@ public class QortalATAPI extends API {
|
||||
/** Returns the timestamp to use for next AT Transaction */
|
||||
private long getNextTransactionTimestamp() {
|
||||
/*
|
||||
* Timestamp is block's timestamp + position in AT-Transactions list.
|
||||
* Use block's timestamp.
|
||||
*
|
||||
* We need increasing timestamps to preserve transaction order and hence a correct signature-reference chain when the block is processed.
|
||||
*
|
||||
* As Qora blocks must share the same milliseconds component in their timestamps, this allows us to generate up to 1,000 AT-Transactions per AT without
|
||||
* issue.
|
||||
*
|
||||
* As long as ATs are not allowed to generate that many per block, e.g. by limiting maximum steps per execution round, then we should be fine.
|
||||
* This is OK because AT transactions are always generated locally and order is preserved in Transaction.getDataComparator().
|
||||
*/
|
||||
|
||||
// XXX THE ABOVE IS NO LONGER TRUE IN QORTAL!
|
||||
// return this.blockTimestamp + this.transactions.size();
|
||||
throw new RuntimeException("AT timestamp code not fixed!");
|
||||
return this.blockTimestamp;
|
||||
}
|
||||
|
||||
/** Returns AT account's lastReference, taking newly generated ATTransactions into account */
|
||||
/** Returns AT account's lastReference */
|
||||
private byte[] getLastReference() {
|
||||
// Use signature from last AT Transaction we generated
|
||||
if (!this.transactions.isEmpty())
|
||||
return this.transactions.get(this.transactions.size() - 1).getTransactionData().getSignature();
|
||||
|
||||
try {
|
||||
// No transactions yet, so look up AT's account's last reference from repository
|
||||
// Look up AT's account's last reference from repository
|
||||
Account atAccount = this.getATAccount();
|
||||
|
||||
return atAccount.getLastReference();
|
||||
@@ -440,4 +492,42 @@ public class QortalATAPI extends API {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Account (possibly PublicKeyAccount) based on value in B.
|
||||
* <p>
|
||||
* If first byte in B starts with either address version bytes,<br>
|
||||
* and bytes 26 to 32 are zero, then use as an address, but only if valid.
|
||||
* <p>
|
||||
* Otherwise, assume B is a public key.
|
||||
*/
|
||||
private Account getAccountFromB(MachineState state) {
|
||||
byte[] bBytes = this.getB(state);
|
||||
|
||||
if ((bBytes[0] == Crypto.ADDRESS_VERSION || bBytes[0] == Crypto.AT_ADDRESS_VERSION)
|
||||
&& Arrays.mismatch(bBytes, Account.ADDRESS_LENGTH, 32, ADDRESS_PADDING, 0, ADDRESS_PADDING.length) == -1) {
|
||||
// Extract only the bytes containing address
|
||||
byte[] addressBytes = Arrays.copyOf(bBytes, Account.ADDRESS_LENGTH);
|
||||
// If address (in byte form) is valid...
|
||||
if (Crypto.isValidAddress(addressBytes))
|
||||
// ...then return an Account using address (converted to Base58
|
||||
return new Account(this.repository, Base58.encode(addressBytes));
|
||||
}
|
||||
|
||||
return new PublicKeyAccount(this.repository, bBytes);
|
||||
}
|
||||
|
||||
/* Convenience methods to allow QortalFunctionCode package-visibility access to A/B-get/set methods. */
|
||||
|
||||
protected byte[] getB(MachineState state) {
|
||||
return super.getB(state);
|
||||
}
|
||||
|
||||
protected void setB(MachineState state, byte[] bBytes) {
|
||||
super.setB(state, bBytes);
|
||||
}
|
||||
|
||||
protected void zeroB(MachineState state) {
|
||||
super.zeroB(state);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,26 +0,0 @@
|
||||
package org.qortal.at;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
public class QortalATLogger implements org.ciyam.at.LoggerInterface {
|
||||
|
||||
// NOTE: We're logging on behalf of org.qortal.at.AT, not ourselves!
|
||||
private static final Logger LOGGER = LogManager.getLogger(AT.class);
|
||||
|
||||
@Override
|
||||
public void error(String message) {
|
||||
LOGGER.error(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(String message) {
|
||||
LOGGER.debug(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void echo(String message) {
|
||||
LOGGER.info(message);
|
||||
}
|
||||
|
||||
}
|
2197
src/main/java/org/qortal/at/QortalAtLogger.java
Normal file
2197
src/main/java/org/qortal/at/QortalAtLogger.java
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user