diff --git a/.github/workflows/pr-testing.yml b/.github/workflows/pr-testing.yml
index f712a321..3d0925df 100644
--- a/.github/workflows/pr-testing.yml
+++ b/.github/workflows/pr-testing.yml
@@ -8,16 +8,16 @@ jobs:
mavenTesting:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Cache local Maven repository
- uses: actions/cache@v2
+ uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Set up the Java JDK
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'adopt'
diff --git a/.gitignore b/.gitignore
index fcc42db9..218e8043 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,6 @@
/.mvn.classpath
/notes*
/settings.json
-/testnet*
/settings*.json
/testchain*.json
/run-testnet*.sh
diff --git a/Q-Apps.md b/Q-Apps.md
new file mode 100644
index 00000000..83f2a356
--- /dev/null
+++ b/Q-Apps.md
@@ -0,0 +1,911 @@
+# Qortal Project - Q-Apps Documentation
+
+## Introduction
+
+Q-Apps are static web apps written in javascript, HTML, CSS, and other static assets. The key difference between a Q-App and a fully static site is its ability to interact with both the logged-in user and on-chain data. This is achieved using the API described in this document.
+
+
+# Section 0: Basic QDN concepts
+
+## Introduction to QDN resources
+Each published item on QDN (Qortal Data Network) is referred to as a "resource". A resource could contain anything from a few characters of text, to a multi-layered directory structure containing thousands of files.
+
+Resources are stored on-chain, however the data payload is generally stored off-chain, and verified using an on-chain SHA-256 hash.
+
+To publish a resource, a user must first register a name, and then include that name when publishing the data. Accounts without a registered name are unable to publish to QDN from a Q-App at this time.
+
+Owning the name grants update privileges to the data. If that name is later sold or transferred, the permission to update that resource is moved to the new owner.
+
+
+## Name, service & identifier
+
+Each QDN resource has 3 important fields:
+- `name` - the registered name of the account that is publishing the data (which will hold update/edit privileges going forwards).
+- `service` - the type of content (e.g. IMAGE or JSON). Different services have different validation rules. See [list of available services](#services).
+- `identifier` - an optional string to allow more than one resource to exist for a given name/service combination. For example, the name `QortalDemo` may wish to publish multiple images. This can be achieved by using a different identifier string for each. The identifier is only unique to the name in question, and so it doesn't matter if another name is using the same service and identifier string.
+
+
+## Shared identifiers
+
+Since an identifier can be used by multiple names, this can be used to the advantage of Q-App developers as it allows for data to be stored in a deterministic location.
+
+An example of this is the user's avatar. This will always be published with service `THUMBNAIL` and identifier `qortal_avatar`, along with the user's name. So, an app can display the avatar of a user just by specifying their name when requesting the data. The same applies when publishing data.
+
+
+## "Default" resources
+
+A "default" resource refers to one without an identifier. For example, when a website is published via the UI, it will use the user's name and the service `WEBSITE`. These do not have an identifier, and are therefore the "default" website for this name. When requesting or publishing data without an identifier, apps can either omit the `identifier` key entirely, or include `"identifier": "default"` to indicate that the resource(s) being queried or published do not have an identifier.
+
+
+
+## Available service types
+
+Here is a list of currently available services that can be used in Q-Apps:
+
+### Public services ###
+The services below are intended to be used for publicly accessible data.
+
+IMAGE,
+THUMBNAIL,
+VIDEO,
+AUDIO,
+PODCAST,
+VOICE,
+ARBITRARY_DATA,
+JSON,
+DOCUMENT,
+LIST,
+PLAYLIST,
+METADATA,
+BLOG,
+BLOG_POST,
+BLOG_COMMENT,
+GIF_REPOSITORY,
+ATTACHMENT,
+FILE,
+FILES,
+CHAIN_DATA,
+STORE,
+PRODUCT,
+OFFER,
+COUPON,
+CODE,
+PLUGIN,
+EXTENSION,
+GAME,
+ITEM,
+NFT,
+DATABASE,
+SNAPSHOT,
+COMMENT,
+CHAIN_COMMENT,
+WEBSITE,
+APP,
+QCHAT_ATTACHMENT,
+QCHAT_IMAGE,
+QCHAT_AUDIO,
+QCHAT_VOICE
+
+### Private services ###
+For the services below, data is encrypted for a single recipient, and can only be decrypted using the private key of the recipient's wallet.
+
+QCHAT_ATTACHMENT_PRIVATE,
+ATTACHMENT_PRIVATE,
+FILE_PRIVATE,
+IMAGE_PRIVATE,
+VIDEO_PRIVATE,
+AUDIO_PRIVATE,
+VOICE_PRIVATE,
+DOCUMENT_PRIVATE,
+MAIL_PRIVATE,
+MESSAGE_PRIVATE
+
+
+## Single vs multi-file resources
+
+Some resources, such as those published with the `IMAGE` or `JSON` service, consist of a single file or piece of data (the image or the JSON string). This is the most common type of QDN resource, especially in the context of Q-Apps. These can be published by supplying a base64-encoded string containing the data.
+
+Other resources, such as those published with the `WEBSITE`, `APP`, or `GIF_REPOSITORY` service, can contain multiple files and directories. Publishing these kinds of files is not yet available for Q-Apps, however it is possible to retrieve multi-file resources that are already published. When retrieving this data (via FETCH_QDN_RESOURCE), a `filepath` must be included to indicate the file that you would like to retrieve. There is no need to specify a filepath for single file resources, as these will automatically return the contents of the single file.
+
+
+## App-specific data
+
+Some apps may want to make all QDN data for a particular service available. However, others may prefer to only deal with data that has been published by their app (if a specific format/schema is being used for instance).
+
+Identifiers can be used to allow app developers to locate data that has been published by their app. The recommended approach for this is to use the app name as a prefix on all identifiers when publishing data.
+
+For instance, an app called `MyApp` could allow users to publish JSON data. The app could choose to prefix all identifiers with the string `myapp_`, and then use a random string for each published resource (resulting in identifiers such as `myapp_qR5ndZ8v`). Then, to locate data that has potentially been published by users of MyApp, it can later search the QDN database for items with `"service": "JSON"` and `"identifier": "myapp_"`. The SEARCH_QDN_RESOURCES action has a `prefix` option in order to match identifiers beginning with the supplied string.
+
+Note that QDN is a permissionless system, and therefore it's not possible to verify that a resource was actually published by the app. It is recommended that apps validate the contents of the resource to ensure it is formatted correctly, instead of making assumptions.
+
+
+## Updating a resource
+
+To update a resource, it can be overwritten by publishing with the same `name`, `service`, and `identifier` combination. Note that the authenticated account must currently own the name in order to publish an update.
+
+
+## Routing
+
+If a non-existent `filepath` is accessed, the default behaviour of QDN is to return a `404: File not found` error. This includes anything published using the `WEBSITE` service.
+
+However, routing is handled differently for anything published using the `APP` service.
+
+For apps, QDN automatically sends all unhandled requests to the index file (generally index.html). This allows the app to use custom routing, as it is able to listen on any path. If a file exists at a path, the file itself will be served, and so the request won't be sent to the index file.
+
+It's recommended that all apps return a 404 page if a request isn't able to be routed.
+
+
+# Section 1: Simple links and image loading via HTML
+
+## Section 1a: Linking to other QDN websites / resources
+
+The `qortal://` protocol can be used to access QDN data from within Qortal websites and apps. The basic format is as follows:
+```
+link text
+```
+
+However, the system will support the omission of the `identifier` and/or `path` components to allow for simpler URL formats.
+
+A simple link to another website can be achieved with this HTML code:
+```
+link text
+```
+
+To link to a specific page of another website:
+```
+link text
+```
+
+To link to a standalone resource, such as an avatar
+```
+avatar
+```
+
+For cases where you would prefer to explicitly include an identifier (to remove ambiguity) you can use the keyword `default` to access a resource that doesn't have an identifier. For instance:
+```
+link to root of website
+link to subpage of website
+```
+
+
+## Section 1b: Linking to other QDN images
+
+The same applies for images, such as displaying an avatar:
+```
+
+```
+
+...or even an image from an entirely different website:
+```
+
+```
+
+
+# Section 2: Integrating a Javascript app
+
+Javascript apps allow for much more complex integrations with Qortal's blockchain data.
+
+## Section 2a: Direct API calls
+
+The standard [Qortal Core API](http://localhost:12391/api-documentation) is available to websites and apps, and can be called directly using a standard AJAX request, such as:
+```
+async function getNameInfo(name) {
+ const response = await fetch("/names/" + name);
+ const nameData = await response.json();
+ console.log("nameData: " + JSON.stringify(nameData));
+}
+getNameInfo("QortalDemo");
+```
+
+However, this only works for read-only data, such as looking up transactions, names, balances, etc. Also, since the address of the logged in account can't be retrieved from the core, apps can't show personalized data with this approach.
+
+
+## Section 2b: User interaction via qortalRequest()
+
+To take things a step further, the qortalRequest() function can be used to interact with the user, in order to:
+
+- Request address and public key of the logged in account
+- Publish data to QDN
+- Send chat messages
+- Join groups
+- Deploy ATs (smart contracts)
+- Send QORT or any supported foreign coin
+- Add/remove items from lists
+
+In addition to the above, qortalRequest() also supports many read-only functions that are also available via direct core API calls. Using qortalRequest() helps with futureproofing, as the core APIs can be modified without breaking functionality of existing Q-Apps.
+
+
+### Making a request
+
+Qortal core will automatically inject the `qortalRequest()` javascript function to all websites/apps, which returns a Promise. This can be used to fetch data or publish data to the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling.
+
+```
+async function myfunction() {
+ try {
+ let res = await qortalRequest({
+ action: "GET_ACCOUNT_DATA",
+ address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
+ });
+ console.log(JSON.stringify(res)); // Log the response to the console
+
+ } catch(e) {
+ console.log("Error: " + JSON.stringify(e));
+ }
+}
+myfunction();
+```
+
+### Timeouts
+
+Request timeouts are handled automatically when using qortalRequest(). The timeout value will differ based on the action being used - see `getDefaultTimeout()` in [q-apps.js](src/main/resources/q-apps/q-apps.js) for the current values.
+
+If a request times out it will throw an error - `The request timed out` - which can be handled by the Q-App.
+
+It is also possible to specify a custom timeout using `qortalRequestWithTimeout(request, timeout)`, however this is discouraged. It's more reliable and futureproof to let the core handle the timeout values.
+
+
+# Section 3: qortalRequest Documentation
+
+## Supported actions
+
+Here is a list of currently supported actions:
+- GET_USER_ACCOUNT
+- GET_ACCOUNT_DATA
+- GET_ACCOUNT_NAMES
+- SEARCH_NAMES
+- GET_NAME_DATA
+- LIST_QDN_RESOURCES
+- SEARCH_QDN_RESOURCES
+- GET_QDN_RESOURCE_STATUS
+- GET_QDN_RESOURCE_PROPERTIES
+- GET_QDN_RESOURCE_METADATA
+- GET_QDN_RESOURCE_URL
+- LINK_TO_QDN_RESOURCE
+- FETCH_QDN_RESOURCE
+- PUBLISH_QDN_RESOURCE
+- PUBLISH_MULTIPLE_QDN_RESOURCES
+- DECRYPT_DATA
+- SAVE_FILE
+- GET_WALLET_BALANCE
+- GET_BALANCE
+- SEND_COIN
+- SEARCH_CHAT_MESSAGES
+- SEND_CHAT_MESSAGE
+- LIST_GROUPS
+- JOIN_GROUP
+- DEPLOY_AT
+- GET_AT
+- GET_AT_DATA
+- LIST_ATS
+- FETCH_BLOCK
+- FETCH_BLOCK_RANGE
+- SEARCH_TRANSACTIONS
+- GET_PRICE
+- GET_LIST_ITEMS
+- ADD_LIST_ITEMS
+- DELETE_LIST_ITEM
+
+More functionality will be added in the future.
+
+## Example Requests
+
+Here are some example requests for each of the above:
+
+### Get address of logged in account
+_Will likely require user approval_
+```
+let account = await qortalRequest({
+ action: "GET_USER_ACCOUNT"
+});
+let address = account.address;
+```
+
+### Get public key of logged in account
+_Will likely require user approval_
+```
+let pubkey = await qortalRequest({
+ action: "GET_USER_ACCOUNT"
+});
+let publicKey = account.publicKey;
+```
+
+### Get account data
+```
+let res = await qortalRequest({
+ action: "GET_ACCOUNT_DATA",
+ address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
+});
+```
+
+### Get names owned by account
+```
+let res = await qortalRequest({
+ action: "GET_ACCOUNT_NAMES",
+ address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
+});
+```
+
+### Search names
+```
+let res = await qortalRequest({
+ action: "SEARCH_NAMES",
+ query: "search query goes here",
+ prefix: false, // Optional - if true, only the beginning of the name is matched
+ limit: 100,
+ offset: 0,
+ reverse: false
+});
+```
+
+### Get name data
+```
+let res = await qortalRequest({
+ action: "GET_NAME_DATA",
+ name: "QortalDemo"
+});
+```
+
+
+### List QDN resources
+```
+let res = await qortalRequest({
+ action: "LIST_QDN_RESOURCES",
+ service: "THUMBNAIL",
+ name: "QortalDemo", // Optional (exact match)
+ identifier: "qortal_avatar", // Optional (exact match)
+ default: true, // Optional
+ includeStatus: false, // Optional - will take time to respond, so only request if necessary
+ includeMetadata: false, // Optional - will take time to respond, so only request if necessary
+ followedOnly: false, // Optional - include followed names only
+ excludeBlocked: false, // Optional - exclude blocked content
+ limit: 100,
+ offset: 0,
+ reverse: true
+});
+```
+
+### Search QDN resources
+```
+let res = await qortalRequest({
+ action: "SEARCH_QDN_RESOURCES",
+ service: "THUMBNAIL",
+ query: "search query goes here", // Optional - searches both "identifier" and "name" fields
+ identifier: "search query goes here", // Optional - searches only the "identifier" field
+ name: "search query goes here", // Optional - searches only the "name" field
+ prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
+ exactMatchNames: true, // Optional - if true, partial name matches are excluded
+ default: false, // Optional - if true, only resources without identifiers are returned
+ includeStatus: false, // Optional - will take time to respond, so only request if necessary
+ includeMetadata: false, // Optional - will take time to respond, so only request if necessary
+ nameListFilter: "QApp1234Subscriptions", // Optional - will only return results if they are from a name included in supplied list
+ followedOnly: false, // Optional - include followed names only
+ excludeBlocked: false, // Optional - exclude blocked content
+ limit: 100,
+ offset: 0,
+ reverse: true
+});
+```
+
+### Search QDN resources (multiple names)
+```
+let res = await qortalRequest({
+ action: "SEARCH_QDN_RESOURCES",
+ service: "THUMBNAIL",
+ query: "search query goes here", // Optional - searches both "identifier" and "name" fields
+ identifier: "search query goes here", // Optional - searches only the "identifier" field
+ names: ["QortalDemo", "crowetic", "AlphaX"], // Optional - searches only the "name" field for any of the supplied names
+ prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
+ default: false, // Optional - if true, only resources without identifiers are returned
+ includeStatus: false, // Optional - will take time to respond, so only request if necessary
+ includeMetadata: false, // Optional - will take time to respond, so only request if necessary
+ nameListFilter: "QApp1234Subscriptions", // Optional - will only return results if they are from a name included in supplied list
+ followedOnly: false, // Optional - include followed names only
+ excludeBlocked: false, // Optional - exclude blocked content
+ limit: 100,
+ offset: 0,
+ reverse: true
+});
+```
+
+### Fetch QDN single file resource
+```
+let res = await qortalRequest({
+ action: "FETCH_QDN_RESOURCE",
+ name: "QortalDemo",
+ service: "THUMBNAIL",
+ identifier: "qortal_avatar", // Optional. If omitted, the default resource is returned, or you can alternatively use the keyword "default"
+ encoding: "base64", // Optional. If omitted, data is returned in raw form
+ rebuild: false
+});
+```
+
+### Fetch file from multi file QDN resource
+Data is returned in the base64 format
+```
+let res = await qortalRequest({
+ action: "FETCH_QDN_RESOURCE",
+ name: "QortalDemo",
+ service: "WEBSITE",
+ identifier: "default", // Optional. If omitted, the default resource is returned, or you can alternatively request that using the keyword "default", as shown here
+ filepath: "index.html", // Required only for resources containing more than one file
+ rebuild: false
+});
+```
+
+### Get QDN resource status
+```
+let res = await qortalRequest({
+ action: "GET_QDN_RESOURCE_STATUS",
+ name: "QortalDemo",
+ service: "THUMBNAIL",
+ identifier: "qortal_avatar", // Optional
+ build: true // Optional - request that the resource is fetched & built in the background
+});
+```
+
+### Get QDN resource properties
+```
+let res = await qortalRequest({
+ action: "GET_QDN_RESOURCE_PROPERTIES",
+ name: "QortalDemo",
+ service: "THUMBNAIL",
+ identifier: "qortal_avatar" // Optional
+});
+// Returns: filename, size, mimeType (where available)
+```
+
+### Get QDN resource metadata
+```
+let res = await qortalRequest({
+ action: "GET_QDN_RESOURCE_METADATA",
+ name: "QortalDemo",
+ service: "THUMBNAIL",
+ identifier: "qortal_avatar" // Optional
+});
+```
+
+### Publish a single file to QDN
+_Requires user approval_.
+Note: this publishes a single, base64-encoded file. Multi-file resource publishing (such as a WEBSITE or GIF_REPOSITORY) is not yet supported via a Q-App. It will be added in a future update.
+```
+let res = await qortalRequest({
+ action: "PUBLISH_QDN_RESOURCE",
+ name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list
+ service: "IMAGE",
+ identifier: "myapp-image1234" // Optional
+ data64: "base64_encoded_data",
+ // filename: "image.jpg", // Optional - to help apps determine the file's type
+ // title: "Title", // Optional
+ // description: "Description", // Optional
+ // category: "TECHNOLOGY", // Optional
+ // tag1: "any", // Optional
+ // tag2: "strings", // Optional
+ // tag3: "can", // Optional
+ // tag4: "go", // Optional
+ // tag5: "here", // Optional
+ // encrypt: true, // Optional - to be used with a private service
+ // recipientPublicKey: "publickeygoeshere" // Only required if `encrypt` is set to true
+});
+```
+
+### Publish multiple resources at once to QDN
+_Requires user approval_.
+Note: each resource being published consists of a single, base64-encoded file, each in its own transaction. Useful for publishing two or more related things, such as a video and a video thumbnail.
+```
+let res = await qortalRequest({
+ action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
+ resources: [
+ name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list
+ service: "IMAGE",
+ identifier: "myapp-image1234" // Optional
+ data64: "base64_encoded_data",
+ // filename: "image.jpg", // Optional - to help apps determine the file's type
+ // title: "Title", // Optional
+ // description: "Description", // Optional
+ // category: "TECHNOLOGY", // Optional
+ // tag1: "any", // Optional
+ // tag2: "strings", // Optional
+ // tag3: "can", // Optional
+ // tag4: "go", // Optional
+ // tag5: "here", // Optional
+ // encrypt: true, // Optional - to be used with a private service
+ // recipientPublicKey: "publickeygoeshere" // Only required if `encrypt` is set to true
+ ],
+ [
+ ... more resources here if needed ...
+ ]
+});
+```
+
+### Decrypt encrypted/private data
+```
+let res = await qortalRequest({
+ action: "DECRYPT_DATA",
+ encryptedData: 'qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1r',
+ publicKey: 'publickeygoeshere'
+});
+// Returns base64 encoded string of plaintext data
+```
+
+### Prompt user to save a file to disk
+Note: mimeType not required but recommended. If not specified, saving will fail if the mimeType is unable to be derived from the Blob.
+```
+let res = await qortalRequest({
+ action: "SAVE_FILE",
+ blob: dataBlob,
+ filename: "myfile.pdf",
+ mimeType: "application/pdf" // Optional but recommended
+});
+```
+
+
+### Get wallet balance (QORT)
+_Requires user approval_
+```
+let res = await qortalRequest({
+ action: "GET_WALLET_BALANCE",
+ coin: "QORT"
+});
+```
+
+
+### Get address or asset balance
+```
+let res = await qortalRequest({
+ action: "GET_BALANCE",
+ address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
+});
+```
+```
+let res = await qortalRequest({
+ action: "GET_BALANCE",
+ assetId: 1,
+ address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
+});
+```
+
+### Send QORT to address
+_Requires user approval_
+```
+let res = await qortalRequest({
+ action: "SEND_COIN",
+ coin: "QORT",
+ destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
+ amount: 1.00000000 // 1 QORT
+});
+```
+
+### Send foreign coin to address
+_Requires user approval_
+Note: default fees can be found [here](https://github.com/Qortal/qortal-ui/blob/master/plugins/plugins/core/qdn/browser/browser.src.js#L205-L209).
+```
+let res = await qortalRequest({
+ action: "SEND_COIN",
+ coin: "LTC",
+ destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y",
+ amount: 1.00000000, // 1 LTC
+ fee: 0.00000020 // Optional fee per byte (default fee used if omitted, recommended) - not used for QORT or ARRR
+});
+```
+
+### Search or list chat messages
+```
+let res = await qortalRequest({
+ action: "SEARCH_CHAT_MESSAGES",
+ before: 999999999999999,
+ after: 0,
+ txGroupId: 0, // Optional (must specify either txGroupId or two involving addresses)
+ // involving: ["QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", "QSefrppsDCsZebcwrqiM1gNbWq7YMDXtG2"], // Optional (must specify either txGroupId or two involving addresses)
+ // reference: "reference", // Optional
+ // chatReference: "chatreference", // Optional
+ // hasChatReference: true, // Optional
+ encoding: "BASE64", // Optional (defaults to BASE58 if omitted)
+ limit: 100,
+ offset: 0,
+ reverse: true
+});
+```
+
+### Send a group chat message
+_Requires user approval_
+```
+let res = await qortalRequest({
+ action: "SEND_CHAT_MESSAGE",
+ groupId: 0,
+ message: "Test"
+});
+```
+
+### Send a private chat message
+_Requires user approval_
+```
+let res = await qortalRequest({
+ action: "SEND_CHAT_MESSAGE",
+ destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
+ message: "Test"
+});
+```
+
+### List groups
+```
+let res = await qortalRequest({
+ action: "LIST_GROUPS",
+ limit: 100,
+ offset: 0,
+ reverse: true
+});
+```
+
+### Join a group
+_Requires user approval_
+```
+let res = await qortalRequest({
+ action: "JOIN_GROUP",
+ groupId: 100
+});
+```
+
+
+### Deploy an AT
+_Requires user approval_
+```
+let res = await qortalRequest({
+ action: "DEPLOY_AT",
+ creationBytes: "12345", // Must be Base58 encoded
+ name: "test name",
+ description: "test description",
+ type: "test type",
+ tags: "test tags",
+ amount: 1.00000000, // 1 QORT
+ assetId: 0,
+ // fee: 0.002 // optional - will use default fee if excluded
+});
+```
+
+### Get AT info
+```
+let res = await qortalRequest({
+ action: "GET_AT",
+ atAddress: "ASRUsCjk6fa5bujv3oWYmWaVqNtvxydpPH"
+});
+```
+
+### Get AT data bytes (base58 encoded)
+```
+let res = await qortalRequest({
+ action: "GET_AT_DATA",
+ atAddress: "ASRUsCjk6fa5bujv3oWYmWaVqNtvxydpPH"
+});
+```
+
+### List ATs by functionality
+```
+let res = await qortalRequest({
+ action: "LIST_ATS",
+ codeHash58: "4KdJETRAdymE7dodDmJbf5d9L1bp4g5Nxky8m47TBkvA",
+ isExecutable: true,
+ limit: 100,
+ offset: 0,
+ reverse: true
+});
+```
+
+### Fetch block by signature
+```
+let res = await qortalRequest({
+ action: "FETCH_BLOCK",
+ signature: "875yGFUy1zHV2hmxNWzrhtn9S1zkeD7SQppwdXFysvTXrankCHCz4iyAUgCBM3GjvibbnyRQpriuy1cyu953U1u5uQdzuH3QjQivi9UVwz86z1Akn17MGd5Z5STjpDT7248K6vzMamuqDei57Znonr8GGgn8yyyABn35CbZUCeAuXju"
+});
+```
+
+### Fetch block by height
+```
+let res = await qortalRequest({
+ action: "FETCH_BLOCK",
+ height: "1139850"
+});
+```
+
+### Fetch a range of blocks
+```
+let res = await qortalRequest({
+ action: "FETCH_BLOCK_RANGE",
+ height: "1139800",
+ count: 20,
+ reverse: false
+});
+```
+
+### Search transactions
+```
+let res = await qortalRequest({
+ action: "SEARCH_TRANSACTIONS",
+ // startBlock: 1139000,
+ // blockLimit: 1000,
+ txGroupId: 0,
+ txType: [
+ "PAYMENT",
+ "REWARD_SHARE"
+ ],
+ confirmationStatus: "CONFIRMED",
+ limit: 10,
+ offset: 0,
+ reverse: false
+});
+```
+
+### Get an estimate of the QORT price
+```
+let res = await qortalRequest({
+ action: "GET_PRICE",
+ blockchain: "LITECOIN",
+ // maxtrades: 10,
+ inverse: true
+});
+```
+
+### Get URL to load a QDN resource
+Note: this returns a "Resource does not exist" error if a non-existent resource is requested.
+```
+let url = await qortalRequest({
+ action: "GET_QDN_RESOURCE_URL",
+ service: "THUMBNAIL",
+ name: "QortalDemo",
+ identifier: "qortal_avatar"
+ // path: "filename.jpg" // optional - not needed if resource contains only one file
+});
+```
+
+### Get URL to load a QDN website
+Note: this returns a "Resource does not exist" error if a non-existent resource is requested.
+```
+let url = await qortalRequest({
+ action: "GET_QDN_RESOURCE_URL",
+ service: "WEBSITE",
+ name: "QortalDemo",
+});
+```
+
+### Get URL to load a specific file from a QDN website
+Note: this returns a "Resource does not exist" error if a non-existent resource is requested.
+```
+let url = await qortalRequest({
+ action: "GET_QDN_RESOURCE_URL",
+ service: "WEBSITE",
+ name: "AlphaX",
+ path: "/assets/img/logo.png"
+});
+```
+
+### Link/redirect to another QDN website
+Note: an alternate method is to include `link text` within your HTML code.
+```
+let res = await qortalRequest({
+ action: "LINK_TO_QDN_RESOURCE",
+ service: "WEBSITE",
+ name: "QortalDemo",
+});
+```
+
+### Link/redirect to a specific path of another QDN website
+Note: an alternate method is to include `link text` within your HTML code.
+```
+let res = await qortalRequest({
+ action: "LINK_TO_QDN_RESOURCE",
+ service: "WEBSITE",
+ name: "QortalDemo",
+ path: "/minting-leveling/index.html"
+});
+```
+
+### Get the contents of a list
+_Requires user approval_
+```
+let res = await qortalRequest({
+ action: "GET_LIST_ITEMS",
+ list_name: "followedNames"
+});
+```
+
+### Add one or more items to a list
+_Requires user approval_
+```
+let res = await qortalRequest({
+ action: "ADD_LIST_ITEMS",
+ list_name: "blockedNames",
+ items: ["QortalDemo"]
+});
+```
+
+### Delete a single item from a list
+_Requires user approval_.
+Items must be deleted one at a time.
+```
+let res = await qortalRequest({
+ action: "DELETE_LIST_ITEM",
+ list_name: "blockedNames",
+ item: "QortalDemo"
+});
+```
+
+
+# Section 4: Examples
+
+Some example projects can be found [here](https://github.com/Qortal/Q-Apps). These can be cloned and modified, or used as a reference when creating a new app.
+
+
+## Sample App
+
+Here is a sample application to display the logged-in user's avatar:
+```
+
+
+
+
+
+
+
+
+```
+
+
+# Section 5: Testing and Development
+
+Publishing an in-development app to mainnet isn't recommended. There are several options for developing and testing a Q-app before publishing to mainnet:
+
+### Preview mode
+
+Select "Preview" in the UI after choosing the zip. This allows for full Q-App testing without the need to publish any data.
+
+
+### Testnets
+
+For an end-to-end test of Q-App publishing, you can use the official testnet, or set up a single node testnet of your own (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](testnet/README.md#quick-start).
+
+
+### Debugging
+
+It is recommended that you develop and test in a web browser, to allow access to the javascript console. To do this:
+1. Open the UI app, then minimise it.
+2. In a Chromium-based web browser, visit: http://localhost:12388/
+3. Log in to your account and then preview your app/website.
+4. Go to `View > Developer > JavaScript Console`. Here you can monitor console logs, errors, and network requests from your app, in the same way as any other web-app.
diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip
index 74acc012..51ba5f69 100755
--- a/WindowsInstaller/Qortal.aip
+++ b/WindowsInstaller/Qortal.aip
@@ -17,10 +17,10 @@
-
+
-
+
@@ -212,7 +212,7 @@
-
+
@@ -1173,7 +1173,7 @@
-
+
diff --git a/lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar
new file mode 100644
index 00000000..c2c3d355
Binary files /dev/null and b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar differ
diff --git a/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom
new file mode 100644
index 00000000..0dc1aedc
--- /dev/null
+++ b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom
@@ -0,0 +1,9 @@
+
+
+ 4.0.0
+ org.ciyam
+ AT
+ 1.4.0
+ POM was created from install:install-file
+
diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml
index 8f8b1f6e..063c735d 100644
--- a/lib/org/ciyam/AT/maven-metadata-local.xml
+++ b/lib/org/ciyam/AT/maven-metadata-local.xml
@@ -3,14 +3,15 @@
org.ciyamAT
- 1.3.8
+ 1.4.01.3.41.3.51.3.61.3.71.3.8
+ 1.4.0
- 20200925114415
+ 20221105114346
diff --git a/pom.xml b/pom.xml
index 3be7fff3..fbcd40a5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
4.0.0org.qortalqortal
- 3.6.1
+ 4.3.0jartrue
@@ -11,7 +11,7 @@
0.15.101.69${maven.build.timestamp}
- 1.3.8
+ 1.4.03.61.82.6
@@ -36,6 +36,7 @@
4.101.45.13.19.4
+ 1.17src/main/java
@@ -147,6 +148,7 @@
tagsSorter: "alpha",
operationsSorter:
"alpha",
+ validatorUrl: false,
@@ -304,6 +306,7 @@
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
org.qortal.controller.Controller
+ true. ..
@@ -727,5 +730,10 @@
protobuf-java${protobuf.version}
+
+ com.j256.simplemagic
+ simplemagic
+ ${simplemagic.version}
+
diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java
index c3a25fb6..2c75dbc0 100644
--- a/src/main/java/org/qortal/account/Account.java
+++ b/src/main/java/org/qortal/account/Account.java
@@ -211,7 +211,8 @@ public class Account {
if (level != null && level >= BlockChain.getInstance().getMinAccountLevelToMint())
return true;
- if (Account.isFounder(accountData.getFlags()))
+ // Founders can always mint, unless they have a penalty
+ if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0)
return true;
return false;
@@ -222,6 +223,11 @@ public class Account {
return this.repository.getAccountRepository().getMintedBlockCount(this.address);
}
+ /** Returns account's blockMintedPenalty or null if account not found in repository. */
+ public Integer getBlocksMintedPenalty() throws DataException {
+ return this.repository.getAccountRepository().getBlocksMintedPenaltyCount(this.address);
+ }
+
/** Returns whether account can build reward-shares.
*
@@ -243,7 +249,7 @@ public class Account {
if (level != null && level >= BlockChain.getInstance().getMinAccountLevelToRewardShare())
return true;
- if (Account.isFounder(accountData.getFlags()))
+ if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0)
return true;
return false;
@@ -271,7 +277,7 @@ public class Account {
/**
* Returns 'effective' minting level, or zero if account does not exist/cannot mint.
*
- * For founder accounts, this returns "founderEffectiveMintingLevel" from blockchain config.
+ * For founder accounts with no penalty, this returns "founderEffectiveMintingLevel" from blockchain config.
*
* @return 0+
* @throws DataException
@@ -281,7 +287,8 @@ public class Account {
if (accountData == null)
return 0;
- if (Account.isFounder(accountData.getFlags()))
+ // Founders are assigned a different effective minting level, as long as they have no penalty
+ if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0)
return BlockChain.getInstance().getFounderEffectiveMintingLevel();
return accountData.getLevel();
@@ -289,8 +296,6 @@ public class Account {
/**
* Returns 'effective' minting level, or zero if reward-share does not exist.
- *
- * this is being used on src/main/java/org/qortal/api/resource/AddressesResource.java to fulfil the online accounts api call
*
* @param repository
* @param rewardSharePublicKey
@@ -309,7 +314,7 @@ public class Account {
/**
* Returns 'effective' minting level, with a fix for the zero level.
*
- * For founder accounts, this returns "founderEffectiveMintingLevel" from blockchain config.
+ * For founder accounts with no penalty, this returns "founderEffectiveMintingLevel" from blockchain config.
*
* @param repository
* @param rewardSharePublicKey
@@ -322,7 +327,7 @@ public class Account {
if (rewardShareData == null)
return 0;
- else if(!rewardShareData.getMinter().equals(rewardShareData.getRecipient()))//the minter is different than the recipient this means sponsorship
+ else if (!rewardShareData.getMinter().equals(rewardShareData.getRecipient())) // Sponsorship reward share
return 0;
Account rewardShareMinter = new Account(repository, rewardShareData.getMinter());
diff --git a/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java
new file mode 100644
index 00000000..725e53f5
--- /dev/null
+++ b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java
@@ -0,0 +1,367 @@
+package org.qortal.account;
+
+import org.qortal.api.resource.TransactionsResource;
+import org.qortal.asset.Asset;
+import org.qortal.data.account.AccountData;
+import org.qortal.data.naming.NameData;
+import org.qortal.data.transaction.*;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+import org.qortal.transaction.Transaction.TransactionType;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+public class SelfSponsorshipAlgoV1 {
+
+ private final Repository repository;
+ private final String address;
+ private final AccountData accountData;
+ private final long snapshotTimestamp;
+ private final boolean override;
+
+ private int registeredNameCount = 0;
+ private int suspiciousCount = 0;
+ private int suspiciousPercent = 0;
+ private int consolidationCount = 0;
+ private int bulkIssuanceCount = 0;
+ private int recentSponsorshipCount = 0;
+
+ private List sponsorshipRewardShares = new ArrayList<>();
+ private final Map> paymentsByAddress = new HashMap<>();
+ private final Set sponsees = new LinkedHashSet<>();
+ private Set consolidatedAddresses = new LinkedHashSet<>();
+ private final Set zeroTransactionAddreses = new LinkedHashSet<>();
+ private final Set penaltyAddresses = new LinkedHashSet<>();
+
+ public SelfSponsorshipAlgoV1(Repository repository, String address, long snapshotTimestamp, boolean override) throws DataException {
+ this.repository = repository;
+ this.address = address;
+ this.accountData = this.repository.getAccountRepository().getAccount(this.address);
+ this.snapshotTimestamp = snapshotTimestamp;
+ this.override = override;
+ }
+
+ public String getAddress() {
+ return this.address;
+ }
+
+ public Set getPenaltyAddresses() {
+ return this.penaltyAddresses;
+ }
+
+
+ public void run() throws DataException {
+ if (this.accountData == null) {
+ // Nothing to do
+ return;
+ }
+
+ this.fetchSponsorshipRewardShares();
+ if (this.sponsorshipRewardShares.isEmpty()) {
+ // Nothing to do
+ return;
+ }
+
+ this.findConsolidatedRewards();
+ this.findBulkIssuance();
+ this.findRegisteredNameCount();
+ this.findRecentSponsorshipCount();
+
+ int score = this.calculateScore();
+ if (score <= 0 && !override) {
+ return;
+ }
+
+ String newAddress = this.getDestinationAccount(this.address);
+ while (newAddress != null) {
+ // Found destination account
+ this.penaltyAddresses.add(newAddress);
+
+ // Run algo for this address, but in "override" mode because it has already been flagged
+ SelfSponsorshipAlgoV1 algoV1 = new SelfSponsorshipAlgoV1(this.repository, newAddress, this.snapshotTimestamp, true);
+ algoV1.run();
+ this.penaltyAddresses.addAll(algoV1.getPenaltyAddresses());
+
+ newAddress = this.getDestinationAccount(newAddress);
+ }
+
+ this.penaltyAddresses.add(this.address);
+
+ if (this.override || this.recentSponsorshipCount < 20) {
+ this.penaltyAddresses.addAll(this.consolidatedAddresses);
+ this.penaltyAddresses.addAll(this.zeroTransactionAddreses);
+ }
+ else {
+ this.penaltyAddresses.addAll(this.sponsees);
+ }
+ }
+
+ private String getDestinationAccount(String address) throws DataException {
+ List transferPrivsTransactions = fetchTransferPrivsForAddress(address);
+ if (transferPrivsTransactions.isEmpty()) {
+ // No TRANSFER_PRIVS transactions for this address
+ return null;
+ }
+
+ AccountData accountData = this.repository.getAccountRepository().getAccount(address);
+ if (accountData == null) {
+ return null;
+ }
+
+ for (TransactionData transactionData : transferPrivsTransactions) {
+ TransferPrivsTransactionData transferPrivsTransactionData = (TransferPrivsTransactionData) transactionData;
+ if (Arrays.equals(transferPrivsTransactionData.getSenderPublicKey(), accountData.getPublicKey())) {
+ return transferPrivsTransactionData.getRecipient();
+ }
+ }
+
+ return null;
+ }
+
+ private void findConsolidatedRewards() throws DataException {
+ List sponseesThatSentRewards = new ArrayList<>();
+ Map paymentRecipients = new HashMap<>();
+
+ // Collect outgoing payments of each sponsee
+ for (String sponseeAddress : this.sponsees) {
+
+ // Firstly fetch all payments for address, since the functions below depend on this data
+ this.fetchPaymentsForAddress(sponseeAddress);
+
+ // Check if the address has zero relevant transactions
+ if (this.hasZeroTransactions(sponseeAddress)) {
+ this.zeroTransactionAddreses.add(sponseeAddress);
+ }
+
+ // Get payment recipients
+ List allPaymentRecipients = this.fetchOutgoingPaymentRecipientsForAddress(sponseeAddress);
+ if (allPaymentRecipients.isEmpty()) {
+ continue;
+ }
+ sponseesThatSentRewards.add(sponseeAddress);
+
+ List addressesPaidByThisSponsee = new ArrayList<>();
+ for (String paymentRecipient : allPaymentRecipients) {
+ if (addressesPaidByThisSponsee.contains(paymentRecipient)) {
+ // We already tracked this association - don't allow multiple to stack up
+ continue;
+ }
+ addressesPaidByThisSponsee.add(paymentRecipient);
+
+ // Increment count for this recipient, or initialize to 1 if not present
+ if (paymentRecipients.computeIfPresent(paymentRecipient, (k, v) -> v + 1) == null) {
+ paymentRecipients.put(paymentRecipient, 1);
+ }
+ }
+
+ }
+
+ // Exclude addresses with a low number of payments
+ Map filteredPaymentRecipients = paymentRecipients.entrySet().stream()
+ .filter(p -> p.getValue() != null && p.getValue() >= 10)
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+ // Now check how many sponsees have sent to this subset of addresses
+ Map sponseesThatConsolidatedRewards = new HashMap<>();
+ for (String sponseeAddress : sponseesThatSentRewards) {
+ List allPaymentRecipients = this.fetchOutgoingPaymentRecipientsForAddress(sponseeAddress);
+ // Remove any that aren't to one of the flagged recipients (i.e. consolidation)
+ allPaymentRecipients.removeIf(r -> !filteredPaymentRecipients.containsKey(r));
+
+ int count = allPaymentRecipients.size();
+ if (count == 0) {
+ continue;
+ }
+ if (sponseesThatConsolidatedRewards.computeIfPresent(sponseeAddress, (k, v) -> v + count) == null) {
+ sponseesThatConsolidatedRewards.put(sponseeAddress, count);
+ }
+ }
+
+ // Remove sponsees that have only sent a low number of payments to the filtered addresses
+ Map filteredSponseesThatConsolidatedRewards = sponseesThatConsolidatedRewards.entrySet().stream()
+ .filter(p -> p.getValue() != null && p.getValue() >= 2)
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+ this.consolidationCount = sponseesThatConsolidatedRewards.size();
+ this.consolidatedAddresses = new LinkedHashSet<>(filteredSponseesThatConsolidatedRewards.keySet());
+ this.suspiciousCount = this.consolidationCount + this.zeroTransactionAddreses.size();
+ this.suspiciousPercent = (int)(this.suspiciousCount / (float) this.sponsees.size() * 100);
+ }
+
+ private void findBulkIssuance() {
+ Long lastTimestamp = null;
+ for (RewardShareTransactionData rewardShareTransactionData : sponsorshipRewardShares) {
+ long timestamp = rewardShareTransactionData.getTimestamp();
+ if (timestamp >= this.snapshotTimestamp) {
+ continue;
+ }
+
+ if (lastTimestamp != null) {
+ if (timestamp - lastTimestamp < 3*60*1000L) {
+ this.bulkIssuanceCount++;
+ }
+ }
+ lastTimestamp = timestamp;
+ }
+ }
+
+ private void findRegisteredNameCount() throws DataException {
+ int registeredNameCount = 0;
+ for (String sponseeAddress : sponsees) {
+ List names = repository.getNameRepository().getNamesByOwner(sponseeAddress);
+ for (NameData name : names) {
+ if (name.getRegistered() < this.snapshotTimestamp) {
+ registeredNameCount++;
+ break;
+ }
+ }
+ }
+ this.registeredNameCount = registeredNameCount;
+ }
+
+ private void findRecentSponsorshipCount() {
+ final long referenceTimestamp = this.snapshotTimestamp - (365 * 24 * 60 * 60 * 1000L);
+ int recentSponsorshipCount = 0;
+ for (RewardShareTransactionData rewardShare : sponsorshipRewardShares) {
+ if (rewardShare.getTimestamp() >= referenceTimestamp) {
+ recentSponsorshipCount++;
+ }
+ }
+ this.recentSponsorshipCount = recentSponsorshipCount;
+ }
+
+ private int calculateScore() {
+ final int suspiciousMultiplier = (this.suspiciousCount >= 100) ? this.suspiciousPercent : 1;
+ final int nameMultiplier = (this.sponsees.size() >= 50 && this.registeredNameCount == 0) ? 2 : 1;
+ final int consolidationMultiplier = Math.max(this.consolidationCount, 1);
+ final int bulkIssuanceMultiplier = Math.max(this.bulkIssuanceCount / 2, 1);
+ final int offset = 9;
+ return suspiciousMultiplier * nameMultiplier * consolidationMultiplier * bulkIssuanceMultiplier - offset;
+ }
+
+ private void fetchSponsorshipRewardShares() throws DataException {
+ List sponsorshipRewardShares = new ArrayList<>();
+
+ // Define relevant transactions
+ List txTypes = List.of(TransactionType.REWARD_SHARE);
+ List transactionDataList = fetchTransactions(repository, txTypes, this.address, false);
+
+ for (TransactionData transactionData : transactionDataList) {
+ if (transactionData.getType() != TransactionType.REWARD_SHARE) {
+ continue;
+ }
+
+ RewardShareTransactionData rewardShareTransactionData = (RewardShareTransactionData) transactionData;
+
+ // Skip removals
+ if (rewardShareTransactionData.getSharePercent() < 0) {
+ continue;
+ }
+
+ // Skip if not sponsored by this account
+ if (!Arrays.equals(rewardShareTransactionData.getCreatorPublicKey(), accountData.getPublicKey())) {
+ continue;
+ }
+
+ // Skip self shares
+ if (Objects.equals(rewardShareTransactionData.getRecipient(), this.address)) {
+ continue;
+ }
+
+ boolean duplicateFound = false;
+ for (RewardShareTransactionData existingRewardShare : sponsorshipRewardShares) {
+ if (Objects.equals(existingRewardShare.getRecipient(), rewardShareTransactionData.getRecipient())) {
+ // Duplicate
+ duplicateFound = true;
+ break;
+ }
+ }
+ if (!duplicateFound) {
+ sponsorshipRewardShares.add(rewardShareTransactionData);
+ this.sponsees.add(rewardShareTransactionData.getRecipient());
+ }
+ }
+
+ this.sponsorshipRewardShares = sponsorshipRewardShares;
+ }
+
+ private List fetchTransferPrivsForAddress(String address) throws DataException {
+ return fetchTransactions(repository,
+ List.of(TransactionType.TRANSFER_PRIVS),
+ address, true);
+ }
+
+ private void fetchPaymentsForAddress(String address) throws DataException {
+ List payments = fetchTransactions(repository,
+ Arrays.asList(TransactionType.PAYMENT, TransactionType.TRANSFER_ASSET),
+ address, false);
+ this.paymentsByAddress.put(address, payments);
+ }
+
+ private List fetchOutgoingPaymentRecipientsForAddress(String address) {
+ List outgoingPaymentRecipients = new ArrayList<>();
+
+ List transactionDataList = this.paymentsByAddress.get(address);
+ if (transactionDataList == null) transactionDataList = new ArrayList<>();
+ transactionDataList.removeIf(t -> t.getTimestamp() >= this.snapshotTimestamp);
+ for (TransactionData transactionData : transactionDataList) {
+ switch (transactionData.getType()) {
+
+ case PAYMENT:
+ PaymentTransactionData paymentTransactionData = (PaymentTransactionData) transactionData;
+ if (!Objects.equals(paymentTransactionData.getRecipient(), address)) {
+ // Outgoing payment from this account
+ outgoingPaymentRecipients.add(paymentTransactionData.getRecipient());
+ }
+ break;
+
+ case TRANSFER_ASSET:
+ TransferAssetTransactionData transferAssetTransactionData = (TransferAssetTransactionData) transactionData;
+ if (transferAssetTransactionData.getAssetId() == Asset.QORT) {
+ if (!Objects.equals(transferAssetTransactionData.getRecipient(), address)) {
+ // Outgoing payment from this account
+ outgoingPaymentRecipients.add(transferAssetTransactionData.getRecipient());
+ }
+ }
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ return outgoingPaymentRecipients;
+ }
+
+ private boolean hasZeroTransactions(String address) {
+ List transactionDataList = this.paymentsByAddress.get(address);
+ if (transactionDataList == null) {
+ return true;
+ }
+ transactionDataList.removeIf(t -> t.getTimestamp() >= this.snapshotTimestamp);
+ return transactionDataList.size() == 0;
+ }
+
+ private static List fetchTransactions(Repository repository, List txTypes, String address, boolean reverse) throws DataException {
+ // Fetch all relevant transactions for this account
+ List signatures = repository.getTransactionRepository()
+ .getSignaturesMatchingCriteria(null, null, null, txTypes,
+ null, null, address, TransactionsResource.ConfirmationStatus.CONFIRMED,
+ null, null, reverse);
+
+ List transactionDataList = new ArrayList<>();
+
+ for (byte[] signature : signatures) {
+ // Fetch transaction data
+ TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature);
+ if (transactionData == null) {
+ continue;
+ }
+ transactionDataList.add(transactionData);
+ }
+
+ return transactionDataList;
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/ApiError.java b/src/main/java/org/qortal/api/ApiError.java
index 659104e7..b52332b1 100644
--- a/src/main/java/org/qortal/api/ApiError.java
+++ b/src/main/java/org/qortal/api/ApiError.java
@@ -79,7 +79,7 @@ public enum ApiError {
// BUYER_ALREADY_OWNER(411, 422),
// POLLS
- // POLL_NO_EXISTS(501, 404),
+ POLL_NO_EXISTS(501, 404),
// POLL_ALREADY_EXISTS(502, 422),
// DUPLICATE_OPTION(503, 422),
// POLL_OPTION_NO_EXISTS(504, 404),
diff --git a/src/main/java/org/qortal/api/ApiRequest.java b/src/main/java/org/qortal/api/ApiRequest.java
index 5517ff53..a51a117e 100644
--- a/src/main/java/org/qortal/api/ApiRequest.java
+++ b/src/main/java/org/qortal/api/ApiRequest.java
@@ -3,6 +3,7 @@ package org.qortal.api;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
+import java.io.Writer;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Socket;
@@ -20,14 +21,12 @@ import javax.net.ssl.SNIHostName;
import javax.net.ssl.SNIServerName;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
-import javax.xml.bind.JAXBContext;
-import javax.xml.bind.JAXBException;
-import javax.xml.bind.UnmarshalException;
-import javax.xml.bind.Unmarshaller;
+import javax.xml.bind.*;
import javax.xml.transform.stream.StreamSource;
import org.eclipse.persistence.exceptions.XMLMarshalException;
import org.eclipse.persistence.jaxb.JAXBContextFactory;
+import org.eclipse.persistence.jaxb.MarshallerProperties;
import org.eclipse.persistence.jaxb.UnmarshallerProperties;
public class ApiRequest {
@@ -107,6 +106,36 @@ public class ApiRequest {
}
}
+ 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 API marshaller", e);
+ }
+ }
+
+ public 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 API", e);
+ }
+ }
+
public static String getParamsString(Map params) {
StringBuilder result = new StringBuilder();
diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java
index 78c9250c..1ee733c6 100644
--- a/src/main/java/org/qortal/api/ApiService.java
+++ b/src/main/java/org/qortal/api/ApiService.java
@@ -13,8 +13,8 @@ import java.security.SecureRandom;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
+import javax.servlet.http.HttpServletRequest;
-import org.checkerframework.checker.units.qual.A;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.rewrite.handler.RedirectPatternRule;
import org.eclipse.jetty.rewrite.handler.RewriteHandler;
@@ -41,6 +41,7 @@ import org.glassfish.jersey.servlet.ServletContainer;
import org.qortal.api.resource.AnnotationPostProcessor;
import org.qortal.api.resource.ApiDefinition;
import org.qortal.api.websocket.*;
+import org.qortal.network.Network;
import org.qortal.settings.Settings;
public class ApiService {
@@ -51,9 +52,11 @@ public class ApiService {
private Server server;
private ApiKey apiKey;
+ public static final String API_VERSION_HEADER = "X-API-VERSION";
+
private ApiService() {
this.config = new ResourceConfig();
- this.config.packages("org.qortal.api.resource");
+ this.config.packages("org.qortal.api.resource", "org.qortal.api.restricted.resource");
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);
@@ -93,7 +96,7 @@ public class ApiService {
throw new RuntimeException("Failed to start SSL API due to broken keystore");
// BouncyCastle-specific SSLContext build
- SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
+ SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE");
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
@@ -123,13 +126,13 @@ public class ApiService {
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
new DetectorConnectionFactory(sslConnectionFactory),
httpConnectionFactory);
- portUnifiedConnector.setHost(Settings.getInstance().getBindAddress());
+ portUnifiedConnector.setHost(Network.getInstance().getBindAddress());
portUnifiedConnector.setPort(Settings.getInstance().getApiPort());
this.server.addConnector(portUnifiedConnector);
} else {
// Non-SSL
- InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress());
+ InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress());
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getApiPort());
this.server = new Server(endpoint);
}
@@ -230,4 +233,19 @@ public class ApiService {
this.server = null;
}
+ public static int getApiVersion(HttpServletRequest request) {
+ // Get API version
+ String apiVersionString = request.getHeader(API_VERSION_HEADER);
+ if (apiVersionString == null) {
+ // Try query string - this is needed to avoid a CORS preflight. See: https://stackoverflow.com/a/43881141
+ apiVersionString = request.getParameter("apiVersion");
+ }
+
+ int apiVersion = 1;
+ if (apiVersionString != null) {
+ apiVersion = Integer.parseInt(apiVersionString);
+ }
+ return apiVersion;
+ }
+
}
diff --git a/src/main/java/org/qortal/api/DevProxyService.java b/src/main/java/org/qortal/api/DevProxyService.java
new file mode 100644
index 00000000..e0bf02db
--- /dev/null
+++ b/src/main/java/org/qortal/api/DevProxyService.java
@@ -0,0 +1,173 @@
+package org.qortal.api;
+
+import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource;
+import org.eclipse.jetty.http.HttpVersion;
+import org.eclipse.jetty.rewrite.handler.RewriteHandler;
+import org.eclipse.jetty.server.*;
+import org.eclipse.jetty.server.handler.ErrorHandler;
+import org.eclipse.jetty.server.handler.InetAccessHandler;
+import org.eclipse.jetty.servlet.FilterHolder;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.servlets.CrossOriginFilter;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.servlet.ServletContainer;
+import org.qortal.api.resource.AnnotationPostProcessor;
+import org.qortal.api.resource.ApiDefinition;
+import org.qortal.network.Network;
+import org.qortal.repository.DataException;
+import org.qortal.settings.Settings;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import java.io.InputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+
+public class DevProxyService {
+
+ private static DevProxyService instance;
+
+ private final ResourceConfig config;
+ private Server server;
+
+ private DevProxyService() {
+ this.config = new ResourceConfig();
+ this.config.packages("org.qortal.api.proxy.resource", "org.qortal.api.resource");
+ this.config.register(OpenApiResource.class);
+ this.config.register(ApiDefinition.class);
+ this.config.register(AnnotationPostProcessor.class);
+ }
+
+ public static DevProxyService getInstance() {
+ if (instance == null)
+ instance = new DevProxyService();
+
+ return instance;
+ }
+
+ public Iterable> getResources() {
+ return this.config.getClasses();
+ }
+
+ public void start() throws DataException {
+ try {
+ // Create API server
+
+ // SSL support if requested
+ String keystorePathname = Settings.getInstance().getSslKeystorePathname();
+ String keystorePassword = Settings.getInstance().getSslKeystorePassword();
+
+ if (keystorePathname != null && keystorePassword != null) {
+ // SSL version
+ if (!Files.isReadable(Path.of(keystorePathname)))
+ throw new RuntimeException("Failed to start SSL API due to broken keystore");
+
+ // BouncyCastle-specific SSLContext build
+ SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
+
+ KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
+
+ try (InputStream keystoreStream = Files.newInputStream(Paths.get(keystorePathname))) {
+ keyStore.load(keystoreStream, keystorePassword.toCharArray());
+ }
+
+ keyManagerFactory.init(keyStore, keystorePassword.toCharArray());
+ sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom());
+
+ SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
+ sslContextFactory.setSslContext(sslContext);
+
+ this.server = new Server();
+
+ HttpConfiguration httpConfig = new HttpConfiguration();
+ httpConfig.setSecureScheme("https");
+ httpConfig.setSecurePort(Settings.getInstance().getDevProxyPort());
+
+ SecureRequestCustomizer src = new SecureRequestCustomizer();
+ httpConfig.addCustomizer(src);
+
+ HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(httpConfig);
+ SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString());
+
+ ServerConnector portUnifiedConnector = new ServerConnector(this.server,
+ new DetectorConnectionFactory(sslConnectionFactory),
+ httpConnectionFactory);
+ portUnifiedConnector.setHost(Network.getInstance().getBindAddress());
+ portUnifiedConnector.setPort(Settings.getInstance().getDevProxyPort());
+
+ this.server.addConnector(portUnifiedConnector);
+ } else {
+ // Non-SSL
+ InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress());
+ InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDevProxyPort());
+ this.server = new Server(endpoint);
+ }
+
+ // Error handler
+ ErrorHandler errorHandler = new ApiErrorHandler();
+ this.server.setErrorHandler(errorHandler);
+
+ // Request logging
+ if (Settings.getInstance().isDevProxyLoggingEnabled()) {
+ RequestLogWriter logWriter = new RequestLogWriter("devproxy-requests.log");
+ logWriter.setAppend(true);
+ logWriter.setTimeZone("UTC");
+ RequestLog requestLog = new CustomRequestLog(logWriter, CustomRequestLog.EXTENDED_NCSA_FORMAT);
+ this.server.setRequestLog(requestLog);
+ }
+
+ // Access handler (currently no whitelist is used)
+ InetAccessHandler accessHandler = new InetAccessHandler();
+ this.server.setHandler(accessHandler);
+
+ // URL rewriting
+ RewriteHandler rewriteHandler = new RewriteHandler();
+ accessHandler.setHandler(rewriteHandler);
+
+ // Context
+ ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
+ context.setContextPath("/");
+ rewriteHandler.setHandler(context);
+
+ // Cross-origin resource sharing
+ FilterHolder corsFilterHolder = new FilterHolder(CrossOriginFilter.class);
+ corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, "*");
+ corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_METHODS_PARAM, "GET, POST, DELETE");
+ corsFilterHolder.setInitParameter(CrossOriginFilter.CHAIN_PREFLIGHT_PARAM, "false");
+ context.addFilter(corsFilterHolder, "/*", null);
+
+ // API servlet
+ ServletContainer container = new ServletContainer(this.config);
+ ServletHolder apiServlet = new ServletHolder(container);
+ apiServlet.setInitOrder(1);
+ context.addServlet(apiServlet, "/*");
+
+ // Start server
+ this.server.start();
+ } catch (Exception e) {
+ // Failed to start
+ throw new DataException("Failed to start developer proxy", e);
+ }
+ }
+
+ public void stop() {
+ try {
+ // Stop server
+ this.server.stop();
+ } catch (Exception e) {
+ // Failed to stop
+ }
+
+ this.server = null;
+ instance = null;
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/DomainMapService.java b/src/main/java/org/qortal/api/DomainMapService.java
index ba0fa067..8b791121 100644
--- a/src/main/java/org/qortal/api/DomainMapService.java
+++ b/src/main/java/org/qortal/api/DomainMapService.java
@@ -3,7 +3,6 @@ package org.qortal.api;
import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.rewrite.handler.RewriteHandler;
-import org.eclipse.jetty.rewrite.handler.RewritePatternRule;
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.server.handler.ErrorHandler;
import org.eclipse.jetty.server.handler.InetAccessHandler;
@@ -16,6 +15,7 @@ import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.servlet.ServletContainer;
import org.qortal.api.resource.AnnotationPostProcessor;
import org.qortal.api.resource.ApiDefinition;
+import org.qortal.network.Network;
import org.qortal.settings.Settings;
import javax.net.ssl.KeyManagerFactory;
@@ -38,7 +38,7 @@ public class DomainMapService {
private DomainMapService() {
this.config = new ResourceConfig();
- this.config.packages("org.qortal.api.domainmap.resource");
+ this.config.packages("org.qortal.api.resource", "org.qortal.api.domainmap.resource");
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);
@@ -69,7 +69,7 @@ public class DomainMapService {
throw new RuntimeException("Failed to start SSL API due to broken keystore");
// BouncyCastle-specific SSLContext build
- SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
+ SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE");
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
@@ -99,13 +99,13 @@ public class DomainMapService {
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
new DetectorConnectionFactory(sslConnectionFactory),
httpConnectionFactory);
- portUnifiedConnector.setHost(Settings.getInstance().getBindAddress());
+ portUnifiedConnector.setHost(Network.getInstance().getBindAddress());
portUnifiedConnector.setPort(Settings.getInstance().getDomainMapPort());
this.server.addConnector(portUnifiedConnector);
} else {
// Non-SSL
- InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress());
+ InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress());
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDomainMapPort());
this.server = new Server(endpoint);
}
diff --git a/src/main/java/org/qortal/api/GatewayService.java b/src/main/java/org/qortal/api/GatewayService.java
index 030a0f2f..24a7b7c9 100644
--- a/src/main/java/org/qortal/api/GatewayService.java
+++ b/src/main/java/org/qortal/api/GatewayService.java
@@ -15,6 +15,7 @@ import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.servlet.ServletContainer;
import org.qortal.api.resource.AnnotationPostProcessor;
import org.qortal.api.resource.ApiDefinition;
+import org.qortal.network.Network;
import org.qortal.settings.Settings;
import javax.net.ssl.KeyManagerFactory;
@@ -37,7 +38,7 @@ public class GatewayService {
private GatewayService() {
this.config = new ResourceConfig();
- this.config.packages("org.qortal.api.gateway.resource");
+ this.config.packages("org.qortal.api.resource", "org.qortal.api.gateway.resource");
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);
@@ -68,7 +69,7 @@ public class GatewayService {
throw new RuntimeException("Failed to start SSL API due to broken keystore");
// BouncyCastle-specific SSLContext build
- SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
+ SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE");
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
@@ -98,13 +99,13 @@ public class GatewayService {
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
new DetectorConnectionFactory(sslConnectionFactory),
httpConnectionFactory);
- portUnifiedConnector.setHost(Settings.getInstance().getBindAddress());
+ portUnifiedConnector.setHost(Network.getInstance().getBindAddress());
portUnifiedConnector.setPort(Settings.getInstance().getGatewayPort());
this.server.addConnector(portUnifiedConnector);
} else {
// Non-SSL
- InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress());
+ InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress());
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getGatewayPort());
this.server = new Server(endpoint);
}
diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java
index 026d9210..f1794594 100644
--- a/src/main/java/org/qortal/api/HTMLParser.java
+++ b/src/main/java/org/qortal/api/HTMLParser.java
@@ -5,28 +5,71 @@ import org.apache.logging.log4j.Logger;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.select.Elements;
+import org.qortal.arbitrary.misc.Service;
+
+import java.util.Objects;
public class HTMLParser {
private static final Logger LOGGER = LogManager.getLogger(HTMLParser.class);
- private String linkPrefix;
+ private String qdnBase;
+ private String qdnBaseWithPath;
private byte[] data;
+ private String qdnContext;
+ private String resourceId;
+ private Service service;
+ private String identifier;
+ private String path;
+ private String theme;
+ private boolean usingCustomRouting;
- public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data) {
- String inPathWithoutFilename = inPath.substring(0, inPath.lastIndexOf('/'));
- this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : "";
+ public HTMLParser(String resourceId, String inPath, String prefix, boolean includeResourceIdInPrefix, byte[] data,
+ String qdnContext, Service service, String identifier, String theme, boolean usingCustomRouting) {
+ String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : String.format("/%s",inPath);
+ this.qdnBase = includeResourceIdInPrefix ? String.format("%s/%s", prefix, resourceId) : prefix;
+ this.qdnBaseWithPath = includeResourceIdInPrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : String.format("%s%s", prefix, inPathWithoutFilename);
this.data = data;
+ this.qdnContext = qdnContext;
+ this.resourceId = resourceId;
+ this.service = service;
+ this.identifier = identifier;
+ this.path = inPath;
+ this.theme = theme;
+ this.usingCustomRouting = usingCustomRouting;
}
public void addAdditionalHeaderTags() {
String fileContents = new String(data);
Document document = Jsoup.parse(fileContents);
- String baseUrl = this.linkPrefix + "/";
Elements head = document.getElementsByTag("head");
if (!head.isEmpty()) {
+ // Add q-apps script tag
+ String qAppsScriptElement = String.format("", qdnContext, theme, service, name, identifier, path, qdnBase, qdnBaseWithPath);
+ head.get(0).prepend(qdnContextVar);
+
// Add base href tag
- String baseElement = String.format("", baseUrl);
+ // Exclude the path if this request was routed back to the index automatically
+ String baseHref = this.usingCustomRouting ? this.qdnBase : this.qdnBaseWithPath;
+ String baseElement = String.format("", baseHref);
head.get(0).prepend(baseElement);
// Add meta charset tag
@@ -39,7 +82,7 @@ public class HTMLParser {
}
public static boolean isHtmlFile(String path) {
- if (path.endsWith(".html") || path.endsWith(".htm")) {
+ if (path.endsWith(".html") || path.endsWith(".htm") || path.equals("")) {
return true;
}
return false;
diff --git a/src/main/java/org/qortal/api/Security.java b/src/main/java/org/qortal/api/Security.java
index 4aca2c49..f009d79f 100644
--- a/src/main/java/org/qortal/api/Security.java
+++ b/src/main/java/org/qortal/api/Security.java
@@ -15,7 +15,21 @@ public abstract class Security {
public static final String API_KEY_HEADER = "X-API-KEY";
+ /**
+ * Check API call is allowed, retrieving the API key from the request header or GET/POST parameters where required
+ * @param request
+ */
public static void checkApiCallAllowed(HttpServletRequest request) {
+ checkApiCallAllowed(request, null);
+ }
+
+ /**
+ * Check API call is allowed, retrieving the API key first from the passedApiKey parameter, with a fallback
+ * to the request header or GET/POST parameters when null.
+ * @param request
+ * @param passedApiKey - the API key to test, or null if it should be retrieved from the request headers.
+ */
+ public static void checkApiCallAllowed(HttpServletRequest request, String passedApiKey) {
// We may want to allow automatic authentication for local requests, if enabled in settings
boolean localAuthBypassEnabled = Settings.getInstance().isLocalAuthBypassEnabled();
if (localAuthBypassEnabled) {
@@ -38,7 +52,10 @@ public abstract class Security {
}
// We require an API key to be passed
- String passedApiKey = request.getHeader(API_KEY_HEADER);
+ if (passedApiKey == null) {
+ // API call not passed as a parameter, so try the header
+ passedApiKey = request.getHeader(API_KEY_HEADER);
+ }
if (passedApiKey == null) {
// Try query string - this is needed to avoid a CORS preflight. See: https://stackoverflow.com/a/43881141
passedApiKey = request.getParameter("apiKey");
@@ -56,7 +73,7 @@ public abstract class Security {
public static void disallowLoopbackRequests(HttpServletRequest request) {
try {
InetAddress remoteAddr = InetAddress.getByName(request.getRemoteAddr());
- if (remoteAddr.isLoopbackAddress()) {
+ if (remoteAddr.isLoopbackAddress() && !Settings.getInstance().isGatewayLoopbackEnabled()) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Local requests not allowed");
}
} catch (UnknownHostException e) {
@@ -84,9 +101,9 @@ public abstract class Security {
}
}
- public static void requirePriorAuthorizationOrApiKey(HttpServletRequest request, String resourceId, Service service, String identifier) {
+ public static void requirePriorAuthorizationOrApiKey(HttpServletRequest request, String resourceId, Service service, String identifier, String apiKey) {
try {
- Security.checkApiCallAllowed(request);
+ Security.checkApiCallAllowed(request, apiKey);
} catch (ApiException e) {
// API call wasn't allowed, but maybe it was pre-authorized
diff --git a/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java b/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java
index cc21587d..019fb753 100644
--- a/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java
+++ b/src/main/java/org/qortal/api/domainmap/resource/DomainMapResource.java
@@ -42,16 +42,16 @@ public class DomainMapResource {
// Build synchronously, so that we don't need to make the summary API endpoints available over
// the domain map server. This means that there will be no loading screen, but this is potentially
// preferred in this situation anyway (e.g. to avoid confusing search engine robots).
- return this.get(domainMap.get(request.getServerName()), ResourceIdType.NAME, Service.WEBSITE, inPath, null, "", false, false);
+ return this.get(domainMap.get(request.getServerName()), ResourceIdType.NAME, Service.WEBSITE, null, inPath, null, "", false, false);
}
return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found");
}
- private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath,
- String secret58, String prefix, boolean usePrefix, boolean async) {
+ private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
+ String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async) {
- ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath,
- secret58, prefix, usePrefix, async, request, response, context);
+ ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath,
+ secret58, prefix, includeResourceIdInPrefix, async, "domainMap", request, response, context);
return renderer.render();
}
diff --git a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java
index a73de1fb..5d056f30 100644
--- a/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java
+++ b/src/main/java/org/qortal/api/gateway/resource/GatewayResource.java
@@ -2,6 +2,7 @@ package org.qortal.api.gateway.resource;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
+import org.apache.commons.lang3.StringUtils;
import org.qortal.api.Security;
import org.qortal.arbitrary.ArbitraryDataFile;
import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType;
@@ -16,6 +17,10 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.List;
@Path("/")
@@ -76,50 +81,83 @@ public class GatewayResource {
@GET
- @Path("{name}/{path:.*}")
+ @Path("{path:.*}")
@SecurityRequirement(name = "apiKey")
- public HttpServletResponse getPathByName(@PathParam("name") String name,
- @PathParam("path") String inPath) {
+ public HttpServletResponse getPath(@PathParam("path") String inPath) {
// Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data
Security.disallowLoopbackRequests(request);
- return this.get(name, ResourceIdType.NAME, Service.WEBSITE, inPath, null, "", true, true);
- }
-
- @GET
- @Path("{name}")
- @SecurityRequirement(name = "apiKey")
- public HttpServletResponse getIndexByName(@PathParam("name") String name) {
- // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data
- Security.disallowLoopbackRequests(request);
- return this.get(name, ResourceIdType.NAME, Service.WEBSITE, "/", null, "", true, true);
- }
-
-
- // Optional /site alternative for backwards support
-
- @GET
- @Path("/site/{name}/{path:.*}")
- public HttpServletResponse getSitePathByName(@PathParam("name") String name,
- @PathParam("path") String inPath) {
- // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data
- Security.disallowLoopbackRequests(request);
- return this.get(name, ResourceIdType.NAME, Service.WEBSITE, inPath, null, "/site", true, true);
- }
-
- @GET
- @Path("/site/{name}")
- public HttpServletResponse getSiteIndexByName(@PathParam("name") String name) {
- // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data
- Security.disallowLoopbackRequests(request);
- return this.get(name, ResourceIdType.NAME, Service.WEBSITE, "/", null, "/site", true, true);
+ return this.parsePath(inPath, "gateway", null, true, true);
}
- private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath,
- String secret58, String prefix, boolean usePrefix, boolean async) {
+ private HttpServletResponse parsePath(String inPath, String qdnContext, String secret58, boolean includeResourceIdInPrefix, boolean async) {
- ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath,
- secret58, prefix, usePrefix, async, request, response, context);
+ if (inPath == null || inPath.equals("")) {
+ // Assume not a real file
+ return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found");
+ }
+
+ // Default service is WEBSITE
+ Service service = Service.WEBSITE;
+ String name = null;
+ String identifier = null;
+ String outPath = "";
+ List prefixParts = new ArrayList<>();
+
+ if (!inPath.contains("/")) {
+ // Assume entire inPath is a registered name
+ name = inPath;
+ }
+ else {
+ // Parse the path to determine what we need to load
+ List parts = new LinkedList<>(Arrays.asList(inPath.split("/")));
+
+ // Check if the first element is a service
+ try {
+ Service parsedService = Service.valueOf(parts.get(0).toUpperCase());
+ if (parsedService != null) {
+ // First element matches a service, so we can assume it is one
+ service = parsedService;
+ parts.remove(0);
+ prefixParts.add(service.name());
+ }
+ } catch (IllegalArgumentException e) {
+ // Not a service
+ }
+
+ if (parts.isEmpty()) {
+ // We need more than just a service
+ return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found");
+ }
+
+ // Service is removed, so assume first element is now a registered name
+ name = parts.get(0);
+ parts.remove(0);
+
+ if (!parts.isEmpty()) {
+ // Name is removed, so check if the first element is now an identifier
+ ArbitraryResourceStatus status = this.getStatus(service, name, parts.get(0), false);
+ if (status.getTotalChunkCount() > 0) {
+ // Matched service, name and identifier combination - so assume this is an identifier and can be removed
+ identifier = parts.get(0);
+ parts.remove(0);
+ prefixParts.add(identifier);
+ }
+ }
+
+ if (!parts.isEmpty()) {
+ // outPath can be built by combining any remaining parts
+ outPath = String.join("/", parts);
+ }
+ }
+
+ String prefix = StringUtils.join(prefixParts, "/");
+ if (prefix != null && prefix.length() > 0) {
+ prefix = "/" + prefix;
+ }
+
+ ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(name, ResourceIdType.NAME, service, identifier, outPath,
+ secret58, prefix, includeResourceIdInPrefix, async, qdnContext, request, response, context);
return renderer.render();
}
diff --git a/src/main/java/org/qortal/api/model/AccountPenaltyStats.java b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java
new file mode 100644
index 00000000..aafe25fc
--- /dev/null
+++ b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java
@@ -0,0 +1,56 @@
+package org.qortal.api.model;
+
+import org.qortal.block.SelfSponsorshipAlgoV1Block;
+import org.qortal.data.account.AccountData;
+import org.qortal.data.naming.NameData;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import java.util.ArrayList;
+import java.util.List;
+
+@XmlAccessorType(XmlAccessType.FIELD)
+public class AccountPenaltyStats {
+
+ public Integer totalPenalties;
+ public Integer maxPenalty;
+ public Integer minPenalty;
+ public String penaltyHash;
+
+ protected AccountPenaltyStats() {
+ }
+
+ public AccountPenaltyStats(Integer totalPenalties, Integer maxPenalty, Integer minPenalty, String penaltyHash) {
+ this.totalPenalties = totalPenalties;
+ this.maxPenalty = maxPenalty;
+ this.minPenalty = minPenalty;
+ this.penaltyHash = penaltyHash;
+ }
+
+ public static AccountPenaltyStats fromAccounts(List accounts) {
+ int totalPenalties = 0;
+ Integer maxPenalty = null;
+ Integer minPenalty = null;
+
+ List addresses = new ArrayList<>();
+ for (AccountData accountData : accounts) {
+ int penalty = accountData.getBlocksMintedPenalty();
+ addresses.add(accountData.getAddress());
+ totalPenalties++;
+
+ // Penalties are expressed as a negative number, so the min and the max are reversed here
+ if (maxPenalty == null || penalty < maxPenalty) maxPenalty = penalty;
+ if (minPenalty == null || penalty > minPenalty) minPenalty = penalty;
+ }
+
+ String penaltyHash = SelfSponsorshipAlgoV1Block.getHash(addresses);
+ return new AccountPenaltyStats(totalPenalties, maxPenalty, minPenalty, penaltyHash);
+ }
+
+
+ @Override
+ public String toString() {
+ return String.format("totalPenalties: %d, maxPenalty: %d, minPenalty: %d, penaltyHash: %s", totalPenalties, maxPenalty, minPenalty, penaltyHash == null ? "null" : penaltyHash);
+ }
+}
diff --git a/src/main/java/org/qortal/api/model/AtCreationRequest.java b/src/main/java/org/qortal/api/model/AtCreationRequest.java
new file mode 100644
index 00000000..14ccdaa2
--- /dev/null
+++ b/src/main/java/org/qortal/api/model/AtCreationRequest.java
@@ -0,0 +1,102 @@
+package org.qortal.api.model;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlTransient;
+
+import org.bouncycastle.util.encoders.Base64;
+import org.bouncycastle.util.encoders.DecoderException;
+
+@XmlAccessorType(XmlAccessType.FIELD)
+public class AtCreationRequest {
+
+ @Schema(description = "CIYAM AT version", example = "2")
+ private short ciyamAtVersion;
+
+ @Schema(description = "base64-encoded code bytes", example = "")
+ private String codeBytesBase64;
+
+ @Schema(description = "base64-encoded data bytes", example = "")
+ private String dataBytesBase64;
+
+ private short numCallStackPages;
+ private short numUserStackPages;
+ private long minActivationAmount;
+
+ // Default constructor for JSON deserialization
+ public AtCreationRequest() {}
+
+ // Getters and setters
+ public short getCiyamAtVersion() {
+ return ciyamAtVersion;
+ }
+
+ public void setCiyamAtVersion(short ciyamAtVersion) {
+ this.ciyamAtVersion = ciyamAtVersion;
+ }
+
+
+ public String getCodeBytesBase64() {
+ return this.codeBytesBase64;
+ }
+
+ @XmlTransient
+ @Schema(hidden = true)
+ public byte[] getCodeBytes() {
+ if (this.codeBytesBase64 != null) {
+ try {
+ return Base64.decode(this.codeBytesBase64);
+ }
+ catch (DecoderException e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+
+ public String getDataBytesBase64() {
+ return this.dataBytesBase64;
+ }
+
+ @XmlTransient
+ @Schema(hidden = true)
+ public byte[] getDataBytes() {
+ if (this.dataBytesBase64 != null) {
+ try {
+ return Base64.decode(this.dataBytesBase64);
+ }
+ catch (DecoderException e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+
+ public short getNumCallStackPages() {
+ return numCallStackPages;
+ }
+
+ public void setNumCallStackPages(short numCallStackPages) {
+ this.numCallStackPages = numCallStackPages;
+ }
+
+ public short getNumUserStackPages() {
+ return numUserStackPages;
+ }
+
+ public void setNumUserStackPages(short numUserStackPages) {
+ this.numUserStackPages = numUserStackPages;
+ }
+
+ public long getMinActivationAmount() {
+ return minActivationAmount;
+ }
+
+ public void setMinActivationAmount(long minActivationAmount) {
+ this.minActivationAmount = minActivationAmount;
+ }
+}
diff --git a/src/main/java/org/qortal/api/model/ConnectedPeer.java b/src/main/java/org/qortal/api/model/ConnectedPeer.java
index 3d383321..c4198654 100644
--- a/src/main/java/org/qortal/api/model/ConnectedPeer.java
+++ b/src/main/java/org/qortal/api/model/ConnectedPeer.java
@@ -1,6 +1,7 @@
package org.qortal.api.model;
import io.swagger.v3.oas.annotations.media.Schema;
+import org.qortal.controller.Controller;
import org.qortal.data.block.BlockSummaryData;
import org.qortal.data.network.PeerData;
import org.qortal.network.Handshake;
@@ -36,6 +37,7 @@ public class ConnectedPeer {
public Long lastBlockTimestamp;
public UUID connectionId;
public String age;
+ public Boolean isTooDivergent;
protected ConnectedPeer() {
}
@@ -69,6 +71,11 @@ public class ConnectedPeer {
this.lastBlockSignature = peerChainTipData.getSignature();
this.lastBlockTimestamp = peerChainTipData.getTimestamp();
}
+
+ // Only include isTooDivergent decision if we've had the opportunity to request block summaries this peer
+ if (peer.getLastTooDivergentTime() != null) {
+ this.isTooDivergent = Controller.wasRecentlyTooDivergent.test(peer);
+ }
}
}
diff --git a/src/main/java/org/qortal/api/model/FileProperties.java b/src/main/java/org/qortal/api/model/FileProperties.java
new file mode 100644
index 00000000..c63506dd
--- /dev/null
+++ b/src/main/java/org/qortal/api/model/FileProperties.java
@@ -0,0 +1,16 @@
+package org.qortal.api.model;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+
+@XmlAccessorType(XmlAccessType.FIELD)
+public class FileProperties {
+
+ public String filename;
+ public String mimeType;
+ public Long size;
+
+ public FileProperties() {
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/model/PollVotes.java b/src/main/java/org/qortal/api/model/PollVotes.java
new file mode 100644
index 00000000..c57ebc37
--- /dev/null
+++ b/src/main/java/org/qortal/api/model/PollVotes.java
@@ -0,0 +1,56 @@
+package org.qortal.api.model;
+
+import java.util.List;
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import org.qortal.data.voting.VoteOnPollData;
+
+@Schema(description = "Poll vote info, including voters")
+// All properties to be converted to JSON via JAX-RS
+@XmlAccessorType(XmlAccessType.FIELD)
+public class PollVotes {
+
+ @Schema(description = "List of individual votes")
+ @XmlElement(name = "votes")
+ public List votes;
+
+ @Schema(description = "Total number of votes")
+ public Integer totalVotes;
+
+ @Schema(description = "List of vote counts for each option")
+ public List voteCounts;
+
+ // For JAX-RS
+ protected PollVotes() {
+ }
+
+ public PollVotes(List votes, Integer totalVotes, List voteCounts) {
+ this.votes = votes;
+ this.totalVotes = totalVotes;
+ this.voteCounts = voteCounts;
+ }
+
+ @Schema(description = "Vote info")
+ // All properties to be converted to JSON via JAX-RS
+ @XmlAccessorType(XmlAccessType.FIELD)
+ public static class OptionCount {
+ @Schema(description = "Option name")
+ public String optionName;
+
+ @Schema(description = "Vote count")
+ public Integer voteCount;
+
+ // For JAX-RS
+ protected OptionCount() {
+ }
+
+ public OptionCount(String optionName, Integer voteCount) {
+ this.optionName = optionName;
+ this.voteCount = voteCount;
+ }
+ }
+}
diff --git a/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java
new file mode 100644
index 00000000..7972c551
--- /dev/null
+++ b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java
@@ -0,0 +1,164 @@
+package org.qortal.api.proxy.resource;
+
+import org.qortal.api.ApiError;
+import org.qortal.api.ApiExceptionFactory;
+import org.qortal.api.HTMLParser;
+import org.qortal.arbitrary.misc.Service;
+import org.qortal.controller.DevProxyManager;
+
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.core.Context;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.ConnectException;
+import java.net.HttpURLConnection;
+import java.net.ProtocolException;
+import java.net.URL;
+import java.util.Enumeration;
+
+
+@Path("/")
+public class DevProxyServerResource {
+
+ @Context HttpServletRequest request;
+ @Context HttpServletResponse response;
+ @Context ServletContext context;
+
+
+ @GET
+ public HttpServletResponse getProxyIndex() {
+ return this.proxy("/");
+ }
+
+ @GET
+ @Path("{path:.*}")
+ public HttpServletResponse getProxyPath(@PathParam("path") String inPath) {
+ return this.proxy(inPath);
+ }
+
+ private HttpServletResponse proxy(String inPath) {
+ try {
+ String source = DevProxyManager.getInstance().getSourceHostAndPort();
+
+ if (!inPath.startsWith("/")) {
+ inPath = "/" + inPath;
+ }
+
+ String queryString = request.getQueryString() != null ? "?" + request.getQueryString() : "";
+
+ // Open URL
+ URL url = new URL(String.format("http://%s%s%s", source, inPath, queryString));
+ HttpURLConnection con = (HttpURLConnection) url.openConnection();
+
+ // Proxy the request data
+ this.proxyRequestToConnection(request, con);
+
+ try {
+ // Make the request and proxy the response code
+ response.setStatus(con.getResponseCode());
+ }
+ catch (ConnectException e) {
+
+ // Tey converting localhost / 127.0.0.1 to IPv6 [::1]
+ if (source.startsWith("localhost") || source.startsWith("127.0.0.1")) {
+ int port = 80;
+ String[] parts = source.split(":");
+ if (parts.length > 1) {
+ port = Integer.parseInt(parts[1]);
+ }
+ source = String.format("[::1]:%d", port);
+ }
+
+ // Retry connection
+ url = new URL(String.format("http://%s%s%s", source, inPath, queryString));
+ con = (HttpURLConnection) url.openConnection();
+ this.proxyRequestToConnection(request, con);
+ response.setStatus(con.getResponseCode());
+ }
+
+ // Proxy the response data back to the caller
+ this.proxyConnectionToResponse(con, response, inPath);
+
+ } catch (IOException e) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage());
+ }
+
+ return response;
+ }
+
+ private void proxyRequestToConnection(HttpServletRequest request, HttpURLConnection con) throws ProtocolException {
+ // Proxy the request method
+ con.setRequestMethod(request.getMethod());
+
+ // Proxy the request headers
+ Enumeration headerNames = request.getHeaderNames();
+ while (headerNames.hasMoreElements()) {
+ String headerName = headerNames.nextElement();
+ String headerValue = request.getHeader(headerName);
+ con.setRequestProperty(headerName, headerValue);
+ }
+
+ // TODO: proxy any POST parameters from "request" to "con"
+ }
+
+ private void proxyConnectionToResponse(HttpURLConnection con, HttpServletResponse response, String inPath) throws IOException {
+ // Proxy the response headers
+ for (int i = 0; ; i++) {
+ String headerKey = con.getHeaderFieldKey(i);
+ String headerValue = con.getHeaderField(i);
+ if (headerKey != null && headerValue != null) {
+ response.addHeader(headerKey, headerValue);
+ continue;
+ }
+ break;
+ }
+
+ // Read the response body
+ InputStream inputStream = con.getInputStream();
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int bytesRead;
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, bytesRead);
+ }
+ byte[] data = outputStream.toByteArray(); // TODO: limit file size that can be read into memory
+
+ // Close the streams
+ outputStream.close();
+ inputStream.close();
+
+ // Extract filename
+ String filename = "";
+ if (inPath.contains("/")) {
+ String[] parts = inPath.split("/");
+ if (parts.length > 0) {
+ filename = parts[parts.length - 1];
+ }
+ }
+
+ // Parse and modify output if needed
+ if (HTMLParser.isHtmlFile(filename)) {
+ // HTML file - needs to be parsed
+ HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, "light", true);
+ htmlParser.addAdditionalHeaderTags();
+ response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' ws:; font-src 'self' data:;");
+ response.setContentType(con.getContentType());
+ response.setContentLength(htmlParser.getData().length);
+ response.getOutputStream().write(htmlParser.getData());
+ }
+ else {
+ // Regular file - can be streamed directly
+ response.addHeader("Content-Security-Policy", "default-src 'self'");
+ response.setContentType(con.getContentType());
+ response.setContentLength(data.length);
+ response.getOutputStream().write(data);
+ }
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/resource/AddressesResource.java b/src/main/java/org/qortal/api/resource/AddressesResource.java
index 468b90a8..79cb6e05 100644
--- a/src/main/java/org/qortal/api/resource/AddressesResource.java
+++ b/src/main/java/org/qortal/api/resource/AddressesResource.java
@@ -14,6 +14,7 @@ import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
+import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.*;
@@ -27,6 +28,7 @@ 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.AccountPenaltyStats;
import org.qortal.api.model.ApiOnlineAccount;
import org.qortal.api.model.RewardShareKeyRequest;
import org.qortal.asset.Asset;
@@ -34,6 +36,7 @@ import org.qortal.controller.LiteNode;
import org.qortal.controller.OnlineAccountsManager;
import org.qortal.crypto.Crypto;
import org.qortal.data.account.AccountData;
+import org.qortal.data.account.AccountPenaltyData;
import org.qortal.data.account.RewardShareData;
import org.qortal.data.network.OnlineAccountData;
import org.qortal.data.network.OnlineAccountLevel;
@@ -471,6 +474,54 @@ public class AddressesResource {
}
}
+ @GET
+ @Path("/penalties")
+ @Operation(
+ summary = "Get addresses with penalties",
+ description = "Returns a list of accounts with a blocksMintedPenalty",
+ responses = {
+ @ApiResponse(
+ description = "accounts with penalties",
+ content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = AccountPenaltyData.class)))
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
+ public List getAccountsWithPenalties() {
+ try (final Repository repository = RepositoryManager.getRepository()) {
+
+ List accounts = repository.getAccountRepository().getPenaltyAccounts();
+ List penalties = accounts.stream().map(a -> new AccountPenaltyData(a.getAddress(), a.getBlocksMintedPenalty())).collect(Collectors.toList());
+
+ return penalties;
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+ @GET
+ @Path("/penalties/stats")
+ @Operation(
+ summary = "Get stats about current penalties",
+ responses = {
+ @ApiResponse(
+ description = "aggregated stats about accounts with penalties",
+ content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = AccountPenaltyStats.class)))
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
+ public AccountPenaltyStats getPenaltyStats() {
+ try (final Repository repository = RepositoryManager.getRepository()) {
+
+ List accounts = repository.getAccountRepository().getPenaltyAccounts();
+ return AccountPenaltyStats.fromAccounts(accounts);
+
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
@POST
@Path("/publicize")
@Operation(
diff --git a/src/main/java/org/qortal/api/resource/AppsResource.java b/src/main/java/org/qortal/api/resource/AppsResource.java
new file mode 100644
index 00000000..19a4a184
--- /dev/null
+++ b/src/main/java/org/qortal/api/resource/AppsResource.java
@@ -0,0 +1,83 @@
+package org.qortal.api.resource;
+
+import com.google.common.io.Resources;
+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.Hidden;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.qortal.api.*;
+
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.*;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+
+@Path("/apps")
+@Tag(name = "Apps")
+public class AppsResource {
+
+ @Context HttpServletRequest request;
+ @Context HttpServletResponse response;
+ @Context ServletContext context;
+
+ @GET
+ @Path("/q-apps.js")
+ @Hidden // For internal Q-App API use only
+ @Operation(
+ summary = "Javascript interface for Q-Apps",
+ responses = {
+ @ApiResponse(
+ description = "javascript",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string"
+ )
+ )
+ )
+ }
+ )
+ public String getQAppsJs() {
+ URL url = Resources.getResource("q-apps/q-apps.js");
+ try {
+ return Resources.toString(url, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND);
+ }
+ }
+
+ @GET
+ @Path("/q-apps-gateway.js")
+ @Hidden // For internal Q-App API use only
+ @Operation(
+ summary = "Gateway-specific interface for Q-Apps",
+ responses = {
+ @ApiResponse(
+ description = "javascript",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string"
+ )
+ )
+ )
+ }
+ )
+ public String getQAppsGatewayJs() {
+ URL url = Resources.getResource("q-apps/q-apps-gateway.js");
+ try {
+ return Resources.toString(url, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND);
+ }
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java
index 978183c0..e7a20d0e 100644
--- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java
+++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java
@@ -1,6 +1,8 @@
package org.qortal.api.resource;
import com.google.common.primitives.Bytes;
+import com.j256.simplemagic.ContentInfo;
+import com.j256.simplemagic.ContentInfoUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
@@ -12,11 +14,14 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.io.*;
+import java.net.FileNameMap;
+import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Objects;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
@@ -25,11 +30,13 @@ import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
+import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.bouncycastle.util.encoders.Base64;
import org.qortal.api.*;
+import org.qortal.api.model.FileProperties;
import org.qortal.api.resource.TransactionsResource.ConfirmationStatus;
import org.qortal.arbitrary.*;
import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType;
@@ -38,6 +45,7 @@ import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
import org.qortal.arbitrary.misc.Category;
import org.qortal.arbitrary.misc.Service;
import org.qortal.controller.Controller;
+import org.qortal.controller.arbitrary.ArbitraryDataRenderManager;
import org.qortal.controller.arbitrary.ArbitraryDataStorageManager;
import org.qortal.controller.arbitrary.ArbitraryMetadataManager;
import org.qortal.data.account.AccountData;
@@ -57,10 +65,7 @@ import org.qortal.transaction.Transaction.ValidationResult;
import org.qortal.transform.TransformationException;
import org.qortal.transform.transaction.ArbitraryTransactionTransformer;
import org.qortal.transform.transaction.TransactionTransformer;
-import org.qortal.utils.ArbitraryTransactionUtils;
-import org.qortal.utils.Base58;
-import org.qortal.utils.NTP;
-import org.qortal.utils.ZipUtils;
+import org.qortal.utils.*;
@Path("/arbitrary")
@Tag(name = "Arbitrary")
@@ -88,12 +93,15 @@ public class ArbitraryResource {
@ApiErrors({ApiError.REPOSITORY_ISSUE})
public List getResources(
@QueryParam("service") Service service,
+ @QueryParam("name") String name,
@QueryParam("identifier") String identifier,
@Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource,
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse,
- @Parameter(description = "Filter names by list") @QueryParam("namefilter") String nameFilter,
+ @Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly,
+ @Parameter(description = "Exclude blocked content") @QueryParam("excludeblocked") Boolean excludeBlocked,
+ @Parameter(description = "Filter names by list") @QueryParam("namefilter") String nameListFilter,
@Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus,
@Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) {
@@ -110,28 +118,33 @@ public class ArbitraryResource {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "identifier cannot be specified when requesting a default resource");
}
- // Load filter from list if needed
+ // Set up name filters if supplied
List names = null;
- if (nameFilter != null) {
- names = ResourceListManager.getInstance().getStringsInList(nameFilter);
+ if (name != null) {
+ // Filter using single name
+ names = Arrays.asList(name);
+ }
+ else if (nameListFilter != null) {
+ // Filter using supplied list of names
+ names = ResourceListManager.getInstance().getStringsInList(nameListFilter);
if (names.isEmpty()) {
- // List doesn't exist or is empty - so there will be no matches
+ // If list is empty (or doesn't exist) we can shortcut with empty response
return new ArrayList<>();
}
}
List resources = repository.getArbitraryRepository()
- .getArbitraryResources(service, identifier, names, defaultRes, limit, offset, reverse);
+ .getArbitraryResources(service, identifier, names, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse);
if (resources == null) {
return new ArrayList<>();
}
if (includeStatus != null && includeStatus) {
- resources = this.addStatusToResources(resources);
+ resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
- resources = this.addMetadataToResources(resources);
+ resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
@@ -155,30 +168,56 @@ public class ArbitraryResource {
@ApiErrors({ApiError.REPOSITORY_ISSUE})
public List searchResources(
@QueryParam("service") Service service,
- @QueryParam("query") String query,
+ @Parameter(description = "Query (searches both name and identifier fields)") @QueryParam("query") String query,
+ @Parameter(description = "Identifier (searches identifier field only)") @QueryParam("identifier") String identifier,
+ @Parameter(description = "Name (searches name field only)") @QueryParam("name") List names,
+ @Parameter(description = "Prefix only (if true, only the beginning of fields are matched)") @QueryParam("prefix") Boolean prefixOnly,
+ @Parameter(description = "Exact match names only (if true, partial name matches are excluded)") @QueryParam("exactmatchnames") Boolean exactMatchNamesOnly,
@Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource,
+ @Parameter(description = "Filter names by list (exact matches only)") @QueryParam("namefilter") String nameListFilter,
+ @Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly,
+ @Parameter(description = "Exclude blocked content") @QueryParam("excludeblocked") Boolean excludeBlocked,
+ @Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus,
+ @Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata,
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
- @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse,
- @Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus,
- @Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) {
+ @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
try (final Repository repository = RepositoryManager.getRepository()) {
boolean defaultRes = Boolean.TRUE.equals(defaultResource);
+ boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly);
+
+ List exactMatchNames = new ArrayList<>();
+
+ if (nameListFilter != null) {
+ // Load names from supplied list of names
+ exactMatchNames.addAll(ResourceListManager.getInstance().getStringsInList(nameListFilter));
+
+ // If list is empty (or doesn't exist) we can shortcut with empty response
+ if (exactMatchNames.isEmpty()) {
+ return new ArrayList<>();
+ }
+ }
+
+ // Move names to exact match list, if requested
+ if (exactMatchNamesOnly != null && exactMatchNamesOnly && names != null) {
+ exactMatchNames.addAll(names);
+ names = null;
+ }
List resources = repository.getArbitraryRepository()
- .searchArbitraryResources(service, query, defaultRes, limit, offset, reverse);
+ .searchArbitraryResources(service, query, identifier, names, usePrefixOnly, exactMatchNames, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse);
if (resources == null) {
return new ArrayList<>();
}
if (includeStatus != null && includeStatus) {
- resources = this.addStatusToResources(resources);
+ resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
- resources = this.addMetadataToResources(resources);
+ resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
@@ -188,67 +227,6 @@ public class ArbitraryResource {
}
}
- @GET
- @Path("/resources/names")
- @Operation(
- summary = "List arbitrary resources available on chain, grouped by creator's name",
- responses = {
- @ApiResponse(
- content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceInfo.class))
- )
- }
- )
- @ApiErrors({ApiError.REPOSITORY_ISSUE})
- public List getResourcesGroupedByName(
- @QueryParam("service") Service service,
- @QueryParam("identifier") String identifier,
- @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource,
- @Parameter(ref = "limit") @QueryParam("limit") Integer limit,
- @Parameter(ref = "offset") @QueryParam("offset") Integer offset,
- @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse,
- @Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus,
- @Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) {
-
- try (final Repository repository = RepositoryManager.getRepository()) {
-
- // Treat empty identifier as null
- if (identifier != null && identifier.isEmpty()) {
- identifier = null;
- }
-
- // Ensure that "default" and "identifier" parameters cannot coexist
- boolean defaultRes = Boolean.TRUE.equals(defaultResource);
- if (defaultRes == true && identifier != null) {
- throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "identifier cannot be specified when requesting a default resource");
- }
-
- List creatorNames = repository.getArbitraryRepository()
- .getArbitraryResourceCreatorNames(service, identifier, defaultRes, limit, offset, reverse);
-
- for (ArbitraryResourceNameInfo creatorName : creatorNames) {
- String name = creatorName.name;
- if (name != null) {
- List resources = repository.getArbitraryRepository()
- .getArbitraryResources(service, identifier, Arrays.asList(name), defaultRes, null, null, reverse);
-
- if (includeStatus != null && includeStatus) {
- resources = this.addStatusToResources(resources);
- }
- if (includeMetadata != null && includeMetadata) {
- resources = this.addMetadataToResources(resources);
- }
-
- creatorName.resources = resources;
- }
- }
-
- return creatorNames;
-
- } catch (DataException e) {
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
- }
- }
-
@GET
@Path("/resource/status/{service}/{name}")
@Operation(
@@ -266,10 +244,35 @@ public class ArbitraryResource {
@PathParam("name") String name,
@QueryParam("build") Boolean build) {
- Security.requirePriorAuthorizationOrApiKey(request, name, service, null);
+ if (!Settings.getInstance().isQDNAuthBypassEnabled())
+ Security.requirePriorAuthorizationOrApiKey(request, name, service, null, apiKey);
+
return ArbitraryTransactionUtils.getStatus(service, name, null, build);
}
+ @GET
+ @Path("/resource/properties/{service}/{name}/{identifier}")
+ @Operation(
+ summary = "Get properties of a QDN resource",
+ description = "This attempts a download of the data if it's not available locally. A filename will only be returned for single file resources. mimeType is only returned when it can be determined.",
+ responses = {
+ @ApiResponse(
+ content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = FileProperties.class))
+ )
+ }
+ )
+ @SecurityRequirement(name = "apiKey")
+ public FileProperties getResourceProperties(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
+ @PathParam("service") Service service,
+ @PathParam("name") String name,
+ @PathParam("identifier") String identifier) {
+
+ if (!Settings.getInstance().isQDNAuthBypassEnabled())
+ Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey);
+
+ return this.getFileProperties(service, name, identifier);
+ }
+
@GET
@Path("/resource/status/{service}/{name}/{identifier}")
@Operation(
@@ -288,7 +291,9 @@ public class ArbitraryResource {
@PathParam("identifier") String identifier,
@QueryParam("build") Boolean build) {
- Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier);
+ if (!Settings.getInstance().isQDNAuthBypassEnabled())
+ Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey);
+
return ArbitraryTransactionUtils.getStatus(service, name, identifier, build);
}
@@ -501,6 +506,9 @@ public class ArbitraryResource {
}
for (ArbitraryTransactionData transactionData : transactionDataList) {
+ if (transactionData.getService() == null) {
+ continue;
+ }
ArbitraryResourceInfo arbitraryResourceInfo = new ArbitraryResourceInfo();
arbitraryResourceInfo.name = transactionData.getName();
arbitraryResourceInfo.service = transactionData.getService();
@@ -511,10 +519,10 @@ public class ArbitraryResource {
}
if (includeStatus != null && includeStatus) {
- resources = this.addStatusToResources(resources);
+ resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
- resources = this.addMetadataToResources(resources);
+ resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
@@ -544,7 +552,7 @@ public class ArbitraryResource {
Security.checkApiCallAllowed(request);
ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier);
- return resource.delete();
+ return resource.delete(false);
}
@POST
@@ -641,6 +649,7 @@ public class ArbitraryResource {
@PathParam("service") Service service,
@PathParam("name") String name,
@QueryParam("filepath") String filepath,
+ @QueryParam("encoding") String encoding,
@QueryParam("rebuild") boolean rebuild,
@QueryParam("async") boolean async,
@QueryParam("attempts") Integer attempts) {
@@ -650,7 +659,7 @@ public class ArbitraryResource {
Security.checkApiCallAllowed(request);
}
- return this.download(service, name, null, filepath, rebuild, async, attempts);
+ return this.download(service, name, null, filepath, encoding, rebuild, async, attempts);
}
@GET
@@ -676,16 +685,17 @@ public class ArbitraryResource {
@PathParam("name") String name,
@PathParam("identifier") String identifier,
@QueryParam("filepath") String filepath,
+ @QueryParam("encoding") String encoding,
@QueryParam("rebuild") boolean rebuild,
@QueryParam("async") boolean async,
@QueryParam("attempts") Integer attempts) {
// Authentication can be bypassed in the settings, for those running public QDN nodes
if (!Settings.getInstance().isQDNAuthBypassEnabled()) {
- Security.checkApiCallAllowed(request);
+ Security.checkApiCallAllowed(request, apiKey);
}
- return this.download(service, name, identifier, filepath, rebuild, async, attempts);
+ return this.download(service, name, identifier, filepath, encoding, rebuild, async, attempts);
}
@@ -708,18 +718,15 @@ public class ArbitraryResource {
}
)
@SecurityRequirement(name = "apiKey")
- public ArbitraryResourceMetadata getMetadata(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
- @PathParam("service") Service service,
- @PathParam("name") String name,
- @PathParam("identifier") String identifier) {
- Security.checkApiCallAllowed(request);
-
+ public ArbitraryResourceMetadata getMetadata(@PathParam("service") Service service,
+ @PathParam("name") String name,
+ @PathParam("identifier") String identifier) {
ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier);
try {
- ArbitraryDataTransactionMetadata transactionMetadata = ArbitraryMetadataManager.getInstance().fetchMetadata(resource, false);
+ ArbitraryDataTransactionMetadata transactionMetadata = ArbitraryMetadataManager.getInstance().fetchMetadata(resource, true);
if (transactionMetadata != null) {
- ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata);
+ ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata, true);
if (resourceMetadata != null) {
return resourceMetadata;
}
@@ -733,7 +740,7 @@ public class ArbitraryResource {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage());
}
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND);
}
@@ -773,6 +780,8 @@ public class ArbitraryResource {
@QueryParam("description") String description,
@QueryParam("tags") List tags,
@QueryParam("category") Category category,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
String path) {
Security.checkApiCallAllowed(request);
@@ -781,7 +790,7 @@ public class ArbitraryResource {
}
return this.upload(Service.valueOf(serviceString), name, null, path, null, null, false,
- title, description, tags, category);
+ fee, null, title, description, tags, category, preview);
}
@POST
@@ -818,6 +827,8 @@ public class ArbitraryResource {
@QueryParam("description") String description,
@QueryParam("tags") List tags,
@QueryParam("category") Category category,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
String path) {
Security.checkApiCallAllowed(request);
@@ -826,7 +837,7 @@ public class ArbitraryResource {
}
return this.upload(Service.valueOf(serviceString), name, identifier, path, null, null, false,
- title, description, tags, category);
+ fee, null, title, description, tags, category, preview);
}
@@ -864,6 +875,9 @@ public class ArbitraryResource {
@QueryParam("description") String description,
@QueryParam("tags") List tags,
@QueryParam("category") Category category,
+ @QueryParam("filename") String filename,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
String base64) {
Security.checkApiCallAllowed(request);
@@ -872,7 +886,7 @@ public class ArbitraryResource {
}
return this.upload(Service.valueOf(serviceString), name, null, null, null, base64, false,
- title, description, tags, category);
+ fee, filename, title, description, tags, category, preview);
}
@POST
@@ -907,6 +921,9 @@ public class ArbitraryResource {
@QueryParam("description") String description,
@QueryParam("tags") List tags,
@QueryParam("category") Category category,
+ @QueryParam("filename") String filename,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
String base64) {
Security.checkApiCallAllowed(request);
@@ -915,7 +932,7 @@ public class ArbitraryResource {
}
return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64, false,
- title, description, tags, category);
+ fee, filename, title, description, tags, category, preview);
}
@@ -952,6 +969,8 @@ public class ArbitraryResource {
@QueryParam("description") String description,
@QueryParam("tags") List tags,
@QueryParam("category") Category category,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
String base64Zip) {
Security.checkApiCallAllowed(request);
@@ -960,7 +979,7 @@ public class ArbitraryResource {
}
return this.upload(Service.valueOf(serviceString), name, null, null, null, base64Zip, true,
- title, description, tags, category);
+ fee, null, title, description, tags, category, preview);
}
@POST
@@ -995,6 +1014,8 @@ public class ArbitraryResource {
@QueryParam("description") String description,
@QueryParam("tags") List tags,
@QueryParam("category") Category category,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
String base64Zip) {
Security.checkApiCallAllowed(request);
@@ -1003,7 +1024,7 @@ public class ArbitraryResource {
}
return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64Zip, true,
- title, description, tags, category);
+ fee, null, title, description, tags, category, preview);
}
@@ -1043,6 +1064,9 @@ public class ArbitraryResource {
@QueryParam("description") String description,
@QueryParam("tags") List tags,
@QueryParam("category") Category category,
+ @QueryParam("filename") String filename,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
String string) {
Security.checkApiCallAllowed(request);
@@ -1051,7 +1075,7 @@ public class ArbitraryResource {
}
return this.upload(Service.valueOf(serviceString), name, null, null, string, null, false,
- title, description, tags, category);
+ fee, filename, title, description, tags, category, preview);
}
@POST
@@ -1088,6 +1112,9 @@ public class ArbitraryResource {
@QueryParam("description") String description,
@QueryParam("tags") List tags,
@QueryParam("category") Category category,
+ @QueryParam("filename") String filename,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
String string) {
Security.checkApiCallAllowed(request);
@@ -1096,15 +1123,48 @@ public class ArbitraryResource {
}
return this.upload(Service.valueOf(serviceString), name, identifier, null, string, null, false,
- title, description, tags, category);
+ fee, filename, title, description, tags, category, preview);
}
// Shared methods
+ private String preview(String directoryPath, Service service) {
+ Security.checkApiCallAllowed(request);
+ ArbitraryTransactionData.Method method = ArbitraryTransactionData.Method.PUT;
+ ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.ZIP;
+
+ ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(Paths.get(directoryPath),
+ null, service, null, method, compression,
+ null, null, null, null);
+ try {
+ arbitraryDataWriter.save();
+ } catch (IOException | DataException | InterruptedException | MissingDataException e) {
+ LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage());
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage());
+ } catch (RuntimeException e) {
+ LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage());
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
+ }
+
+ ArbitraryDataFile arbitraryDataFile = arbitraryDataWriter.getArbitraryDataFile();
+ if (arbitraryDataFile != null) {
+ String digest58 = arbitraryDataFile.digest58();
+ if (digest58 != null) {
+ // Pre-authorize resource
+ ArbitraryDataResource resource = new ArbitraryDataResource(digest58, null, null, null);
+ ArbitraryDataRenderManager.getInstance().addToAuthorizedResources(resource);
+
+ return "/render/hash/" + digest58 + "?secret=" + Base58.encode(arbitraryDataFile.getSecret());
+ }
+ }
+ return "Unable to generate preview URL";
+ }
+
private String upload(Service service, String name, String identifier,
- String path, String string, String base64, boolean zipped,
- String title, String description, List tags, Category category) {
+ String path, String string, String base64, boolean zipped, Long fee, String filename,
+ String title, String description, List tags, Category category,
+ Boolean preview) {
// Fetch public key from registered name
try (final Repository repository = RepositoryManager.getRepository()) {
NameData nameData = repository.getNameRepository().fromName(name);
@@ -1113,7 +1173,11 @@ public class ArbitraryResource {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, error);
}
- final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L);
+ final Long now = NTP.getTime();
+ if (now == null) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NO_TIME_SYNC);
+ }
+ final Long minLatestBlockTimestamp = now - (60 * 60 * 1000L);
if (!Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC);
}
@@ -1128,7 +1192,12 @@ public class ArbitraryResource {
if (path == null) {
// See if we have a string instead
if (string != null) {
- File tempFile = File.createTempFile("qortal-", ".tmp");
+ if (filename == null) {
+ // Use current time as filename
+ filename = String.format("qortal-%d", NTP.getTime());
+ }
+ java.nio.file.Path tempDirectory = Files.createTempDirectory("qortal-");
+ File tempFile = Paths.get(tempDirectory.toString(), filename).toFile();
tempFile.deleteOnExit();
BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile.toPath().toString()));
writer.write(string);
@@ -1138,7 +1207,12 @@ public class ArbitraryResource {
}
// ... or base64 encoded raw data
else if (base64 != null) {
- File tempFile = File.createTempFile("qortal-", ".tmp");
+ if (filename == null) {
+ // Use current time as filename
+ filename = String.format("qortal-%d", NTP.getTime());
+ }
+ java.nio.file.Path tempDirectory = Files.createTempDirectory("qortal-");
+ File tempFile = Paths.get(tempDirectory.toString(), filename).toFile();
tempFile.deleteOnExit();
Files.write(tempFile.toPath(), Base64.decode(base64));
path = tempFile.toPath().toString();
@@ -1161,15 +1235,25 @@ public class ArbitraryResource {
// The actual data will be in a randomly-named subfolder of tempDirectory
// Remove hidden folders, i.e. starting with "_", as some systems can add them, e.g. "__MACOSX"
String[] files = tempDirectory.toFile().list((parent, child) -> !child.startsWith("_"));
- if (files.length == 1) { // Single directory or file only
+ if (files != null && files.length == 1) { // Single directory or file only
path = Paths.get(tempDirectory.toString(), files[0]).toString();
}
}
}
+ // Finish here if user has requested a preview
+ if (preview != null && preview == true) {
+ return this.preview(path, service);
+ }
+
+ // Default to zero fee if not specified
+ if (fee == null) {
+ fee = 0L;
+ }
+
try {
ArbitraryDataTransactionBuilder transactionBuilder = new ArbitraryDataTransactionBuilder(
- repository, publicKey58, Paths.get(path), name, null, service, identifier,
+ repository, publicKey58, fee, Paths.get(path), name, null, service, identifier,
title, description, tags, category
);
@@ -1183,12 +1267,13 @@ public class ArbitraryResource {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
- } catch (DataException | IOException e) {
+ } catch (Exception e) {
+ LOGGER.info("Exception when publishing data: ", e);
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage());
}
}
- private HttpServletResponse download(Service service, String name, String identifier, String filepath, boolean rebuild, boolean async, Integer maxAttempts) {
+ private HttpServletResponse download(Service service, String name, String identifier, String filepath, String encoding, boolean rebuild, boolean async, Integer maxAttempts) {
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
try {
@@ -1231,7 +1316,7 @@ public class ArbitraryResource {
if (filepath == null || filepath.isEmpty()) {
// No file path supplied - so check if this is a single file resource
String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal");
- if (files.length == 1) {
+ if (files != null && files.length == 1) {
// This is a single file resource
filepath = files[0];
}
@@ -1241,13 +1326,50 @@ public class ArbitraryResource {
}
}
- // TODO: limit file size that can be read into memory
java.nio.file.Path path = Paths.get(outputPath.toString(), filepath);
if (!Files.exists(path)) {
String message = String.format("No file exists at filepath: %s", filepath);
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, message);
}
- byte[] data = Files.readAllBytes(path);
+
+ byte[] data;
+ int fileSize = (int)path.toFile().length();
+ int length = fileSize;
+
+ // Parse "Range" header
+ Integer rangeStart = null;
+ Integer rangeEnd = null;
+ String range = request.getHeader("Range");
+ if (range != null) {
+ range = range.replace("bytes=", "");
+ String[] parts = range.split("-");
+ rangeStart = (parts != null && parts.length > 0) ? Integer.parseInt(parts[0]) : null;
+ rangeEnd = (parts != null && parts.length > 1) ? Integer.parseInt(parts[1]) : fileSize;
+ }
+
+ if (rangeStart != null && rangeEnd != null) {
+ // We have a range, so update the requested length
+ length = rangeEnd - rangeStart;
+ }
+
+ if (length < fileSize && encoding == null) {
+ // Partial content requested, and not encoding the data
+ response.setStatus(206);
+ response.addHeader("Content-Range", String.format("bytes %d-%d/%d", rangeStart, rangeEnd-1, fileSize));
+ data = FilesystemUtils.readFromFile(path.toString(), rangeStart, length);
+ }
+ else {
+ // Full content requested (or encoded data)
+ response.setStatus(200);
+ data = Files.readAllBytes(path); // TODO: limit file size that can be read into memory
+ }
+
+ // Encode the data if requested
+ if (encoding != null && Objects.equals(encoding.toLowerCase(), "base64")) {
+ data = Base64.encode(data);
+ }
+
+ response.addHeader("Accept-Ranges", "bytes");
response.setContentType(context.getMimeType(path.toString()));
response.setContentLength(data.length);
response.getOutputStream().write(data);
@@ -1259,41 +1381,44 @@ public class ArbitraryResource {
}
}
+ private FileProperties getFileProperties(Service service, String name, String identifier) {
+ ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
+ try {
+ arbitraryDataReader.loadSynchronously(false);
+ java.nio.file.Path outputPath = arbitraryDataReader.getFilePath();
+ if (outputPath == null) {
+ // Assume the resource doesn't exist
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, "File not found");
+ }
- private List addStatusToResources(List resources) {
- // Determine and add the status of each resource
- List updatedResources = new ArrayList<>();
- for (ArbitraryResourceInfo resourceInfo : resources) {
- try {
- ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ResourceIdType.NAME,
- resourceInfo.service, resourceInfo.identifier);
- ArbitraryResourceStatus status = resource.getStatus(true);
- if (status != null) {
- resourceInfo.status = status;
+ FileProperties fileProperties = new FileProperties();
+ fileProperties.size = FileUtils.sizeOfDirectory(outputPath.toFile());
+
+ String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal");
+ if (files.length == 1) {
+ String filename = files[0];
+ java.nio.file.Path filePath = Paths.get(outputPath.toString(), files[0]);
+ ContentInfoUtil util = new ContentInfoUtil();
+ ContentInfo info = util.findMatch(filePath.toFile());
+ String mimeType;
+ if (info != null) {
+ // Attempt to extract MIME type from file contents
+ mimeType = info.getMimeType();
}
- updatedResources.add(resourceInfo);
-
- } catch (Exception e) {
- // Catch and log all exceptions, since some systems are experiencing 500 errors when including statuses
- LOGGER.info("Caught exception when adding status to resource %s: %s", resourceInfo, e.toString());
+ else {
+ // Fall back to using the filename
+ FileNameMap fileNameMap = URLConnection.getFileNameMap();
+ mimeType = fileNameMap.getContentTypeFor(filename);
+ }
+ fileProperties.filename = filename;
+ fileProperties.mimeType = mimeType;
}
- }
- return updatedResources;
- }
- private List addMetadataToResources(List resources) {
- // Add metadata fields to each resource if they exist
- List updatedResources = new ArrayList<>();
- for (ArbitraryResourceInfo resourceInfo : resources) {
- ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ResourceIdType.NAME,
- resourceInfo.service, resourceInfo.identifier);
- ArbitraryDataTransactionMetadata transactionMetadata = resource.getLatestTransactionMetadata();
- ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata);
- if (resourceMetadata != null) {
- resourceInfo.metadata = resourceMetadata;
- }
- updatedResources.add(resourceInfo);
+ return fileProperties;
+
+ } catch (Exception e) {
+ LOGGER.debug(String.format("Unable to load %s %s: %s", service, name, e.getMessage()));
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage());
}
- return updatedResources;
}
}
diff --git a/src/main/java/org/qortal/api/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java
index 29a2344d..13bfec83 100644
--- a/src/main/java/org/qortal/api/resource/AtResource.java
+++ b/src/main/java/org/qortal/api/resource/AtResource.java
@@ -27,6 +27,7 @@ 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.api.model.AtCreationRequest;
import org.qortal.data.transaction.DeployAtTransactionData;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
@@ -38,9 +39,14 @@ import org.qortal.transform.TransformationException;
import org.qortal.transform.transaction.DeployAtTransactionTransformer;
import org.qortal.utils.Base58;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
@Path("/at")
@Tag(name = "Automated Transactions")
public class AtResource {
+ private static final Logger logger = LoggerFactory.getLogger(AtResource.class);
@Context
HttpServletRequest request;
@@ -156,6 +162,52 @@ public class AtResource {
}
}
+ @POST
+ @Path("/create")
+ @Operation(
+ summary = "Create base58-encoded AT creation bytes from the provided parameters",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(
+ implementation = AtCreationRequest.class
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ description = "AT creation bytes suitable for use in a DEPLOY_AT transaction",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string"
+ )
+ )
+ )
+ }
+ )
+ public String create(AtCreationRequest atCreationRequest) {
+ if (atCreationRequest.getCiyamAtVersion() < 2) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "ciyamAtVersion must be at least 2");
+ }
+ if (atCreationRequest.getCodeBytes() == null) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Valid codeBytesBase64 must be supplied");
+ }
+ if (atCreationRequest.getDataBytes() == null) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Valid dataBytesBase64 must be supplied");
+ }
+
+ byte[] creationBytes = MachineState.toCreationBytes(
+ atCreationRequest.getCiyamAtVersion(),
+ atCreationRequest.getCodeBytes(),
+ atCreationRequest.getDataBytes(),
+ atCreationRequest.getNumCallStackPages(),
+ atCreationRequest.getNumUserStackPages(),
+ atCreationRequest.getMinActivationAmount()
+ );
+ return Base58.encode(creationBytes);
+ }
@POST
@Operation(
summary = "Build raw, unsigned, DEPLOY_AT transaction",
diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java
index 195b2ca4..ad5e466d 100644
--- a/src/main/java/org/qortal/api/resource/BlocksResource.java
+++ b/src/main/java/org/qortal/api/resource/BlocksResource.java
@@ -48,6 +48,7 @@ import org.qortal.repository.RepositoryManager;
import org.qortal.transform.TransformationException;
import org.qortal.transform.block.BlockTransformer;
import org.qortal.utils.Base58;
+import org.qortal.utils.Triple;
@Path("/blocks")
@Tag(name = "Blocks")
@@ -165,10 +166,13 @@ public class BlocksResource {
}
// Not found, so try the block archive
- byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, false, repository);
- if (bytes != null) {
- if (version != 1) {
- throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Archived blocks require version 1");
+ Triple serializedBlock = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, false, repository);
+ if (serializedBlock != null) {
+ byte[] bytes = serializedBlock.getA();
+ Integer serializationVersion = serializedBlock.getB();
+ if (version != serializationVersion) {
+ // TODO: we could quite easily reserialize the block with the requested version
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Block is not stored using requested serialization version.");
}
return Base58.encode(bytes);
}
@@ -218,14 +222,25 @@ public class BlocksResource {
}
try (final Repository repository = RepositoryManager.getRepository()) {
- // Check if the block exists in either the database or archive
- if (repository.getBlockRepository().getHeightFromSignature(signature) == 0 &&
- repository.getBlockArchiveRepository().getHeightFromSignature(signature) == 0) {
- // Not found in either the database or archive
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
- }
+ // Check if the block exists in either the database or archive
+ int height = repository.getBlockRepository().getHeightFromSignature(signature);
+ if (height == 0) {
+ height = repository.getBlockArchiveRepository().getHeightFromSignature(signature);
+ if (height == 0) {
+ // Not found in either the database or archive
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
+ }
+ }
- return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse);
+ List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height);
+
+ // Expand signatures to transactions
+ List transactions = new ArrayList<>(signatures.size());
+ for (byte[] s : signatures) {
+ transactions.add(repository.getTransactionRepository().fromSignature(s));
+ }
+
+ return transactions;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
@@ -634,13 +649,16 @@ public class BlocksResource {
@ApiErrors({
ApiError.REPOSITORY_ISSUE
})
- public List getBlockRange(@PathParam("height") int height, @Parameter(
- ref = "count"
- ) @QueryParam("count") int count) {
+ public List getBlockRange(@PathParam("height") int height,
+ @Parameter(ref = "count") @QueryParam("count") int count,
+ @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse,
+ @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) {
try (final Repository repository = RepositoryManager.getRepository()) {
List blocks = new ArrayList<>();
+ boolean shouldReverse = (reverse != null && reverse == true);
- for (/* count already set */; count > 0; --count, ++height) {
+ int i = 0;
+ while (i < count) {
BlockData blockData = repository.getBlockRepository().fromHeight(height);
if (blockData == null) {
// Not found - try the archive
@@ -650,8 +668,14 @@ public class BlocksResource {
break;
}
}
+ if (includeOnlineSignatures == null || includeOnlineSignatures == false) {
+ blockData.setOnlineAccountsSignatures(null);
+ }
blocks.add(blockData);
+
+ height = shouldReverse ? height - 1 : height + 1;
+ i++;
}
return blocks;
diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java
index ee2a8599..22e90a43 100644
--- a/src/main/java/org/qortal/api/resource/ChatResource.java
+++ b/src/main/java/org/qortal/api/resource/ChatResource.java
@@ -40,6 +40,8 @@ import org.qortal.utils.Base58;
import com.google.common.primitives.Bytes;
+import static org.qortal.data.chat.ChatMessage.Encoding;
+
@Path("/chat")
@Tag(name = "Chat")
public class ChatResource {
@@ -70,6 +72,10 @@ public class ChatResource {
@QueryParam("txGroupId") Integer txGroupId,
@QueryParam("involving") List involvingAddresses,
@QueryParam("reference") String reference,
+ @QueryParam("chatreference") String chatReference,
+ @QueryParam("haschatreference") Boolean hasChatReference,
+ @QueryParam("sender") String sender,
+ @QueryParam("encoding") Encoding encoding,
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
@@ -92,19 +98,96 @@ public class ChatResource {
if (reference != null)
referenceBytes = Base58.decode(reference);
+ byte[] chatReferenceBytes = null;
+ if (chatReference != null)
+ chatReferenceBytes = Base58.decode(chatReference);
+
try (final Repository repository = RepositoryManager.getRepository()) {
return repository.getChatRepository().getMessagesMatchingCriteria(
before,
after,
txGroupId,
referenceBytes,
+ chatReferenceBytes,
+ hasChatReference,
involvingAddresses,
+ sender,
+ encoding,
limit, offset, reverse);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
+ @GET
+ @Path("/messages/count")
+ @Operation(
+ summary = "Count chat messages",
+ description = "Returns count of CHAT messages that match criteria. Must provide EITHER 'txGroupId' OR two 'involving' addresses.",
+ responses = {
+ @ApiResponse(
+ description = "count of messages",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "integer"
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
+ public int countChatMessages(@QueryParam("before") Long before, @QueryParam("after") Long after,
+ @QueryParam("txGroupId") Integer txGroupId,
+ @QueryParam("involving") List involvingAddresses,
+ @QueryParam("reference") String reference,
+ @QueryParam("chatreference") String chatReference,
+ @QueryParam("haschatreference") Boolean hasChatReference,
+ @QueryParam("sender") String sender,
+ @QueryParam("encoding") Encoding encoding,
+ @Parameter(ref = "limit") @QueryParam("limit") Integer limit,
+ @Parameter(ref = "offset") @QueryParam("offset") Integer offset,
+ @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
+ // Check args meet expectations
+ if ((txGroupId == null && involvingAddresses.size() != 2)
+ || (txGroupId != null && !involvingAddresses.isEmpty()))
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
+
+ // Check any provided addresses are valid
+ if (involvingAddresses.stream().anyMatch(address -> !Crypto.isValidAddress(address)))
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
+
+ if (before != null && before < 1500000000000L)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
+
+ if (after != null && after < 1500000000000L)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
+
+ byte[] referenceBytes = null;
+ if (reference != null)
+ referenceBytes = Base58.decode(reference);
+
+ byte[] chatReferenceBytes = null;
+ if (chatReference != null)
+ chatReferenceBytes = Base58.decode(chatReference);
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ return repository.getChatRepository().getMessagesMatchingCriteria(
+ before,
+ after,
+ txGroupId,
+ referenceBytes,
+ chatReferenceBytes,
+ hasChatReference,
+ involvingAddresses,
+ sender,
+ encoding,
+ limit, offset, reverse).size();
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
@GET
@Path("/message/{signature}")
@Operation(
@@ -121,7 +204,7 @@ public class ChatResource {
}
)
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
- public ChatMessage getMessageBySignature(@PathParam("signature") String signature58) {
+ public ChatMessage getMessageBySignature(@PathParam("signature") String signature58, @QueryParam("encoding") Encoding encoding) {
byte[] signature = Base58.decode(signature58);
try (final Repository repository = RepositoryManager.getRepository()) {
@@ -131,7 +214,7 @@ public class ChatResource {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Message not found");
}
- return repository.getChatRepository().toChatMessage(chatTransactionData);
+ return repository.getChatRepository().toChatMessage(chatTransactionData, encoding);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
@@ -154,12 +237,12 @@ public class ChatResource {
}
)
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
- public ActiveChats getActiveChats(@PathParam("address") String address) {
+ public ActiveChats getActiveChats(@PathParam("address") String address, @QueryParam("encoding") Encoding encoding) {
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);
+ return repository.getChatRepository().getActiveChats(address, encoding);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java
index 80d19804..1e276e59 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java
@@ -14,6 +14,7 @@ import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
+import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
@@ -35,6 +36,37 @@ public class CrossChainBitcoinResource {
@Context
HttpServletRequest request;
+ @GET
+ @Path("/height")
+ @Operation(
+ summary = "Returns current Bitcoin block height",
+ description = "Returns the height of the most recent block in the Bitcoin chain.",
+ responses = {
+ @ApiResponse(
+ content = @Content(
+ schema = @Schema(
+ type = "number"
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ public String getBitcoinHeight() {
+ Bitcoin bitcoin = Bitcoin.getInstance();
+
+ try {
+ Integer height = bitcoin.getBlockchainHeight();
+ if (height == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+
+ return height.toString();
+
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/walletbalance")
@Operation(
@@ -68,7 +100,7 @@ public class CrossChainBitcoinResource {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
try {
- Long balance = bitcoin.getWalletBalanceFromTransactions(key58);
+ Long balance = bitcoin.getWalletBalance(key58);
if (balance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
@@ -118,6 +150,45 @@ public class CrossChainBitcoinResource {
}
}
+ @POST
+ @Path("/unusedaddress")
+ @Operation(
+ summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
+ description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string",
+ description = "BIP32 'm' private/public key in base58",
+ example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ @SecurityRequirement(name = "apiKey")
+ public String getUnusedBitcoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, 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.getUnusedReceiveAddress(key58);
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/send")
@Operation(
diff --git a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java
index 57049639..781d78f6 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java
@@ -14,6 +14,7 @@ import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
+import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
@@ -35,6 +36,37 @@ public class CrossChainDigibyteResource {
@Context
HttpServletRequest request;
+ @GET
+ @Path("/height")
+ @Operation(
+ summary = "Returns current Digibyte block height",
+ description = "Returns the height of the most recent block in the Digibyte chain.",
+ responses = {
+ @ApiResponse(
+ content = @Content(
+ schema = @Schema(
+ type = "number"
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ public String getDigibyteHeight() {
+ Digibyte digibyte = Digibyte.getInstance();
+
+ try {
+ Integer height = digibyte.getBlockchainHeight();
+ if (height == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+
+ return height.toString();
+
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/walletbalance")
@Operation(
@@ -68,7 +100,7 @@ public class CrossChainDigibyteResource {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
try {
- Long balance = digibyte.getWalletBalanceFromTransactions(key58);
+ Long balance = digibyte.getWalletBalance(key58);
if (balance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
@@ -118,6 +150,45 @@ public class CrossChainDigibyteResource {
}
}
+ @POST
+ @Path("/unusedaddress")
+ @Operation(
+ summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
+ description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string",
+ description = "BIP32 'm' private/public key in base58",
+ example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ @SecurityRequirement(name = "apiKey")
+ public String getUnusedDigibyteReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) {
+ Security.checkApiCallAllowed(request);
+
+ Digibyte digibyte = Digibyte.getInstance();
+
+ if (!digibyte.isValidDeterministicKey(key58))
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
+
+ try {
+ return digibyte.getUnusedReceiveAddress(key58);
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/send")
@Operation(
diff --git a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java
index 189a53d3..ff1d6d14 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java
@@ -21,6 +21,7 @@ import org.qortal.crosschain.SimpleTransaction;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
+import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
@@ -33,6 +34,37 @@ public class CrossChainDogecoinResource {
@Context
HttpServletRequest request;
+ @GET
+ @Path("/height")
+ @Operation(
+ summary = "Returns current Dogecoin block height",
+ description = "Returns the height of the most recent block in the Dogecoin chain.",
+ responses = {
+ @ApiResponse(
+ content = @Content(
+ schema = @Schema(
+ type = "number"
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ public String getDogecoinHeight() {
+ Dogecoin dogecoin = Dogecoin.getInstance();
+
+ try {
+ Integer height = dogecoin.getBlockchainHeight();
+ if (height == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+
+ return height.toString();
+
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/walletbalance")
@Operation(
@@ -66,7 +98,7 @@ public class CrossChainDogecoinResource {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
try {
- Long balance = dogecoin.getWalletBalanceFromTransactions(key58);
+ Long balance = dogecoin.getWalletBalance(key58);
if (balance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
@@ -116,6 +148,45 @@ public class CrossChainDogecoinResource {
}
}
+ @POST
+ @Path("/unusedaddress")
+ @Operation(
+ summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
+ description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string",
+ description = "BIP32 'm' private/public key in base58",
+ example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ @SecurityRequirement(name = "apiKey")
+ public String getUnusedDogecoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) {
+ Security.checkApiCallAllowed(request);
+
+ Dogecoin dogecoin = Dogecoin.getInstance();
+
+ if (!dogecoin.isValidDeterministicKey(key58))
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
+
+ try {
+ return dogecoin.getUnusedReceiveAddress(key58);
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/send")
@Operation(
diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java
index 664b013a..45b92c7c 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java
@@ -8,11 +8,10 @@ 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;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
-import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.*;
@@ -25,7 +24,6 @@ import org.bitcoinj.core.*;
import org.bitcoinj.script.Script;
import org.qortal.api.*;
import org.qortal.api.model.CrossChainBitcoinyHTLCStatus;
-import org.qortal.controller.Controller;
import org.qortal.crosschain.*;
import org.qortal.crypto.Crypto;
import org.qortal.data.at.ATData;
@@ -586,98 +584,103 @@ public class CrossChainHtlcResource {
}
List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
- TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null);
- if (tradeBotData == null)
+ List tradeBotDataList = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).collect(Collectors.toList());
+ if (tradeBotDataList == null || tradeBotDataList.isEmpty())
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
- Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain();
- int lockTime = tradeBotData.getLockTimeA();
+ // Loop through all matching entries for this AT address, as there might be more than one
+ for (TradeBotData tradeBotData : tradeBotDataList) {
- // We can't refund P2SH-A until lockTime-A has passed
- if (NTP.getTime() <= lockTime * 1000L)
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON);
+ if (tradeBotData == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
- // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
- int medianBlockTime = bitcoiny.getMedianBlockTime();
- if (medianBlockTime <= lockTime)
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON);
+ Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain();
+ int lockTime = tradeBotData.getLockTimeA();
- // Fee for redeem/refund is subtracted from P2SH-A balance.
- long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout);
- long p2shFee = bitcoiny.getP2shFee(feeTimestamp);
- long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
+ // We can't refund P2SH-A until lockTime-A has passed
+ if (NTP.getTime() <= lockTime * 1000L)
+ continue;
- // Create redeem script based on destination chain
- byte[] redeemScriptA;
- String p2shAddressA;
- BitcoinyHTLC.Status htlcStatusA;
- if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) {
- redeemScriptA = PirateChainHTLC.buildScript(tradeBotData.getTradeForeignPublicKey(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
- p2shAddressA = PirateChain.getInstance().deriveP2shAddressBPrefix(redeemScriptA);
- htlcStatusA = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
- }
- else {
- redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
- p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA);
- htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
- }
- LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA));
+ // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
+ int medianBlockTime = bitcoiny.getMedianBlockTime();
+ if (medianBlockTime <= lockTime)
+ continue;
- switch (htlcStatusA) {
- case UNFUNDED:
- case FUNDING_IN_PROGRESS:
- // Still waiting for P2SH-A to be funded...
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON);
+ // Fee for redeem/refund is subtracted from P2SH-A balance.
+ long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout);
+ long p2shFee = bitcoiny.getP2shFee(feeTimestamp);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
- case REDEEM_IN_PROGRESS:
- case REDEEMED:
- case REFUND_IN_PROGRESS:
- case REFUNDED:
- // Too late!
- return false;
+ // Create redeem script based on destination chain
+ byte[] redeemScriptA;
+ String p2shAddressA;
+ BitcoinyHTLC.Status htlcStatusA;
+ if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) {
+ redeemScriptA = PirateChainHTLC.buildScript(tradeBotData.getTradeForeignPublicKey(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
+ p2shAddressA = PirateChain.getInstance().deriveP2shAddressBPrefix(redeemScriptA);
+ htlcStatusA = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+ } else {
+ redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
+ p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA);
+ htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+ }
+ LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA));
- case FUNDED:{
- Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
+ switch (htlcStatusA) {
+ case UNFUNDED:
+ case FUNDING_IN_PROGRESS:
+ // Still waiting for P2SH-A to be funded...
+ continue;
- if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) {
- // Pirate Chain custom integration
+ case REDEEM_IN_PROGRESS:
+ case REDEEMED:
+ case REFUND_IN_PROGRESS:
+ case REFUNDED:
+ // Too late!
+ continue;
- PirateChain pirateChain = PirateChain.getInstance();
- String p2shAddressT3 = pirateChain.deriveP2shAddress(redeemScriptA);
+ case FUNDED: {
+ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
- // Get funding txid
- String fundingTxidHex = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddressA, minimumAmountA);
- if (fundingTxidHex == null) {
- throw new ForeignBlockchainException("Missing funding txid when refunding P2SH");
+ if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) {
+ // Pirate Chain custom integration
+
+ PirateChain pirateChain = PirateChain.getInstance();
+ String p2shAddressT3 = pirateChain.deriveP2shAddress(redeemScriptA);
+
+ // Get funding txid
+ String fundingTxidHex = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+ if (fundingTxidHex == null) {
+ throw new ForeignBlockchainException("Missing funding txid when refunding P2SH");
+ }
+ String fundingTxid58 = Base58.encode(HashCode.fromString(fundingTxidHex).asBytes());
+
+ byte[] privateKey = tradeBotData.getTradePrivateKey();
+ String privateKey58 = Base58.encode(privateKey);
+ String redeemScript58 = Base58.encode(redeemScriptA);
+
+ String txid = PirateChain.getInstance().refundP2sh(p2shAddressT3,
+ receiveAddress, refundAmount.value, redeemScript58, fundingTxid58, lockTime, privateKey58);
+ LOGGER.info("Refund txid: {}", txid);
+ } else {
+ // ElectrumX coins
+
+ ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA);
+
+ // Validate the destination foreign blockchain address
+ Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress);
+ if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
+
+ Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey,
+ fundingOutputs, redeemScriptA, lockTime, receiving.getHash());
+
+ bitcoiny.broadcastTransaction(p2shRefundTransaction);
}
- String fundingTxid58 = Base58.encode(HashCode.fromString(fundingTxidHex).asBytes());
- byte[] privateKey = tradeBotData.getTradePrivateKey();
- String privateKey58 = Base58.encode(privateKey);
- String redeemScript58 = Base58.encode(redeemScriptA);
-
- String txid = PirateChain.getInstance().refundP2sh(p2shAddressT3,
- receiveAddress, refundAmount.value, redeemScript58, fundingTxid58, lockTime, privateKey58);
- LOGGER.info("Refund txid: {}", txid);
+ return true;
}
- else {
- // ElectrumX coins
-
- ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
- List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA);
-
- // Validate the destination foreign blockchain address
- Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress);
- if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH)
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
-
- Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey,
- fundingOutputs, redeemScriptA, lockTime, receiving.getHash());
-
- bitcoiny.broadcastTransaction(p2shRefundTransaction);
- }
-
- return true;
}
}
diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java
index 8ac0f9a0..3e2ff799 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java
@@ -14,6 +14,7 @@ import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
+import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
@@ -35,6 +36,37 @@ public class CrossChainLitecoinResource {
@Context
HttpServletRequest request;
+ @GET
+ @Path("/height")
+ @Operation(
+ summary = "Returns current Litecoin block height",
+ description = "Returns the height of the most recent block in the Litecoin chain.",
+ responses = {
+ @ApiResponse(
+ content = @Content(
+ schema = @Schema(
+ type = "number"
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ public String getLitecoinHeight() {
+ Litecoin litecoin = Litecoin.getInstance();
+
+ try {
+ Integer height = litecoin.getBlockchainHeight();
+ if (height == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+
+ return height.toString();
+
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/walletbalance")
@Operation(
@@ -68,7 +100,7 @@ public class CrossChainLitecoinResource {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
try {
- Long balance = litecoin.getWalletBalanceFromTransactions(key58);
+ Long balance = litecoin.getWalletBalance(key58);
if (balance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
@@ -118,6 +150,45 @@ public class CrossChainLitecoinResource {
}
}
+ @POST
+ @Path("/unusedaddress")
+ @Operation(
+ summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
+ description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string",
+ description = "BIP32 'm' private/public key in base58",
+ example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ @SecurityRequirement(name = "apiKey")
+ public String getUnusedLitecoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, 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.getUnusedReceiveAddress(key58);
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/send")
@Operation(
diff --git a/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java
index bd7bf57d..6989e7c7 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java
@@ -20,6 +20,7 @@ import org.qortal.crosschain.SimpleTransaction;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
+import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
@@ -32,6 +33,37 @@ public class CrossChainPirateChainResource {
@Context
HttpServletRequest request;
+ @GET
+ @Path("/height")
+ @Operation(
+ summary = "Returns current PirateChain block height",
+ description = "Returns the height of the most recent block in the PirateChain chain.",
+ responses = {
+ @ApiResponse(
+ content = @Content(
+ schema = @Schema(
+ type = "number"
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ public String getPirateChainHeight() {
+ PirateChain pirateChain = PirateChain.getInstance();
+
+ try {
+ Integer height = pirateChain.getBlockchainHeight();
+ if (height == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+
+ return height.toString();
+
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/walletbalance")
@Operation(
diff --git a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java
index 756b0bb5..b1d6aed4 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java
@@ -14,6 +14,7 @@ import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
+import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
@@ -35,6 +36,37 @@ public class CrossChainRavencoinResource {
@Context
HttpServletRequest request;
+ @GET
+ @Path("/height")
+ @Operation(
+ summary = "Returns current Ravencoin block height",
+ description = "Returns the height of the most recent block in the Ravencoin chain.",
+ responses = {
+ @ApiResponse(
+ content = @Content(
+ schema = @Schema(
+ type = "number"
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ public String getRavencoinHeight() {
+ Ravencoin ravencoin = Ravencoin.getInstance();
+
+ try {
+ Integer height = ravencoin.getBlockchainHeight();
+ if (height == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+
+ return height.toString();
+
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/walletbalance")
@Operation(
@@ -68,7 +100,7 @@ public class CrossChainRavencoinResource {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
try {
- Long balance = ravencoin.getWalletBalanceFromTransactions(key58);
+ Long balance = ravencoin.getWalletBalance(key58);
if (balance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
@@ -118,6 +150,45 @@ public class CrossChainRavencoinResource {
}
}
+ @POST
+ @Path("/unusedaddress")
+ @Operation(
+ summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
+ description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string",
+ description = "BIP32 'm' private/public key in base58",
+ example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
+ @SecurityRequirement(name = "apiKey")
+ public String getUnusedRavencoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) {
+ Security.checkApiCallAllowed(request);
+
+ Ravencoin ravencoin = Ravencoin.getInstance();
+
+ if (!ravencoin.isValidDeterministicKey(key58))
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
+
+ try {
+ return ravencoin.getUnusedReceiveAddress(key58);
+ } catch (ForeignBlockchainException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
+ }
+ }
+
@POST
@Path("/send")
@Operation(
diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java
index bb7c70a5..44ef62ad 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java
@@ -115,6 +115,9 @@ public class CrossChainResource {
crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp));
}
+ // Remove any trades that have had too many failures
+ crossChainTrades = TradeBot.getInstance().removeFailedTrades(repository, crossChainTrades);
+
if (limit != null && limit > 0) {
// Make sure to not return more than the limit
int upperLimit = Math.min(limit, crossChainTrades.size());
@@ -129,6 +132,64 @@ public class CrossChainResource {
}
}
+ @GET
+ @Path("/tradeoffers/hidden")
+ @Operation(
+ summary = "Find cross-chain trade offers that have been hidden due to too many failures",
+ responses = {
+ @ApiResponse(
+ content = @Content(
+ array = @ArraySchema(
+ schema = @Schema(
+ implementation = CrossChainTradeData.class
+ )
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
+ public List getHiddenTradeOffers(
+ @Parameter(
+ description = "Limit to specific blockchain",
+ example = "LITECOIN",
+ schema = @Schema(implementation = SupportedBlockchain.class)
+ ) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain) {
+
+ final boolean isExecutable = true;
+ List crossChainTrades = new ArrayList<>();
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain);
+
+ for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) {
+ byte[] codeHash = acctInfo.getKey().value;
+ ACCT acct = acctInfo.getValue().get();
+
+ List atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, null, null, null);
+
+ for (ATData atData : atsData) {
+ CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
+ if (crossChainTradeData.mode == AcctMode.OFFERING) {
+ crossChainTrades.add(crossChainTradeData);
+ }
+ }
+ }
+
+ // Sort the trades by timestamp
+ crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp));
+
+ // Remove trades that haven't failed
+ crossChainTrades.removeIf(t -> !TradeBot.getInstance().isFailedTrade(repository, t));
+
+ crossChainTrades.stream().forEach(CrossChainResource::decorateTradeDataWithPresence);
+
+ return crossChainTrades;
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
@GET
@Path("/trade/{ataddress}")
@Operation(
diff --git a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java
index 3c8bd28f..aefca016 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java
@@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
+import java.util.Objects;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
@@ -38,9 +39,12 @@ import org.qortal.crypto.Crypto;
import org.qortal.data.at.ATData;
import org.qortal.data.crosschain.CrossChainTradeData;
import org.qortal.data.crosschain.TradeBotData;
+import org.qortal.data.transaction.MessageTransactionData;
+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.Transaction;
import org.qortal.utils.Base58;
import org.qortal.utils.NTP;
@@ -223,6 +227,17 @@ public class CrossChainTradeBotResource {
if (crossChainTradeData.mode != AcctMode.OFFERING)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
+ // Check if there is a buy or a cancel request in progress for this trade
+ List txTypes = List.of(Transaction.TransactionType.MESSAGE);
+ List unconfirmed = repository.getTransactionRepository().getUnconfirmedTransactions(txTypes, null, 0, 0, false);
+ for (TransactionData transactionData : unconfirmed) {
+ MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData;
+ if (Objects.equals(messageTransactionData.getRecipient(), atAddress)) {
+ // There is a pending request for this trade, so block this buy attempt to reduce the risk of refunds
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Trade has an existing buy request or is pending cancellation.");
+ }
+ }
+
AcctTradeBot.ResponseResult result = TradeBot.getInstance().startResponse(repository, atData, acct, crossChainTradeData,
tradeBotRespondRequest.foreignKey, tradeBotRespondRequest.receivingAddress);
diff --git a/src/main/java/org/qortal/api/resource/DeveloperResource.java b/src/main/java/org/qortal/api/resource/DeveloperResource.java
new file mode 100644
index 00000000..ba534502
--- /dev/null
+++ b/src/main/java/org/qortal/api/resource/DeveloperResource.java
@@ -0,0 +1,96 @@
+package org.qortal.api.resource;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.qortal.api.ApiError;
+import org.qortal.api.ApiErrors;
+import org.qortal.api.ApiExceptionFactory;
+import org.qortal.controller.DevProxyManager;
+import org.qortal.repository.DataException;
+
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+
+
+@Path("/developer")
+@Tag(name = "Developer Tools")
+public class DeveloperResource {
+
+ @Context HttpServletRequest request;
+ @Context HttpServletResponse response;
+ @Context ServletContext context;
+
+
+ @POST
+ @Path("/proxy/start")
+ @Operation(
+ summary = "Start proxy server, for real time QDN app/website development",
+ requestBody = @RequestBody(
+ description = "Host and port of source webserver to be proxied",
+ required = true,
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string",
+ example = "127.0.0.1:5173"
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ description = "Port number of running server",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "number"
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_CRITERIA})
+ public Integer startProxy(String sourceHostAndPort) {
+ // TODO: API key
+ DevProxyManager devProxyManager = DevProxyManager.getInstance();
+ try {
+ devProxyManager.setSourceHostAndPort(sourceHostAndPort);
+ devProxyManager.start();
+ return devProxyManager.getPort();
+
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage());
+ }
+ }
+
+ @POST
+ @Path("/proxy/stop")
+ @Operation(
+ summary = "Stop proxy server",
+ responses = {
+ @ApiResponse(
+ description = "true if stopped",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "boolean"
+ )
+ )
+ )
+ }
+ )
+ public boolean stopProxy() {
+ DevProxyManager devProxyManager = DevProxyManager.getInstance();
+ devProxyManager.stop();
+ return !devProxyManager.isRunning();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/qortal/api/resource/NamesResource.java b/src/main/java/org/qortal/api/resource/NamesResource.java
index a900d6bf..6cde26b3 100644
--- a/src/main/java/org/qortal/api/resource/NamesResource.java
+++ b/src/main/java/org/qortal/api/resource/NamesResource.java
@@ -47,6 +47,7 @@ import org.qortal.transform.transaction.RegisterNameTransactionTransformer;
import org.qortal.transform.transaction.SellNameTransactionTransformer;
import org.qortal.transform.transaction.UpdateNameTransactionTransformer;
import org.qortal.utils.Base58;
+import org.qortal.utils.Unicode;
@Path("/names")
@Tag(name = "Names")
@@ -63,19 +64,19 @@ public class NamesResource {
description = "registered name info",
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
- array = @ArraySchema(schema = @Schema(implementation = NameSummary.class))
+ array = @ArraySchema(schema = @Schema(implementation = NameData.class))
)
)
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
- public List getAllNames(@Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset,
- @Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) {
+ public List getAllNames(@Parameter(description = "Return only names registered or updated after timestamp") @QueryParam("after") Long after,
+ @Parameter(ref = "limit") @QueryParam("limit") Integer limit,
+ @Parameter(ref = "offset") @QueryParam("offset") Integer offset,
+ @Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) {
try (final Repository repository = RepositoryManager.getRepository()) {
- List names = repository.getNameRepository().getAllNames(limit, offset, reverse);
- // Convert to summary
- return names.stream().map(NameSummary::new).collect(Collectors.toList());
+ return repository.getNameRepository().getAllNames(after, limit, offset, reverse);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
@@ -135,12 +136,13 @@ public class NamesResource {
public NameData getName(@PathParam("name") String name) {
try (final Repository repository = RepositoryManager.getRepository()) {
NameData nameData;
+ String reducedName = Unicode.sanitize(name);
if (Settings.getInstance().isLite()) {
nameData = LiteNode.getInstance().fetchNameData(name);
}
else {
- nameData = repository.getNameRepository().fromName(name);
+ nameData = repository.getNameRepository().fromReducedName(reducedName);
}
if (nameData == null) {
@@ -155,6 +157,41 @@ public class NamesResource {
}
}
+ @GET
+ @Path("/search")
+ @Operation(
+ summary = "Search registered names",
+ responses = {
+ @ApiResponse(
+ description = "registered name info",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ array = @ArraySchema(schema = @Schema(implementation = NameData.class))
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.NAME_UNKNOWN, ApiError.REPOSITORY_ISSUE})
+ public List searchNames(@QueryParam("query") String query,
+ @Parameter(description = "Prefix only (if true, only the beginning of the name is matched)") @QueryParam("prefix") Boolean prefixOnly,
+ @Parameter(ref = "limit") @QueryParam("limit") Integer limit,
+ @Parameter(ref = "offset") @QueryParam("offset") Integer offset,
+ @Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) {
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ if (query == null) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Missing query");
+ }
+
+ boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly);
+
+ return repository.getNameRepository().searchNames(query, usePrefixOnly, limit, offset, reverse);
+ } catch (ApiException e) {
+ throw e;
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
@POST
@Path("/register")
@@ -410,4 +447,4 @@ public class NamesResource {
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/org/qortal/api/resource/PollsResource.java b/src/main/java/org/qortal/api/resource/PollsResource.java
new file mode 100644
index 00000000..c64a8caf
--- /dev/null
+++ b/src/main/java/org/qortal/api/resource/PollsResource.java
@@ -0,0 +1,258 @@
+package org.qortal.api.resource;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.qortal.api.ApiError;
+import org.qortal.api.ApiErrors;
+import org.qortal.api.ApiExceptionFactory;
+import org.qortal.data.transaction.CreatePollTransactionData;
+import org.qortal.data.transaction.PaymentTransactionData;
+import org.qortal.data.transaction.VoteOnPollTransactionData;
+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.transform.TransformationException;
+import org.qortal.transform.transaction.CreatePollTransactionTransformer;
+import org.qortal.transform.transaction.PaymentTransactionTransformer;
+import org.qortal.transform.transaction.VoteOnPollTransactionTransformer;
+import org.qortal.utils.Base58;
+
+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 io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.ArraySchema;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.ws.rs.GET;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.QueryParam;
+import org.qortal.api.ApiException;
+import org.qortal.api.model.PollVotes;
+import org.qortal.data.voting.PollData;
+import org.qortal.data.voting.PollOptionData;
+import org.qortal.data.voting.VoteOnPollData;
+
+@Path("/polls")
+@Tag(name = "Polls")
+public class PollsResource {
+ @Context
+ HttpServletRequest request;
+
+ @GET
+ @Operation(
+ summary = "List all polls",
+ responses = {
+ @ApiResponse(
+ description = "poll info",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ array = @ArraySchema(schema = @Schema(implementation = PollData.class))
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.REPOSITORY_ISSUE})
+ public List getAllPolls(@Parameter(
+ ref = "limit"
+ ) @QueryParam("limit") Integer limit, @Parameter(
+ ref = "offset"
+ ) @QueryParam("offset") Integer offset, @Parameter(
+ ref = "reverse"
+ ) @QueryParam("reverse") Boolean reverse) {
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ List allPollData = repository.getVotingRepository().getAllPolls(limit, offset, reverse);
+ return allPollData;
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+ @GET
+ @Path("/{pollName}")
+ @Operation(
+ summary = "Info on poll",
+ responses = {
+ @ApiResponse(
+ description = "poll info",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(implementation = PollData.class)
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.REPOSITORY_ISSUE})
+ public PollData getPollData(@PathParam("pollName") String pollName) {
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ PollData pollData = repository.getVotingRepository().fromPollName(pollName);
+ if (pollData == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.POLL_NO_EXISTS);
+
+ return pollData;
+ } catch (ApiException e) {
+ throw e;
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+ @GET
+ @Path("/votes/{pollName}")
+ @Operation(
+ summary = "Votes on poll",
+ responses = {
+ @ApiResponse(
+ description = "poll votes",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(implementation = PollVotes.class)
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.REPOSITORY_ISSUE})
+ public PollVotes getPollVotes(@PathParam("pollName") String pollName, @QueryParam("onlyCounts") Boolean onlyCounts) {
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ PollData pollData = repository.getVotingRepository().fromPollName(pollName);
+ if (pollData == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.POLL_NO_EXISTS);
+
+ List votes = repository.getVotingRepository().getVotes(pollName);
+
+ // Initialize map for counting votes
+ Map voteCountMap = new HashMap<>();
+ for (PollOptionData optionData : pollData.getPollOptions()) {
+ voteCountMap.put(optionData.getOptionName(), 0);
+ }
+
+ int totalVotes = 0;
+ for (VoteOnPollData vote : votes) {
+ String selectedOption = pollData.getPollOptions().get(vote.getOptionIndex()).getOptionName();
+ if (voteCountMap.containsKey(selectedOption)) {
+ voteCountMap.put(selectedOption, voteCountMap.get(selectedOption) + 1);
+ totalVotes++;
+ }
+ }
+
+ // Convert map to list of VoteInfo
+ List voteCounts = voteCountMap.entrySet().stream()
+ .map(entry -> new PollVotes.OptionCount(entry.getKey(), entry.getValue()))
+ .collect(Collectors.toList());
+
+ if (onlyCounts != null && onlyCounts) {
+ return new PollVotes(null, totalVotes, voteCounts);
+ } else {
+ return new PollVotes(votes, totalVotes, voteCounts);
+ }
+ } catch (ApiException e) {
+ throw e;
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+ @POST
+ @Path("/create")
+ @Operation(
+ summary = "Build raw, unsigned, CREATE_POLL transaction",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(
+ implementation = CreatePollTransactionData.class
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ description = "raw, unsigned, CREATE_POLL 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 CreatePoll(CreatePollTransactionData 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);
+
+ Transaction.ValidationResult result = transaction.isValidUnconfirmed();
+ if (result != Transaction.ValidationResult.OK)
+ throw TransactionsResource.createTransactionInvalidException(request, result);
+
+ byte[] bytes = CreatePollTransactionTransformer.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("/vote")
+ @Operation(
+ summary = "Build raw, unsigned, VOTE_ON_POLL transaction",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(
+ implementation = VoteOnPollTransactionData.class
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ description = "raw, unsigned, VOTE_ON_POLL 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 VoteOnPoll(VoteOnPollTransactionData 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);
+
+ Transaction.ValidationResult result = transaction.isValidUnconfirmed();
+ if (result != Transaction.ValidationResult.OK)
+ throw TransactionsResource.createTransactionInvalidException(request, result);
+
+ byte[] bytes = VoteOnPollTransactionTransformer.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);
+ }
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/resource/StatsResource.java b/src/main/java/org/qortal/api/resource/StatsResource.java
new file mode 100644
index 00000000..c1588490
--- /dev/null
+++ b/src/main/java/org/qortal/api/resource/StatsResource.java
@@ -0,0 +1,70 @@
+package org.qortal.api.resource;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.qortal.api.*;
+import org.qortal.block.BlockChain;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+import org.qortal.repository.RepositoryManager;
+import org.qortal.utils.Amounts;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.*;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Path("/stats")
+@Tag(name = "Stats")
+public class StatsResource {
+
+ private static final Logger LOGGER = LogManager.getLogger(StatsResource.class);
+
+
+ @Context
+ HttpServletRequest request;
+
+ @GET
+ @Path("/supply/circulating")
+ @Operation(
+ summary = "Fetch circulating QORT supply",
+ responses = {
+ @ApiResponse(
+ description = "circulating supply of QORT",
+ content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", format = "number"))
+ )
+ }
+ )
+ public BigDecimal circulatingSupply() {
+ long total = 0L;
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ int currentHeight = repository.getBlockRepository().getBlockchainHeight();
+
+ List rewardsByHeight = BlockChain.getInstance().getBlockRewardsByHeight();
+ int rewardIndex = rewardsByHeight.size() - 1;
+ BlockChain.RewardByHeight rewardInfo = rewardsByHeight.get(rewardIndex);
+
+ for (int height = currentHeight; height > 1; --height) {
+ if (height < rewardInfo.height) {
+ --rewardIndex;
+ rewardInfo = rewardsByHeight.get(rewardIndex);
+ }
+
+ total += rewardInfo.reward;
+ }
+
+ return Amounts.toBigDecimal(total);
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/resource/TransactionsResource.java b/src/main/java/org/qortal/api/resource/TransactionsResource.java
index 2b9b28a1..61cef867 100644
--- a/src/main/java/org/qortal/api/resource/TransactionsResource.java
+++ b/src/main/java/org/qortal/api/resource/TransactionsResource.java
@@ -9,6 +9,8 @@ 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.io.IOException;
+import java.io.StringWriter;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
@@ -18,19 +20,12 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
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.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import org.qortal.account.PrivateKeyAccount;
-import org.qortal.api.ApiError;
-import org.qortal.api.ApiErrors;
-import org.qortal.api.ApiException;
-import org.qortal.api.ApiExceptionFactory;
+import org.qortal.api.*;
import org.qortal.api.model.SimpleTransactionSignRequest;
import org.qortal.controller.Controller;
import org.qortal.controller.LiteNode;
@@ -220,10 +215,25 @@ public class TransactionsResource {
}
try (final Repository repository = RepositoryManager.getRepository()) {
- if (repository.getBlockRepository().getHeightFromSignature(signature) == 0)
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
+ // Check if the block exists in either the database or archive
+ int height = repository.getBlockRepository().getHeightFromSignature(signature);
+ if (height == 0) {
+ height = repository.getBlockArchiveRepository().getHeightFromSignature(signature);
+ if (height == 0) {
+ // Not found in either the database or archive
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
+ }
+ }
- return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse);
+ List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height);
+
+ // Expand signatures to transactions
+ List transactions = new ArrayList<>(signatures.size());
+ for (byte[] s : signatures) {
+ transactions.add(repository.getTransactionRepository().fromSignature(s));
+ }
+
+ return transactions;
} catch (ApiException e) {
throw e;
} catch (DataException e) {
@@ -709,7 +719,7 @@ public class TransactionsResource {
),
responses = {
@ApiResponse(
- description = "true if accepted, false otherwise",
+ description = "For API version 1, this returns true if accepted.\nFor API version 2, the transactionData is returned as a JSON string if accepted.",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
@@ -722,7 +732,9 @@ public class TransactionsResource {
@ApiErrors({
ApiError.BLOCKCHAIN_NEEDS_SYNC, ApiError.INVALID_SIGNATURE, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE
})
- public String processTransaction(String rawBytes58) {
+ public String processTransaction(String rawBytes58, @HeaderParam(ApiService.API_VERSION_HEADER) String apiVersionHeader) {
+ int apiVersion = ApiService.getApiVersion(request);
+
// Only allow a transaction to be processed if our latest block is less than 60 minutes old
// If older than this, we should first wait until the blockchain is synced
final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L);
@@ -759,13 +771,27 @@ public class TransactionsResource {
blockchainLock.unlock();
}
- return "true";
+ switch (apiVersion) {
+ case 1:
+ return "true";
+
+ case 2:
+ default:
+ // Marshall transactionData to string
+ StringWriter stringWriter = new StringWriter();
+ ApiRequest.marshall(stringWriter, transactionData);
+ return stringWriter.toString();
+ }
+
+
} catch (NumberFormatException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
} catch (InterruptedException e) {
throw createTransactionInvalidException(request, ValidationResult.NO_BLOCKCHAIN_LOCK);
+ } catch (IOException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
}
}
diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java
similarity index 83%
rename from src/main/java/org/qortal/api/resource/AdminResource.java
rename to src/main/java/org/qortal/api/restricted/resource/AdminResource.java
index 9cff1bbb..ecb8c6c9 100644
--- a/src/main/java/org/qortal/api/resource/AdminResource.java
+++ b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java
@@ -1,4 +1,4 @@
-package org.qortal.api.resource;
+package org.qortal.api.restricted.resource;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -20,6 +20,7 @@ import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
+import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -31,10 +32,13 @@ import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
+import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.appender.RollingFileAppender;
+import org.json.JSONArray;
+import org.json.JSONObject;
import org.qortal.account.Account;
import org.qortal.account.PrivateKeyAccount;
import org.qortal.api.*;
@@ -42,9 +46,11 @@ 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.AutoUpdate;
import org.qortal.controller.Controller;
import org.qortal.controller.Synchronizer;
import org.qortal.controller.Synchronizer.SynchronizationResult;
+import org.qortal.controller.repository.BlockArchiveRebuilder;
import org.qortal.data.account.MintingAccountData;
import org.qortal.data.account.RewardShareData;
import org.qortal.network.Network;
@@ -152,6 +158,53 @@ public class AdminResource {
return nodeStatus;
}
+ @GET
+ @Path("/settings")
+ @Operation(
+ summary = "Fetch node settings",
+ responses = {
+ @ApiResponse(
+ content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Settings.class))
+ )
+ }
+ )
+ public Settings settings() {
+ Settings nodeSettings = Settings.getInstance();
+
+ return nodeSettings;
+ }
+
+ @GET
+ @Path("/settings/{setting}")
+ @Operation(
+ summary = "Fetch a single node setting",
+ responses = {
+ @ApiResponse(
+ content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
+ )
+ }
+ )
+ public String setting(@PathParam("setting") String setting) {
+ try {
+ Object settingValue = FieldUtils.readField(Settings.getInstance(), setting, true);
+ if (settingValue == null) {
+ return "null";
+ }
+ else if (settingValue instanceof String[]) {
+ JSONArray array = new JSONArray(settingValue);
+ return array.toString(4);
+ }
+ else if (settingValue instanceof List) {
+ JSONArray array = new JSONArray((List